diff options
-rw-r--r-- | src/routes.rs | 353 | ||||
-rw-r--r-- | src/routes/healthcheck.rs | 22 | ||||
-rw-r--r-- | src/routes/login.rs | 140 | ||||
-rw-r--r-- | src/routes/register.rs | 139 | ||||
-rw-r--r-- | src/routes/user.rs | 106 |
5 files changed, 423 insertions, 337 deletions
diff --git a/src/routes.rs b/src/routes.rs index 1ec4e30..d9a2a0b 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,170 +1,30 @@ -use std::{str::FromStr, sync::Arc}; +use std::sync::Arc; -use argon2::{ - password_hash::{rand_core::OsRng, SaltString}, - Argon2, PasswordHash, PasswordHasher, PasswordVerifier, -}; use axum::{ - extract::State, - http::{header::SET_COOKIE, StatusCode, Uri}, + http::{StatusCode, Uri}, response::IntoResponse, - Json, -}; -use axum_extra::{ - extract::cookie::{Cookie, SameSite}, - routing::{RouterExt, TypedPath}, }; -use jsonwebtoken::{EncodingKey, Header}; -use serde::Deserialize; +use axum_extra::routing::RouterExt; -use crate::{ - model::{LoginSchema, RegisterSchema, TokenClaims, User}, - state::AppState, - Error, -}; +use crate::state::AppState; + +mod healthcheck; +mod login; +mod register; +mod user; #[tracing::instrument] pub fn init_router(state: Arc<AppState>) -> axum::Router { axum::Router::new() // .route("/api/user", get(get_user)) - .typed_get(HealthCheck::get) - .typed_get(UserUuid::get) - .typed_post(Register::post) - .typed_post(Login::post) + .typed_get(healthcheck::HealthCheck::get) + .typed_get(user::UserUuid::get) + .typed_post(register::Register::post) + .typed_post(login::Login::post) .fallback(fallback) .with_state(state) } -#[derive(Debug, Deserialize, TypedPath)] -#[typed_path("/api/healthcheck")] -pub struct HealthCheck; - -impl HealthCheck { - #[tracing::instrument] - pub async fn get(self) -> impl IntoResponse { - const MESSAGE: &str = "Unnamed server"; - - let json_response = serde_json::json!({ - "status": "success", - "message": MESSAGE - }); - - Json(json_response) - } -} - -#[derive(Debug, Deserialize, TypedPath)] -#[typed_path("/api/user/:uuid")] -pub struct UserUuid { - pub uuid: uuid::Uuid, -} - -impl UserUuid { - /// Get a user with a specific `uuid` - #[tracing::instrument] - pub async fn get(self, State(state): State<Arc<AppState>>) -> impl IntoResponse { - sqlx::query_as!(User, "SELECT * FROM users WHERE uuid = $1", self.uuid) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| Error::UserNotFound) - .map(Json) - } -} - -#[derive(Debug, Deserialize, TypedPath)] -#[typed_path("/api/register")] -pub struct Register; - -impl Register { - #[tracing::instrument(skip(password))] - pub async fn post( - self, - State(state): State<Arc<AppState>>, - Json(RegisterSchema { - name, - email, - password, - }): Json<RegisterSchema>, - ) -> impl IntoResponse { - email_address::EmailAddress::from_str(&email)?; - - let exists: Option<bool> = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") - .bind(email.to_ascii_lowercase()) - .fetch_one(&state.pool) - .await?; - - if exists.is_some_and(|b| b) { - return Err(Error::EmailExists); - } - - let salt = SaltString::generate(&mut OsRng); - let password_hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; - - let user = sqlx::query_as!( - User, - "INSERT INTO users (name,email,password_hash) VALUES ($1, $2, $3) RETURNING *", - name, - email.to_ascii_lowercase(), - password_hash.to_string() - ) - .fetch_one(&state.pool) - .await?; - - Ok((StatusCode::CREATED, Json(user))) - } -} - -#[derive(Debug, Deserialize, TypedPath)] -#[typed_path("/api/login")] -pub struct Login; - -impl Login { - #[tracing::instrument(skip(state, password))] - pub async fn post( - self, - State(state): State<Arc<AppState>>, - Json(LoginSchema { email, password }): Json<LoginSchema>, - ) -> Result<impl IntoResponse, Error> { - let User { - uuid, - password_hash, - .. - } = sqlx::query_as!( - User, - "SELECT * FROM users WHERE email = $1", - email.to_ascii_lowercase() - ) - .fetch_optional(&state.pool) - .await? - .ok_or(Error::LoginInvalid)?; - - Argon2::default() - .verify_password(password.as_bytes(), &PasswordHash::new(&password_hash)?)?; - - let token = jsonwebtoken::encode( - &Header::default(), - &TokenClaims::new(uuid, state.jwt_max_age), - &EncodingKey::from_secret(state.jwt_secret.as_ref()), - )?; - - let cookie = Cookie::build(("token", token.to_owned())) - .path("/") - .max_age(state.jwt_max_age) - .same_site(SameSite::Lax) - .http_only(true) - .build(); - - let mut response = Json(token).into_response(); - - response - .headers_mut() - .insert(SET_COOKIE, cookie.to_string().parse().unwrap()); - - Ok(response) - } -} - pub async fn fallback(uri: Uri) -> impl IntoResponse { (StatusCode::NOT_FOUND, format!("Route not found: {uri}")) } @@ -175,12 +35,13 @@ mod tests { use axum::{ body::Body, - http::{header, Request, StatusCode}, + http::{Request, StatusCode}, }; - use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; + use crate::Error; + const JWT_SECRET: &str = "test-jwt-secret-token"; const JWT_MAX_AGE: time::Duration = time::Duration::HOUR; @@ -207,186 +68,4 @@ mod tests { Ok(()) } - - #[sqlx::test(fixtures(path = "../fixtures", scripts("users")))] - async fn test_user_ok(pool: PgPool) -> Result<(), Error> { - let state = Arc::new(AppState { - pool, - jwt_secret: JWT_SECRET.to_string(), - jwt_max_age: JWT_MAX_AGE, - }); - let router = init_router(state.clone()); - - let user = User { - uuid: uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"), - name: "Arthur Dent".to_string(), - email: "adent@earth.sol".to_string(), - ..Default::default() - }; - - let request = Request::builder() - .uri(format!("/api/user/{}", user.uuid)) - .body(Body::empty())?; - - let response = router.oneshot(request).await.unwrap(); - - assert_eq!(StatusCode::OK, response.status()); - - let body_bytes = response.into_body().collect().await?.to_bytes(); - let User { - uuid, name, email, .. - } = serde_json::from_slice(&body_bytes)?; - - assert_eq!(user.uuid, uuid); - assert_eq!(user.name, name); - assert_eq!(user.email, email); - - Ok(()) - } - - #[sqlx::test] - async fn test_user_not_found(pool: PgPool) -> Result<(), Error> { - let state = Arc::new(AppState { - pool, - jwt_secret: JWT_SECRET.to_string(), - jwt_max_age: JWT_MAX_AGE, - }); - let router = init_router(state.clone()); - - let user = User { - uuid: uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"), - name: "Arthur Dent".to_string(), - email: "adent@earth.sol".to_string(), - ..Default::default() - }; - - let request = Request::builder() - .uri(format!("/api/user/{}", user.uuid)) - .body(Body::empty())?; - - let response = router.oneshot(request).await.unwrap(); - - assert_eq!(StatusCode::NOT_FOUND, response.status()); - - Ok(()) - } - - #[sqlx::test] - async fn test_register_created(pool: PgPool) -> Result<(), Error> { - let state = Arc::new(AppState { - pool, - jwt_secret: JWT_SECRET.to_string(), - jwt_max_age: JWT_MAX_AGE, - }); - let router = init_router(state.clone()); - - let user = RegisterSchema { - name: "Arthur Dent".to_string(), - email: "adent@earth.sol".to_string(), - password: "solongandthanksforallthefish".to_string(), - }; - - let request = Request::builder() - .uri("/api/register") - .method("POST") - .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) - .body(Body::from(serde_json::to_vec(&user).unwrap()))?; - - let response = router.oneshot(request).await.unwrap(); - - assert_eq!(StatusCode::CREATED, response.status()); - - let body_bytes = response.into_body().collect().await?.to_bytes(); - let User { name, email, .. } = serde_json::from_slice(&body_bytes)?; - - assert_eq!(user.name, name); - assert_eq!(user.email, email); - - Ok(()) - } - - #[sqlx::test(fixtures(path = "../fixtures", scripts("users")))] - async fn test_register_conflict(pool: PgPool) -> Result<(), Error> { - let state = Arc::new(AppState { - pool, - jwt_secret: JWT_SECRET.to_string(), - jwt_max_age: JWT_MAX_AGE, - }); - let router = init_router(state.clone()); - - let user = RegisterSchema { - name: "Arthur Dent".to_string(), - email: "adent@earth.sol".to_string(), - password: "solongandthanksforallthefish".to_string(), - }; - - let request = Request::builder() - .uri("/api/register") - .method("POST") - .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) - .body(Body::from(serde_json::to_vec(&user).unwrap()))?; - - let response = router.oneshot(request).await.unwrap(); - - assert_eq!(StatusCode::CONFLICT, response.status()); - - Ok(()) - } - - #[sqlx::test(fixtures(path = "../fixtures", scripts("users")))] - async fn test_login_unauthorized(pool: PgPool) -> Result<(), Error> { - let state = Arc::new(AppState { - pool, - jwt_secret: JWT_SECRET.to_string(), - jwt_max_age: JWT_MAX_AGE, - }); - let router = init_router(state.clone()); - - let user = LoginSchema { - email: "adent@earth.sol".to_string(), - password: "hunter2".to_string(), - }; - - let request = Request::builder() - .uri("/api/login") - .method("POST") - .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) - .body(Body::from(serde_json::to_vec(&user).unwrap()))?; - - let response = router.oneshot(request).await.unwrap(); - - assert_eq!(StatusCode::UNAUTHORIZED, response.status()); - - Ok(()) - } - - #[sqlx::test(fixtures(path = "../fixtures", scripts("users")))] - async fn test_login_ok(pool: PgPool) -> Result<(), Error> { - let state = Arc::new(AppState { - pool, - jwt_secret: JWT_SECRET.to_string(), - jwt_max_age: JWT_MAX_AGE, - }); - let router = init_router(state.clone()); - - let user = LoginSchema { - email: "adent@earth.sol".to_string(), - password: "solongandthanksforallthefish".to_string(), - }; - - let response = router - .oneshot( - Request::builder() - .uri("/api/login") - .method("POST") - .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) - .body(Body::from(serde_json::to_vec(&user).unwrap()))?, - ) - .await - .unwrap(); - - assert_eq!(StatusCode::OK, response.status()); - - Ok(()) - } } diff --git a/src/routes/healthcheck.rs b/src/routes/healthcheck.rs new file mode 100644 index 0000000..7627336 --- /dev/null +++ b/src/routes/healthcheck.rs @@ -0,0 +1,22 @@ +use axum::{response::IntoResponse, Json}; +use axum_extra::routing::TypedPath; +use serde::Deserialize; + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/healthcheck")] +pub struct HealthCheck; + +impl HealthCheck { + #[tracing::instrument] + pub async fn get(self) -> impl IntoResponse { + const MESSAGE: &str = "Unnamed server"; + + let json_response = serde_json::json!({ + "status": "success", + "message": MESSAGE + }); + + Json(json_response) + } +} + diff --git a/src/routes/login.rs b/src/routes/login.rs new file mode 100644 index 0000000..12442e8 --- /dev/null +++ b/src/routes/login.rs @@ -0,0 +1,140 @@ +use std::sync::Arc; + +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{extract::State, http::header::SET_COOKIE, response::IntoResponse, Json}; +use axum_extra::{ + extract::cookie::{Cookie, SameSite}, + routing::TypedPath, +}; +use jsonwebtoken::{EncodingKey, Header}; +use serde::Deserialize; + +use crate::{ + model::{LoginSchema, TokenClaims, User}, + state::AppState, + Error, +}; + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/login")] +pub struct Login; + +impl Login { + #[tracing::instrument(skip(state, password))] + pub async fn post( + self, + State(state): State<Arc<AppState>>, + Json(LoginSchema { email, password }): Json<LoginSchema>, + ) -> Result<impl IntoResponse, Error> { + let User { + uuid, + password_hash, + .. + } = sqlx::query_as!( + User, + "SELECT * FROM users WHERE email = $1", + email.to_ascii_lowercase() + ) + .fetch_optional(&state.pool) + .await? + .ok_or(Error::LoginInvalid)?; + + Argon2::default() + .verify_password(password.as_bytes(), &PasswordHash::new(&password_hash)?)?; + + let token = jsonwebtoken::encode( + &Header::default(), + &TokenClaims::new(uuid, state.jwt_max_age), + &EncodingKey::from_secret(state.jwt_secret.as_ref()), + )?; + + let cookie = Cookie::build(("token", token.to_owned())) + .path("/") + .max_age(state.jwt_max_age) + .same_site(SameSite::Lax) + .http_only(true) + .build(); + + let mut response = Json(token).into_response(); + + response + .headers_mut() + .insert(SET_COOKIE, cookie.to_string().parse().unwrap()); + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + }; + use sqlx::PgPool; + use tower::ServiceExt; + + use crate::init_router; + + const JWT_SECRET: &str = "test-jwt-secret-token"; + const JWT_MAX_AGE: time::Duration = time::Duration::HOUR; + + #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] + async fn test_login_unauthorized(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { + pool, + jwt_secret: JWT_SECRET.to_string(), + jwt_max_age: JWT_MAX_AGE, + }); + let router = init_router(state.clone()); + + let user = LoginSchema { + email: "adent@earth.sol".to_string(), + password: "hunter2".to_string(), + }; + + let request = Request::builder() + .uri("/api/login") + .method("POST") + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&user).unwrap()))?; + + let response = router.oneshot(request).await.unwrap(); + + assert_eq!(StatusCode::UNAUTHORIZED, response.status()); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] + async fn test_login_ok(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { + pool, + jwt_secret: JWT_SECRET.to_string(), + jwt_max_age: JWT_MAX_AGE, + }); + let router = init_router(state.clone()); + + let user = LoginSchema { + email: "adent@earth.sol".to_string(), + password: "solongandthanksforallthefish".to_string(), + }; + + let response = router + .oneshot( + Request::builder() + .uri("/api/login") + .method("POST") + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&user).unwrap()))?, + ) + .await + .unwrap(); + + assert_eq!(StatusCode::OK, response.status()); + + Ok(()) + } +} diff --git a/src/routes/register.rs b/src/routes/register.rs new file mode 100644 index 0000000..1c0f82d --- /dev/null +++ b/src/routes/register.rs @@ -0,0 +1,139 @@ +use std::{str::FromStr, sync::Arc}; + +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, +}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use axum_extra::routing::TypedPath; +use serde::Deserialize; + +use crate::{ + model::{RegisterSchema, User}, + state::AppState, + Error, +}; + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/register")] +pub struct Register; + +impl Register { + #[tracing::instrument(skip(password))] + pub async fn post( + self, + State(state): State<Arc<AppState>>, + Json(RegisterSchema { + name, + email, + password, + }): Json<RegisterSchema>, + ) -> impl IntoResponse { + email_address::EmailAddress::from_str(&email)?; + + let exists: Option<bool> = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") + .bind(email.to_ascii_lowercase()) + .fetch_one(&state.pool) + .await?; + + if exists.is_some_and(|b| b) { + return Err(Error::EmailExists); + } + + let salt = SaltString::generate(&mut OsRng); + let password_hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; + + let user = sqlx::query_as!( + User, + "INSERT INTO users (name,email,password_hash) VALUES ($1, $2, $3) RETURNING *", + name, + email.to_ascii_lowercase(), + password_hash.to_string() + ) + .fetch_one(&state.pool) + .await?; + + Ok((StatusCode::CREATED, Json(user))) + } +} + +#[cfg(test)] +mod tests { + use crate::init_router; + + use super::*; + + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + }; + use http_body_util::BodyExt; + use sqlx::PgPool; + use tower::ServiceExt; + + const JWT_SECRET: &str = "test-jwt-secret-token"; + const JWT_MAX_AGE: time::Duration = time::Duration::HOUR; + + #[sqlx::test] + async fn test_register_created(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { + pool, + jwt_secret: JWT_SECRET.to_string(), + jwt_max_age: JWT_MAX_AGE, + }); + let router = init_router(state.clone()); + + let user = RegisterSchema { + name: "Arthur Dent".to_string(), + email: "adent@earth.sol".to_string(), + password: "solongandthanksforallthefish".to_string(), + }; + + let request = Request::builder() + .uri("/api/register") + .method("POST") + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&user).unwrap()))?; + + let response = router.oneshot(request).await.unwrap(); + + assert_eq!(StatusCode::CREATED, response.status()); + + let body_bytes = response.into_body().collect().await?.to_bytes(); + let User { name, email, .. } = serde_json::from_slice(&body_bytes)?; + + assert_eq!(user.name, name); + assert_eq!(user.email, email); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] + async fn test_register_conflict(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { + pool, + jwt_secret: JWT_SECRET.to_string(), + jwt_max_age: JWT_MAX_AGE, + }); + let router = init_router(state.clone()); + + let user = RegisterSchema { + name: "Arthur Dent".to_string(), + email: "adent@earth.sol".to_string(), + password: "solongandthanksforallthefish".to_string(), + }; + + let request = Request::builder() + .uri("/api/register") + .method("POST") + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&user).unwrap()))?; + + let response = router.oneshot(request).await.unwrap(); + + assert_eq!(StatusCode::CONFLICT, response.status()); + + Ok(()) + } +} diff --git a/src/routes/user.rs b/src/routes/user.rs new file mode 100644 index 0000000..71ed9a0 --- /dev/null +++ b/src/routes/user.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use axum::{extract::State, response::IntoResponse, Json}; +use axum_extra::routing::TypedPath; +use serde::Deserialize; + +use crate::{model::User, state::AppState, Error}; + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/user/:uuid")] +pub struct UserUuid { + pub uuid: uuid::Uuid, +} + +impl UserUuid { + /// Get a user with a specific `uuid` + #[tracing::instrument] + pub async fn get(self, State(state): State<Arc<AppState>>) -> impl IntoResponse { + sqlx::query_as!(User, "SELECT * FROM users WHERE uuid = $1", self.uuid) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| Error::UserNotFound) + .map(Json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use sqlx::PgPool; + use tower::ServiceExt; + + use crate::init_router; + + const JWT_SECRET: &str = "test-jwt-secret-token"; + const JWT_MAX_AGE: time::Duration = time::Duration::HOUR; + + #[sqlx::test] + async fn test_user_not_found(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { + pool, + jwt_secret: JWT_SECRET.to_string(), + jwt_max_age: JWT_MAX_AGE, + }); + let router = init_router(state.clone()); + + let user = User { + uuid: uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"), + name: "Arthur Dent".to_string(), + email: "adent@earth.sol".to_string(), + ..Default::default() + }; + + let request = Request::builder() + .uri(format!("/api/user/{}", user.uuid)) + .body(Body::empty())?; + + let response = router.oneshot(request).await.unwrap(); + + assert_eq!(StatusCode::NOT_FOUND, response.status()); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] + async fn test_user_ok(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { + pool, + jwt_secret: JWT_SECRET.to_string(), + jwt_max_age: JWT_MAX_AGE, + }); + let router = init_router(state.clone()); + + let user = User { + uuid: uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"), + name: "Arthur Dent".to_string(), + email: "adent@earth.sol".to_string(), + ..Default::default() + }; + + let request = Request::builder() + .uri(format!("/api/user/{}", user.uuid)) + .body(Body::empty())?; + + let response = router.oneshot(request).await.unwrap(); + + assert_eq!(StatusCode::OK, response.status()); + + let body_bytes = response.into_body().collect().await?.to_bytes(); + let User { + uuid, name, email, .. + } = serde_json::from_slice(&body_bytes)?; + + assert_eq!(user.uuid, uuid); + assert_eq!(user.name, name); + assert_eq!(user.email, email); + + Ok(()) + } +} |