summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorToby Vincent <tobyv@tobyvin.dev>2024-04-15 20:51:43 -0500
committerToby Vincent <tobyv@tobyvin.dev>2024-04-15 20:51:43 -0500
commit940de575ca40e906c714c6b4ae1d1cbb6b4fc3d6 (patch)
tree2bead1436dea90f578cff694b4a8f1f9585396ca
parent4eea9d6ab134bdd05506dc85145e72ab186bf2ad (diff)
refactor(api): move login/logout into account mod
-rw-r--r--src/api.rs2
-rw-r--r--src/api/account.rs197
-rw-r--r--src/api/users.rs150
3 files changed, 205 insertions, 144 deletions
diff --git a/src/api.rs b/src/api.rs
index c776945..f6dd76a 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -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(())
- }
}