diff options
Diffstat (limited to 'src/routes/login.rs')
-rw-r--r-- | src/routes/login.rs | 140 |
1 files changed, 140 insertions, 0 deletions
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(()) + } +} |