Adds an `is_admin` flag on users plus the substrate every later PR in the admin feature builds on: - migration 0018 adds the column with default false - `repo::user::bootstrap_admin` creates or promotes the user named by `ADMIN_USERNAME` at startup, hashing `ADMIN_PASSWORD` only when the row is new — never overwriting an existing hash, so an operator can rotate the admin password via the UI without env-var conflict - `CurrentSessionUser` extractor accepts only the session cookie; `RequireAdmin` composes over it and additionally requires `user.is_admin`. Bearer tokens are intentionally excluded so an admin's bot token never inherits admin authority (privilege-escalation surface that bites every "API keys reuse user perms" auth design) - demotion is instant: `RequireAdmin` re-reads the user row each request `/api/v1/auth/me` now exposes `is_admin`; no other response embeds `User`, so no privacy fanout to audit.
123 lines
4.4 KiB
Rust
123 lines
4.4 KiB
Rust
//! Auth extractors.
|
|
//!
|
|
//! Three extractors are available, in increasing strictness:
|
|
//!
|
|
//! - [`CurrentUser`] — accepts either a session cookie or an
|
|
//! `Authorization: Bearer <token>` 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<AppState> for CurrentUser {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
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::<Authorization<Bearer>>::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<AppState> for CurrentSessionUser {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
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<AppState> for RequireAdmin {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
let CurrentSessionUser(user) =
|
|
CurrentSessionUser::from_request_parts(parts, state).await?;
|
|
if !user.is_admin {
|
|
return Err(AppError::Forbidden);
|
|
}
|
|
Ok(RequireAdmin(user))
|
|
}
|
|
}
|