summaryrefslogtreecommitdiffstats
path: root/src/api/account.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/account.rs')
-rw-r--r--src/api/account.rs197
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(())
+ }
+}