diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 20e3af9..a0fa23c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.36.7" +version = "0.37.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0018_admin_role.sql b/backend/migrations/0018_admin_role.sql new file mode 100644 index 0000000..22747dd --- /dev/null +++ b/backend/migrations/0018_admin_role.sql @@ -0,0 +1,5 @@ +-- Admin role flag on users. Booted from ADMIN_USERNAME / ADMIN_PASSWORD env at +-- startup (see app::build). Demotion is instant: the RequireAdmin extractor +-- re-reads the user row every request, so flipping this column takes effect on +-- the next call without a session purge. +ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/app.rs b/backend/src/app.rs index 4eeb2aa..f667586 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -60,6 +60,13 @@ pub async fn build(config: Config) -> anyhow::Result { .await?; sqlx::migrate!("./migrations").run(&db).await?; + if let Some((username, password)) = config.admin_bootstrap.as_ref() { + repo::user::bootstrap_admin(&db, username, password) + .await + .context("bootstrap_admin from ADMIN_USERNAME/ADMIN_PASSWORD env")?; + tracing::info!(admin_username = %username, "admin bootstrap ensured"); + } + let storage: Arc = Arc::new(LocalStorage::new(config.storage_dir.clone())); let daemon = if config.crawler.daemon_enabled { diff --git a/backend/src/auth/extractor.rs b/backend/src/auth/extractor.rs index 40d7bec..72b3755 100644 --- a/backend/src/auth/extractor.rs +++ b/backend/src/auth/extractor.rs @@ -1,11 +1,19 @@ -//! `CurrentUser` axum extractor. +//! Auth extractors. //! -//! Resolves a request to a logged-in user by trying, in order: -//! 1. a `mangalord_session` cookie (session lookup by `sha256(value)`); -//! 2. an `Authorization: Bearer ` header (api_token lookup). +//! Three extractors are available, in increasing strictness: //! -//! Both paths look up by hash, never by raw value. Failure to resolve -//! either way returns 401 via `AppError::Unauthenticated`. +//! - [`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; @@ -61,3 +69,54 @@ impl FromRequestParts for CurrentUser { 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)) + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs index 28438d1..5851cb7 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -59,6 +59,13 @@ pub struct Config { pub upload: UploadConfig, pub cors_allowed_origins: Vec, pub crawler: CrawlerConfig, + /// `(username, password)` for the admin user provisioned at startup + /// when both `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set. `None` + /// skips the bootstrap entirely. See `repo::user::bootstrap_admin` + /// for the create-vs-promote semantics — notably the password here + /// is used only when creating a new row, never to overwrite an + /// existing one. + pub admin_bootstrap: Option<(String, String)>, } /// All crawler-daemon knobs read from env. Mirrors the env vars the @@ -158,10 +165,21 @@ impl Config { }) .unwrap_or_default(), crawler: CrawlerConfig::from_env()?, + admin_bootstrap: admin_bootstrap_from_env(), }) } } +/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME` +/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is +/// treated as "no bootstrap" rather than a hard error, so an operator +/// can comment out one env var without crashing the server. +fn admin_bootstrap_from_env() -> Option<(String, String)> { + let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty())?; + let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty())?; + Some((username, password)) +} + impl CrawlerConfig { pub fn from_env() -> anyhow::Result { // Parse CRAWLER_DAILY_AT (HH:MM, 24h). Invalid → fail fast. diff --git a/backend/src/domain/user.rs b/backend/src/domain/user.rs index 7a4bf6d..c81d1e3 100644 --- a/backend/src/domain/user.rs +++ b/backend/src/domain/user.rs @@ -10,4 +10,5 @@ pub struct User { #[serde(skip)] pub password_hash: String, pub created_at: DateTime, + pub is_admin: bool, } diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index 82d39c9..630c71a 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -11,7 +11,7 @@ pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppRe r#" INSERT INTO users (username, password_hash) VALUES ($1, $2) - RETURNING id, username, password_hash, created_at + RETURNING id, username, password_hash, created_at, is_admin "#, ) .bind(username) @@ -35,7 +35,7 @@ pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppRe pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult> { let row = sqlx::query_as::<_, User>( r#" - SELECT id, username, password_hash, created_at + SELECT id, username, password_hash, created_at, is_admin FROM users WHERE lower(username) = lower($1) "#, @@ -48,7 +48,7 @@ pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult