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 serde_json::json; 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>, Json(LoginSchema { email, password }): Json, ) -> Result { 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) } } #[derive(Debug, Deserialize, TypedPath)] #[typed_path("/api/logout")] pub struct Logout; impl Logout { #[tracing::instrument] pub async fn get(self) -> impl IntoResponse { let cookie = Cookie::build(("token", "")) .path("/") .max_age(time::Duration::hours(-1)) .same_site(SameSite::Lax) .http_only(true) .build(); let mut response = Json(json!({"status": "success"})).into_response(); response .headers_mut() .insert(SET_COOKIE, cookie.to_string().parse().unwrap()); 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; type TestResult> = std::result::Result; #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_login_unauthorized(pool: PgPool) -> TestResult { 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)?))?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNAUTHORIZED, response.status()); Ok(()) } #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_login_ok(pool: PgPool) -> TestResult { 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 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)?))?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::OK, response.status()); Ok(()) } #[sqlx::test] async fn test_logout(pool: PgPool) -> TestResult { 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 request = Request::builder() .uri("/api/logout") .method("GET") .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::OK, response.status()); Ok(()) } }