From 2429a41b2fd5c7b869a96ae9ab47016c8e9111a5 Mon Sep 17 00:00:00 2001 From: nathan Date: Sun, 12 Mar 2023 19:31:55 +0800 Subject: [PATCH] feat: add login test --- sqlx-data.json | 53 +++++++++++++++++++++++++++---- src/api/user.rs | 33 +++++++++++++------ src/component/auth/password.rs | 16 ++++++---- src/component/auth/user.rs | 58 ++++++++++++++++++++++++++++------ tests/api/register.rs | 26 +++++++++++++++ tests/api/test_server.rs | 49 ++++++++++++++++++++++------ 6 files changed, 194 insertions(+), 41 deletions(-) diff --git a/sqlx-data.json b/sqlx-data.json index 1413c1da..53978b56 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1,19 +1,58 @@ { "db": "PostgreSQL", - "e8c487b4314c267f6da2667b95f6c8003fabc2461c10df2d6d39d081e74e167f": { - "query": "\n INSERT INTO user (uid, username, password, email,create_time)\n VALUES ($1, $2, $3, $4, $5)\n ", + "1775f308cda63b61c0aaf4d8d1c7cf00f6bff5041e821575f65867c32d42f1b0": { "describe": { "columns": [], + "nullable": [], "parameters": { "Left": [ "Uuid", "Text", "Text", - "Text", - "Timestamptz" + "Timestamptz", + "Text" ] - }, - "nullable": [] - } + } + }, + "query": "\n INSERT INTO users (uid, email, username, create_time, password)\n VALUES ($1, $2, $3, $4, $5)\n " + }, + "b188bb8915fa0fc8c5dfefa27f1b086b839633c3c4dc6e4991b43a608d46a170": { + "describe": { + "columns": [ + { + "name": "uid", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "password", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT uid, password\n FROM users\n WHERE email = $1\n " + }, + "b754835f0a1543fc32acbfee436f775e542036888e7a2051622efa435debb318": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + } + }, + "query": "\n UPDATE users\n SET password= $1\n WHERE uid = $2\n " } } \ No newline at end of file diff --git a/src/api/user.rs b/src/api/user.rs index 8adf9bd3..88f4dc2b 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -1,5 +1,5 @@ use crate::component::auth::{ - register_user, InputParamsError, LoginRequest, RegisterRequestParams, + login, register, InputParamsError, LoginRequest, RegisterRequestParams, }; use crate::domain::{UserEmail, UserName, UserPassword}; use crate::state::State; @@ -10,21 +10,36 @@ use actix_web::{web, HttpResponse, Scope}; pub fn user_scope() -> Scope { web::scope("/api/user") - .service(web::resource("/login").route(web::post().to(login))) - .service(web::resource("/logout").route(web::get().to(logout))) - .service(web::resource("/register").route(web::post().to(register))) + .service(web::resource("/login").route(web::post().to(login_handler))) + .service(web::resource("/logout").route(web::get().to(logout_handler))) + .service(web::resource("/register").route(web::post().to(register_handler))) } -async fn login(id: Identity, req: Data, state: Data) -> Result { - todo!() +async fn login_handler(req: Json, state: Data) -> Result { + let params = req.into_inner(); + let email = UserEmail::parse(params.email) + .map_err(|e| InputParamsError::InvalidEmail(e))? + .0; + let password = UserPassword::parse(params.password) + .map_err(|_| InputParamsError::InvalidPassword)? + .0; + let resp = login(state.pg_pool.clone(), state.cache.clone(), email, password).await?; + Ok(HttpResponse::Ok().json(resp)) } -async fn logout(payload: Payload, id: Identity, state: Data) -> Result { +async fn logout_handler( + payload: Payload, + id: Identity, + state: Data, +) -> Result { todo!() } #[tracing::instrument(level = "debug", skip(state))] -async fn register(req: Json, state: Data) -> Result { +async fn register_handler( + req: Json, + state: Data, +) -> Result { let params = req.into_inner(); let name = UserName::parse(params.name) .map_err(|e| InputParamsError::InvalidName(e))? @@ -36,7 +51,7 @@ async fn register(req: Json, state: Data) -> Resul .map_err(|_| InputParamsError::InvalidPassword)? .0; - let resp = register_user( + let resp = register( state.pg_pool.clone(), state.cache.clone(), name, diff --git a/src/component/auth/password.rs b/src/component/auth/password.rs index 005e185f..83930467 100644 --- a/src/component/auth/password.rs +++ b/src/component/auth/password.rs @@ -1,3 +1,4 @@ +use crate::component::auth::AuthError; use crate::telemetry::spawn_blocking_with_tracing; use anyhow::Context; use argon2::password_hash::SaltString; @@ -6,7 +7,7 @@ use secrecy::{ExposeSecret, Secret}; use sqlx::PgPool; pub struct Credentials { - pub username: String, + pub email: String, pub password: Secret, } @@ -14,8 +15,8 @@ pub struct Credentials { pub async fn validate_credentials( credentials: Credentials, pool: &PgPool, -) -> Result { - let mut user_id = None; +) -> Result { + let mut uid = None; let mut expected_hash_password = Secret::new( "$argon2id$v=19$m=15000,t=2,p=1$\ gZiV/M1gPc22ElAH/Jh1Hw$\ @@ -23,10 +24,10 @@ pub async fn validate_credentials( .to_string(), ); - if let Some((stored_user_id, stored_hash_password)) = - get_stored_credentials(&credentials.username, pool).await? + if let Some((stored_uid, stored_hash_password)) = + get_stored_credentials(&credentials.email, pool).await? { - user_id = Some(stored_user_id); + uid = Some(stored_uid); expected_hash_password = stored_hash_password; } @@ -36,7 +37,8 @@ pub async fn validate_credentials( .await .context("Failed to spawn blocking task.")??; - user_id.ok_or_else(|| anyhow::anyhow!("Unknown username.")) + uid.ok_or_else(|| anyhow::anyhow!("Unknown email.")) + .map_err(AuthError::InvalidCredentials) } #[tracing::instrument(name = "Change password", skip(password, pool))] diff --git a/src/component/auth/user.rs b/src/component/auth/user.rs index 350fba06..01727342 100644 --- a/src/component/auth/user.rs +++ b/src/component/auth/user.rs @@ -1,20 +1,56 @@ -use crate::component::auth::{compute_hash_password, internal_error, AuthError}; +use crate::component::auth::{ + compute_hash_password, internal_error, validate_credentials, AuthError, Credentials, +}; use crate::config::env::{domain, jwt_secret}; use crate::state::Cache; use actix_web::http::header::HeaderValue; use actix_web::{FromRequest, HttpRequest}; -use anyhow::Context; +use anyhow::{Context, Error}; use chrono::Utc; use chrono::{Duration, Local}; use futures_util::future::{ready, Ready}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use secrecy::Secret; use serde::{Deserialize, Serialize}; use sqlx::types::uuid; use sqlx::{PgPool, Postgres, Transaction}; use std::sync::Arc; use tokio::sync::RwLock; +use uuid::Uuid; -pub async fn register_user( +pub async fn login( + pg_pool: PgPool, + cache: Arc>, + email: String, + password: String, +) -> Result { + let credentials = Credentials { + email, + password: Secret::new(password), + }; + + match validate_credentials(credentials, &pg_pool).await? { + Ok(uid) => { + let uid = uid.to_string(); + let token = Token::create_token(&uid)?.into(); + let logged_user = LoggedUser::new(uid); + cache.write().await.authorized(logged_user); + + Ok(LoginResponse { + token, + user_id: uid, + }) + } + Err(err) => Err(err), + } +} + +pub async fn logout(logged_user: LoggedUser, cache: Arc>) -> Result<(), AuthError> { + cache.write().await.unauthorized(logged_user); + Ok(()) +} + +pub async fn register( pg_pool: PgPool, cache: Arc>, username: String, @@ -87,6 +123,12 @@ pub struct LoginRequest { pub name: String, } +#[derive(Default, Serialize, Deserialize, Debug)] +pub struct LoginResponse { + pub token: String, + pub user_id: String, +} + #[derive(Default, Serialize, Deserialize, Debug)] pub struct RegisterRequestParams { pub email: String, @@ -101,20 +143,18 @@ pub struct RegisterResponse { #[derive(Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] pub struct LoggedUser { - pub user_id: String, + pub uid: String, } impl std::convert::From for LoggedUser { fn from(c: Claim) -> Self { - Self { - user_id: c.user_id(), - } + Self { uid: c.user_id() } } } impl LoggedUser { pub fn new(user_id: String) -> Self { - Self { user_id } + Self { uid: user_id } } pub fn from_token(token: String) -> Result { @@ -123,7 +163,7 @@ impl LoggedUser { } pub fn as_uuid(&self) -> Result { - let uuid = uuid::Uuid::parse_str(&self.user_id).context("Invalid uuid")?; + let uuid = uuid::Uuid::parse_str(&self.uid).context("Invalid uuid")?; Ok(uuid) } } diff --git a/tests/api/register.rs b/tests/api/register.rs index b63807d6..14c89668 100644 --- a/tests/api/register.rs +++ b/tests/api/register.rs @@ -1,5 +1,6 @@ use crate::test_server::spawn_server; use appflowy_server::component::auth::RegisterResponse; +use reqwest::StatusCode; #[tokio::test] // curl -X POST --url http://0.0.0.0:8000/api/user/register --header 'content-type: application/json' --data '{"name":"fake name", "email":"fake@appflowy.io", "password":"Fake@123"}' @@ -14,3 +15,28 @@ async fn register_success() { println!("{:?}", response); } + +#[tokio::test] +async fn register_with_invalid_password() { + let server = spawn_server().await; + let http_resp = server.register("user 1", "fake@appflowy.io", "123").await; + assert_eq!(http_resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn register_with_invalid_name() { + let server = spawn_server().await; + let http_resp = server + .register("", "fake@appflowy.io", "FakePassword!123") + .await; + assert_eq!(http_resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn register_with_invalid_email() { + let server = spawn_server().await; + let http_resp = server + .register("me", "appflowy.io", "FakePassword!123") + .await; + assert_eq!(http_resp.status(), StatusCode::BAD_REQUEST); +} diff --git a/tests/api/test_server.rs b/tests/api/test_server.rs index 01a5c2df..c9fa1ac6 100644 --- a/tests/api/test_server.rs +++ b/tests/api/test_server.rs @@ -9,9 +9,13 @@ use sqlx::{Connection, Executor, PgConnection, PgPool}; // Ensure that the `tracing` stack is only initialised once using `once_cell` static TRACING: Lazy<()> = Lazy::new(|| { - let default_filter_level = "info".to_string(); + let level = "trace".to_string(); + let mut filters = vec![]; + filters.push(format!("appflowy_server={}", level)); + filters.push(format!("hyper={}", level)); + let subscriber_name = "test".to_string(); - let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); + let subscriber = get_subscriber(subscriber_name, filters.join(","), std::io::stdout); init_subscriber(subscriber); }); @@ -42,7 +46,6 @@ impl TestServer { pub async fn spawn_server() -> TestServer { Lazy::force(&TRACING); - let database_name = Uuid::new_v4().to_string(); let config = { let mut config = get_configuration().expect("Failed to read configuration."); @@ -58,18 +61,18 @@ pub async fn spawn_server() -> TestServer { .await .expect("Failed to build application."); + let port = application.port(); + let address = format!("http://localhost:{}", port); + let _ = tokio::spawn(async { + let _ = application.run_until_stopped().await; + }); + let api_client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .cookie_store(true) .build() .unwrap(); - let port = application.port(); - let address = format!("http://localhost:{}", port); - let _ = tokio::spawn(async { - let _ = application.run_until_stopped(); - }); - TestServer { state, api_client, @@ -100,3 +103,31 @@ async fn configure_database(config: &DatabaseSetting) -> PgPool { connection_pool } + +#[derive(serde::Serialize)] +pub struct TestUser { + name: String, + pub email: String, + pub password: String, +} + +impl TestUser { + pub fn generate() -> Self { + Self { + name: "Me".to_string(), + email: "me@appflowy.io".to_string(), + password: "HelloAppFlowy123".to_string(), + } + } + + pub async fn register(&self, test_server: &TestServer) { + let url = format!("{}/api/user/register", test_server.address); + test_server + .api_client + .post(&url) + .json(&self) + .send() + .await + .expect("Fail to register user"); + } +}