use std::str::FromStr; use axum::{ extract::{Path, State}, response::IntoResponse, routing::get, Json, Router, }; use axum_extra::{ headers::{authorization::Basic, Authorization}, routing::Resource, TypedHeader, }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use time::OffsetDateTime; use uuid::Uuid; use crate::{ auth::{AccessClaims, RefreshClaims}, state::AppState, }; use super::error::Error; pub fn router() -> Router { axum::Router::new() .route("/users/login", get(login)) .merge(Resource::named("users").create(create).show(show)) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)] #[serde(rename_all = "camelCase")] pub struct UserSchema { pub id: Uuid, pub name: String, pub email: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RegisterSchema { pub name: String, pub email: String, pub password: String, } pub async fn login( State(state): State, TypedHeader(Authorization(basic)): TypedHeader>, ) -> Result<(AccessClaims, RefreshClaims), Error> { let user_id = sqlx::query_scalar!("SELECT id FROM user_ WHERE email = $1", basic.username()) .fetch_optional(&state.pool) .await? .ok_or(Error::UserNotFound)?; crate::auth::issue( State(state.clone()), TypedHeader(Authorization::basic(&user_id.to_string(), basic.password())), ) .await .map_err(Into::into) } pub async fn create( State(state): State, Json(RegisterSchema { name, email, password, }): Json, ) -> impl IntoResponse { email_address::EmailAddress::from_str(&email)?; let exists: Option = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM user_ WHERE email = $1 LIMIT 1)", email.to_ascii_lowercase() ) .fetch_one(&state.pool) .await?; if exists.is_some_and(|b| b) { return Err(Error::EmailExists); } // TODO: Move this into a tower service let (status, (access, refresh)) = crate::auth::create( State(state.clone()), TypedHeader(Authorization::basic(&email, &password)), ) .await?; let user = sqlx::query_as!( UserSchema, "INSERT INTO user_ (id,name,email) VALUES ($1, $2, $3) RETURNING *", refresh.sub, name, email.to_ascii_lowercase(), ) .fetch_one(&state.pool) .await?; Ok((status, access, refresh, Json(user))) } pub async fn show( Path(uuid): Path, State(state): State, AccessClaims { sub, .. }: AccessClaims, ) -> Result { if uuid != sub { return Err(Error::InvalidToken); } sqlx::query_as!(UserSchema, "SELECT * FROM user_ WHERE id = $1 LIMIT 1", sub) .fetch_optional(&state.pool) .await? .ok_or_else(|| Error::UserNotFound) .map(Json) } #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{ header::{AUTHORIZATION, CONTENT_TYPE, COOKIE}, HeaderValue, Request, StatusCode, }, Router, }; use axum_extra::headers::authorization::Credentials; use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; use crate::{ auth::AccessClaims, tests::{setup_test_env, TestResult}, }; const USER_ID: Uuid = uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"); const USER_NAME: &str = "Arthur Dent"; const USER_EMAIL: &str = "adent@earth.sol"; const USER_PASSWORD: &str = "solongandthanksforallthefish"; #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_uuid_ok(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header(COOKIE, HeaderValue::try_from(AccessClaims::new(USER_ID))?) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::OK, response.status()); let body_bytes = response.into_body().collect().await?.to_bytes(); let UserSchema { id, name, email, .. } = serde_json::from_slice(&body_bytes)?; assert_eq!(USER_ID, id); assert_eq!(USER_NAME, name); assert_eq!(USER_EMAIL, email); Ok(()) } #[sqlx::test] async fn test_uuid_not_found(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header(COOKIE, HeaderValue::try_from(AccessClaims::new(USER_ID))?) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::NOT_FOUND, response.status()); Ok(()) } #[sqlx::test] async fn test_unauthorized_invalid_token_signature(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header( COOKIE, HeaderValue::try_from(AccessClaims::new(uuid::Uuid::new_v4()))?, ) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNAUTHORIZED, response.status()); Ok(()) } #[sqlx::test] async fn test_unauthorized_invalid_token_format(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header(COOKIE, "token=sadfasdfsdfs") .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNPROCESSABLE_ENTITY, response.status()); Ok(()) } #[sqlx::test] async fn test_unauthorized_missing_token(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNAUTHORIZED, response.status()); Ok(()) } #[sqlx::test] async fn test_create_created(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let user = serde_json::json!( { "name": USER_NAME, "email": USER_EMAIL, "password": USER_PASSWORD, }); let request = Request::builder() .uri("/users") .method("POST") .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .body(Body::from(serde_json::to_vec(&user)?))?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::CREATED, response.status()); let body_bytes = response.into_body().collect().await?.to_bytes(); let UserSchema { 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_create_conflict(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let user = serde_json::json!( { "name": USER_NAME, "email": USER_EMAIL, "password": USER_PASSWORD, }); let request = Request::builder() .uri("/users") .method("POST") .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .body(Body::from(serde_json::to_vec(&user)?))?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::CONFLICT, response.status()); Ok(()) } #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_login_ok(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let auth = Authorization::basic(USER_EMAIL, USER_PASSWORD); let request = Request::builder() .uri("/users/login") .method("GET") .header(AUTHORIZATION, auth.0.encode()) .body(Body::empty())?; let response = router.oneshot(request).await?; println!("{response:?}"); assert_eq!(StatusCode::OK, response.status()); Ok(()) } #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_issue_unauthorized(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let auth = Authorization::basic(USER_EMAIL, "hunter2"); let request = Request::builder() .uri("/users/login") .method("GET") .header(AUTHORIZATION, auth.0.encode()) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNAUTHORIZED, response.status()); Ok(()) } #[sqlx::test] async fn test_login_not_found(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let auth = Authorization::basic(USER_EMAIL, USER_PASSWORD); let request = Request::builder() .uri("/users/login") .method("GET") .header(AUTHORIZATION, auth.0.encode()) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::NOT_FOUND, response.status()); Ok(()) } }