diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/api.rs | 2 | ||||
-rw-r--r-- | src/api/account.rs | 197 | ||||
-rw-r--r-- | src/api/users.rs | 150 |
3 files changed, 205 insertions, 144 deletions
@@ -2,11 +2,13 @@ use axum::{http::Uri, response::IntoResponse, routing::get}; use crate::state::AppState; +pub mod account; pub mod error; pub mod users; pub fn router() -> axum::Router<AppState> { axum::Router::new() + .nest("/account", account::router()) .merge(users::router()) .route("/healthcheck", get(healthcheck)) .fallback(fallback) 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(()) + } +} diff --git a/src/api/users.rs b/src/api/users.rs index 3ac72d9..2440e6e 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -3,35 +3,20 @@ use std::str::FromStr; use axum::{ extract::{Path, State}, response::IntoResponse, - routing::get, - Json, Router, -}; -use axum_extra::{ - extract::{cookie::Cookie, CookieJar}, - headers::{authorization::Basic, Authorization}, - routing::Resource, - typed_header::TypedHeaderRejection, - TypedHeader, + Json, }; +use axum_extra::{headers::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 crate::{auth::AccessClaims, state::AppState}; use super::error::Error; -pub fn router() -> Router<AppState> { - let users = Resource::named("users").create(create).show(show); - - axum::Router::new() - .route("/users/login", get(login)) - .route("/users/logout", get(logout)) - .merge(users) +pub fn router() -> Resource<AppState> { + Resource::named("users").create(create).show(show) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)] @@ -51,34 +36,6 @@ pub struct RegisterSchema { pub password: String, } -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)?)) -} - pub async fn create( State(state): State<AppState>, Json(RegisterSchema { @@ -143,13 +100,12 @@ mod tests { use axum::{ body::Body, http::{ - header::{AUTHORIZATION, CONTENT_TYPE, COOKIE}, + header::{CONTENT_TYPE, COOKIE}, HeaderValue, Request, StatusCode, }, Router, }; - use axum_extra::headers::{authorization::Credentials, Header, HeaderMapExt, SetCookie}; use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; @@ -320,98 +276,4 @@ mod tests { 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?; - - 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(()) - } - - #[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("/users/logout") - .method("GET") - .header(COOKIE, HeaderValue::try_from(AccessClaims::new(USER_ID))?) - .body(Body::empty())?; - - let response = router.oneshot(request).await?; - - assert_eq!(StatusCode::OK, response.status()); - - if let Some(set_cookie) = response.headers().typed_get::<SetCookie>() { - let mut values = Vec::new(); - set_cookie.encode(&mut values); - for value in values { - let cookie: Cookie = value.to_str()?.parse().unwrap(); - if cookie.name() == "token" { - assert_eq!(cookie.value(), ""); - assert_eq!(cookie.max_age(), Some(time::Duration::ZERO)); - } - } - } - - Ok(()) - } } |