diff options
author | Toby Vincent <tobyv@tobyvin.dev> | 2024-04-15 20:51:43 -0500 |
---|---|---|
committer | Toby Vincent <tobyv@tobyvin.dev> | 2024-04-15 20:51:43 -0500 |
commit | 940de575ca40e906c714c6b4ae1d1cbb6b4fc3d6 (patch) | |
tree | 2bead1436dea90f578cff694b4a8f1f9585396ca /src/api/account.rs | |
parent | 4eea9d6ab134bdd05506dc85145e72ab186bf2ad (diff) |
refactor(api): move login/logout into account mod
Diffstat (limited to 'src/api/account.rs')
-rw-r--r-- | src/api/account.rs | 197 |
1 files changed, 197 insertions, 0 deletions
diff --git a/src/api/account.rs b/src/api/account.rs new file mode 100644 index 0000000..39b4dc0 --- /dev/null +++ b/src/api/account.rs @@ -0,0 +1,197 @@ +use axum::{extract::State, routing::get, Router}; +use axum_extra::{ + extract::{cookie::Cookie, CookieJar}, + headers::{authorization::Basic, Authorization}, + typed_header::TypedHeaderRejection, + TypedHeader, +}; + +use crate::{ + auth::{AccessClaims, RefreshClaims}, + state::AppState, +}; + +use super::error::Error; + +pub fn router() -> Router<AppState> { + axum::Router::new() + .route("/login", get(login)) + .route("/logout", get(logout)) +} + +pub async fn login( + State(state): State<AppState>, + auth: Result<TypedHeader<Authorization<Basic>>, TypedHeaderRejection>, + claims: Option<RefreshClaims>, +) -> Result<(AccessClaims, RefreshClaims), Error> { + if let Some(refresh_claims) = claims { + return Ok((refresh_claims.refresh(), refresh_claims)); + } + + let TypedHeader(Authorization(basic)) = auth?; + + 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 logout(claims: AccessClaims, jar: CookieJar) -> Result<CookieJar, Error> { + Ok(jar.remove(Cookie::try_from(claims)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::{ + body::Body, + http::{ + header::{AUTHORIZATION, COOKIE, SET_COOKIE}, + HeaderValue, Request, StatusCode, + }, + Router, + }; + + use axum_extra::headers::authorization::Credentials; + use http_body_util::BodyExt; + use sqlx::PgPool; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::{ + auth::AccessClaims, + tests::{setup_test_env, TestResult}, + }; + + const USER_ID: Uuid = uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"); + const USER_EMAIL: &str = "adent@earth.sol"; + const USER_PASSWORD: &str = "solongandthanksforallthefish"; + + #[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("/login") + .method("GET") + .header(AUTHORIZATION, auth.0.encode()) + .body(Body::empty())?; + + let (mut parts, body) = router.oneshot(request).await?.into_parts(); + + assert_eq!(StatusCode::OK, parts.status); + + let body_bytes = body.collect().await?.to_bytes(); + let body = std::str::from_utf8(&body_bytes)?; + + let refresh_claims: RefreshClaims = crate::auth::jwt::JWT.decode(body)?.claims; + assert_eq!(USER_ID, refresh_claims.sub); + + let set_cookie = parts + .headers + .get(SET_COOKIE) + .expect("Failed to get set-header cookie"); + + parts.headers.insert(COOKIE, set_cookie.clone()); + + let jar = CookieJar::from_headers(&parts.headers); + + let cookie = jar + .get("token") + .expect("'token' cookie not found in response cookie jar"); + + let access_claims: AccessClaims = crate::auth::jwt::JWT.decode(cookie.value())?.claims; + + assert_eq!(USER_ID, access_claims.sub); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] + async fn test_login_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("/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("/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(()) + } + + #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] + async fn test_logout_ok(pool: PgPool) -> TestResult { + setup_test_env(); + + let router = Router::new().merge(router()).with_state(AppState { pool }); + + let request = Request::builder() + .uri("/logout") + .method("GET") + .header(COOKIE, HeaderValue::try_from(AccessClaims::new(USER_ID))?) + .body(Body::empty())?; + + let (mut parts, _) = router.oneshot(request).await?.into_parts(); + + assert_eq!(StatusCode::OK, parts.status); + + let set_cookie = parts + .headers + .get(SET_COOKIE) + .expect("Failed to get set-header cookie"); + + parts.headers.insert(COOKIE, set_cookie.clone()); + + let jar = CookieJar::from_headers(&parts.headers); + let cookie = jar + .get("token") + .expect("'token' cookie not found in response cookie jar"); + + assert_eq!(cookie.value(), ""); + assert_eq!(cookie.max_age(), None); + + Ok(()) + } +} |