//! Auth extractors. //! //! Three extractors are available, in increasing strictness: //! //! - [`CurrentUser`] — accepts either a session cookie or an //! `Authorization: Bearer ` header. Used by ordinary //! authenticated endpoints where bot tokens are first-class clients. //! - [`CurrentSessionUser`] — accepts only the session cookie. Used as //! the substrate for admin extraction so bot tokens cannot authenticate //! as the admin (see [`RequireAdmin`]). //! - [`RequireAdmin`] — composes over [`CurrentSessionUser`] and //! additionally requires `user.is_admin`. Returns 403 for //! authenticated-but-not-admin, 401 otherwise. //! //! All lookups go by `sha256(raw_token)` — the raw value is never stored //! in the database. use axum::async_trait; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum_extra::extract::cookie::CookieJar; use axum_extra::headers::authorization::Bearer; use axum_extra::headers::Authorization; use axum_extra::TypedHeader; use crate::app::AppState; use crate::auth::token::hash_token; use crate::domain::User; use crate::error::AppError; use crate::repo; pub const SESSION_COOKIE_NAME: &str = "mangalord_session"; pub struct CurrentUser(pub User); #[async_trait] impl FromRequestParts for CurrentUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let jar = CookieJar::from_headers(&parts.headers); if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) { let hash = hash_token(cookie.value()); if let Some(session) = repo::session::find_active(&state.db, &hash).await? { if let Some(user) = repo::user::find_by_id(&state.db, session.user_id).await? { return Ok(CurrentUser(user)); } } } if let Ok(TypedHeader(Authorization(bearer))) = TypedHeader::>::from_request_parts(parts, state).await { let hash = hash_token(bearer.token()); if let Some(token) = repo::api_token::find_active(&state.db, &hash).await? { if let Some(user) = repo::user::find_by_id(&state.db, token.user_id).await? { // Fire-and-forget would be ideal but the test harness needs // a deterministic write so the touched timestamp shows up // when the test inspects state. Synchronous is fine. let _ = repo::api_token::touch_last_used(&state.db, token.id).await; return Ok(CurrentUser(user)); } } } Err(AppError::Unauthenticated) } } /// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted /// here — this is the substrate for [`RequireAdmin`] and exists precisely /// to keep admin authority out of bearer-token reach. pub struct CurrentSessionUser(pub User); #[async_trait] impl FromRequestParts for CurrentSessionUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let jar = CookieJar::from_headers(&parts.headers); let cookie = jar .get(SESSION_COOKIE_NAME) .ok_or(AppError::Unauthenticated)?; let hash = hash_token(cookie.value()); let session = repo::session::find_active(&state.db, &hash) .await? .ok_or(AppError::Unauthenticated)?; let user = repo::user::find_by_id(&state.db, session.user_id) .await? .ok_or(AppError::Unauthenticated)?; Ok(CurrentSessionUser(user)) } } /// Admin-only. Composes over [`CurrentSessionUser`] so bot tokens are /// rejected at the auth step (401) rather than the role step (403). /// The user row is re-read every request, so demotion takes effect on /// the very next call without needing to purge sessions. pub struct RequireAdmin(pub User); #[async_trait] impl FromRequestParts for RequireAdmin { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let CurrentSessionUser(user) = CurrentSessionUser::from_request_parts(parts, state).await?; if !user.is_admin { return Err(AppError::Forbidden); } Ok(RequireAdmin(user)) } }