//! Authentication endpoints — register, login, logout, current-user, and //! bot API token management. Session cookies are HttpOnly + SameSite=Lax //! and rotate on login (a fresh session row is created; old sessions //! expire naturally rather than being explicitly invalidated, so other //! devices keep their existing logins). use std::sync::OnceLock; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{delete, get, patch, post}; use axum::{Json, Router}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::app::AppState; use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME}; use crate::auth::password::{hash_password, verify_password}; use crate::auth::token::{generate_token, hash_token}; use crate::config::AuthConfig; use crate::domain::user_preferences::{READER_GAPS, READER_MODES}; use crate::domain::{ApiToken, User, UserPreferences}; use crate::error::{AppError, AppResult}; use crate::repo; pub fn routes() -> Router { Router::new() .route("/auth/config", get(auth_config)) .route("/auth/register", post(register)) .route("/auth/login", post(login)) .route("/auth/logout", post(logout)) .route("/auth/me", get(me)) .route("/auth/me/password", patch(change_password)) .route( "/auth/me/preferences", get(get_preferences).patch(update_preferences), ) .route("/auth/tokens", post(create_token)) .route("/auth/tokens/:id", delete(delete_token)) } /// Public, unauthenticated. Exposes anonymous-relevant auth policy /// (currently just whether self-registration is open) so the frontend /// can render its login / register affordances correctly without a /// probe request that would conflate "disabled" with "rate-limited". #[derive(Debug, Serialize)] pub struct AuthConfigResponse { pub self_register_enabled: bool, } async fn auth_config(State(state): State) -> Json { Json(AuthConfigResponse { self_register_enabled: state.auth.allow_self_register, }) } #[derive(Debug, Deserialize)] pub struct Credentials { pub username: String, pub password: String, } #[derive(Debug, Serialize)] pub struct AuthResponse { pub user: User, } #[derive(Debug, Deserialize)] pub struct CreateTokenInput { pub name: String, } #[derive(Debug, Deserialize)] pub struct ChangePassword { pub current_password: String, pub new_password: String, } #[derive(Debug, Deserialize)] pub struct UpdatePreferences { pub reader_mode: Option, pub reader_page_gap: Option, } #[derive(Debug, Serialize)] pub struct CreatedTokenResponse { #[serde(flatten)] pub token: ApiToken, /// Raw bearer token — returned exactly once at creation. pub bearer: String, } async fn register( State(state): State, jar: CookieJar, Json(input): Json, ) -> AppResult { // Rate limit before the disabled check so an operator who flips // the toggle can't be probed for the toggle state via timing — // disabled and enabled paths both consume a token, and disabled // returns 403 instead of running argon2. check_auth_rate_limit(&state, "register")?; if !state.auth.allow_self_register { return Err(AppError::Forbidden); } let username = input.username.trim(); validate_username(username)?; validate_password(&input.password)?; let pwhash = hash_password(&input.password)?; let user = repo::user::create(&state.db, username, &pwhash).await?; let jar = start_session(&state, &user, jar).await?; Ok((StatusCode::CREATED, jar, Json(AuthResponse { user }))) } async fn login( State(state): State, jar: CookieJar, Json(input): Json, ) -> AppResult { check_auth_rate_limit(&state, "login")?; let username = input.username.trim(); if username.is_empty() || input.password.is_empty() { return Err(AppError::InvalidInput( "username and password are required".into(), )); } let user = repo::user::find_by_username(&state.db, username).await?; let Some(user) = user else { // No such user. Run argon2 against a stable dummy hash so the // response time matches the wrong-password branch — otherwise // an attacker can enumerate usernames by timing the no-user // 401 against the wrong-password 401. let _ = verify_password(&input.password, dummy_password_hash()); return Err(AppError::Unauthenticated); }; if !verify_password(&input.password, &user.password_hash) { return Err(AppError::Unauthenticated); } let jar = start_session(&state, &user, jar).await?; Ok((StatusCode::OK, jar, Json(AuthResponse { user }))) } /// Lazily-computed argon2 hash used to equalise login response time /// across the "no such user" and "wrong password" branches. Computing /// it once (on the first login of the process) is enough — the hash is /// never compared against a real password, only used to force argon2 /// to do the same amount of work it would for a real verify. fn dummy_password_hash() -> &'static str { static DUMMY: OnceLock = OnceLock::new(); DUMMY .get_or_init(|| { crate::auth::password::hash_password("login-timing-equaliser") .expect("hash_password on a fixed input cannot fail") }) .as_str() } async fn logout( State(state): State, jar: CookieJar, ) -> AppResult { if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) { let hash = hash_token(cookie.value()); repo::session::delete_by_token_hash(&state.db, &hash).await?; } let jar = jar.add(build_expired_cookie(&state.auth)); Ok((StatusCode::NO_CONTENT, jar)) } async fn me(CurrentUser(user): CurrentUser) -> AppResult> { Ok(Json(AuthResponse { user })) } /// `PATCH /api/v1/auth/me/password` — change the current user's password. /// /// Verifies `current_password` against the stored argon2 hash (401 on /// mismatch, matching the login contract). Validates `new_password` /// against the same min-length rule used at registration. On success, /// inside a single transaction: /// - UPDATE users.password_hash with the new argon2 hash /// - DELETE all existing sessions for this user (signs out other /// devices; the stolen-cookie attack surface is closed) /// - INSERT a fresh session and return it as a new cookie so the /// caller stays logged in /// /// Bot tokens (`api_tokens`) are left alone: the user opted into them /// explicitly and can revoke individually via DELETE /auth/tokens/{id}. async fn change_password( State(state): State, CurrentUser(user): CurrentUser, jar: CookieJar, Json(input): Json, ) -> AppResult { check_auth_rate_limit(&state, "change_password")?; if !verify_password(&input.current_password, &user.password_hash) { return Err(AppError::Unauthenticated); } validate_password(&input.new_password)?; let new_hash = hash_password(&input.new_password)?; let mut tx = state.db.begin().await?; sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") .bind(&new_hash) .bind(user.id) .execute(&mut *tx) .await?; repo::session::delete_all_for_user(&mut *tx, user.id).await?; let (raw, hash) = generate_token(); let expires_at = Utc::now() + Duration::days(state.auth.session_ttl_days); repo::session::create(&mut *tx, user.id, &hash, expires_at).await?; tx.commit().await?; let jar = jar.add(build_session_cookie(raw, &state.auth)); Ok((StatusCode::NO_CONTENT, jar)) } /// `GET /api/v1/auth/me/preferences` — return the caller's stored reader /// preferences, or the in-memory defaults if they have never customised them. async fn get_preferences( State(state): State, CurrentUser(user): CurrentUser, ) -> AppResult> { let prefs = repo::user_preferences::find(&state.db, user.id) .await? .unwrap_or_else(|| UserPreferences::defaults_for(user.id)); Ok(Json(prefs)) } /// `PATCH /api/v1/auth/me/preferences` — partial update. Unspecified fields /// keep their current values. Each field is validated against its allowed- /// value list; invalid values return 400 `invalid_input`. async fn update_preferences( State(state): State, CurrentUser(user): CurrentUser, Json(input): Json, ) -> AppResult> { let current = repo::user_preferences::find(&state.db, user.id) .await? .unwrap_or_else(|| UserPreferences::defaults_for(user.id)); let reader_mode = match input.reader_mode { Some(m) => { if !READER_MODES.contains(&m.as_str()) { return Err(AppError::InvalidInput(format!( "reader_mode must be one of {READER_MODES:?}" ))); } m } None => current.reader_mode, }; let reader_page_gap = match input.reader_page_gap { Some(g) => { if !READER_GAPS.contains(&g.as_str()) { return Err(AppError::InvalidInput(format!( "reader_page_gap must be one of {READER_GAPS:?}" ))); } g } None => current.reader_page_gap, }; let saved = repo::user_preferences::upsert(&state.db, user.id, &reader_mode, &reader_page_gap).await?; Ok(Json(saved)) } async fn create_token( State(state): State, CurrentUser(user): CurrentUser, Json(input): Json, ) -> AppResult { let name = input.name.trim(); // Both arms use `ValidationFailed` (422 with field details) to // match the structured-error shape `attach_tag` returns for the // same kind of free-form-identifier validation. The other // /auth/* handlers in this file use `InvalidInput` (400); the // divergence is pre-existing and would warrant a project-wide // pass to flip them all if the client side wants uniform per- // field error rendering. if name.is_empty() { return Err(AppError::ValidationFailed { message: "token name is required".into(), details: serde_json::json!({ "name": "required" }), }); } if name.chars().count() > 64 { return Err(AppError::ValidationFailed { message: "token name too long".into(), details: serde_json::json!({ "name": "max 64 characters" }), }); } let (raw, hash) = generate_token(); let token = repo::api_token::create(&state.db, user.id, name, &hash).await?; Ok(( StatusCode::CREATED, Json(CreatedTokenResponse { token, bearer: raw }), )) } async fn delete_token( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, ) -> AppResult { match repo::api_token::find_owner(&state.db, id).await? { None => Err(AppError::NotFound), Some(owner) if owner != user.id => Err(AppError::Forbidden), Some(_) => { repo::api_token::delete(&state.db, id).await?; Ok(StatusCode::NO_CONTENT) } } } async fn start_session( state: &AppState, user: &User, jar: CookieJar, ) -> AppResult { let (raw, hash) = generate_token(); let expires_at = Utc::now() + Duration::days(state.auth.session_ttl_days); repo::session::create(&state.db, user.id, &hash, expires_at).await?; Ok(jar.add(build_session_cookie(raw, &state.auth))) } // CSRF posture: `SameSite=Lax` is the project's primary CSRF defense. // Browsers refuse to attach this cookie to cross-site POST / PATCH / // DELETE requests, which covers every state-changing endpoint (auth // mutations, uploads, bookmarks, collections, admin user management, // etc. — all JSON over POST/PATCH/DELETE). Lax DOES still attach the // cookie on top-level cross-site GETs, so this defense breaks the // instant anyone adds a state-changing GET. If you reach for one, // switch to `SameSite=Strict` here AND add an explicit CSRF-token // check on the new endpoint. The Bearer-token branch in the // extractor is unaffected (bots authenticate with the token header, // not the cookie) and admin routes reject Bearer entirely — see // `auth::extractor::RequireAdmin`. fn build_session_cookie(raw: String, cfg: &AuthConfig) -> Cookie<'static> { let mut builder = Cookie::build((SESSION_COOKIE_NAME, raw)) .http_only(true) .secure(cfg.cookie_secure) .same_site(SameSite::Lax) .path("/") .max_age(time::Duration::days(cfg.session_ttl_days)); if let Some(domain) = &cfg.cookie_domain { builder = builder.domain(domain.clone()); } builder.build() } fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> { let mut builder = Cookie::build((SESSION_COOKIE_NAME, "")) .http_only(true) .secure(cfg.cookie_secure) .same_site(SameSite::Lax) .path("/") .max_age(time::Duration::seconds(0)); if let Some(domain) = &cfg.cookie_domain { builder = builder.domain(domain.clone()); } builder.build() } /// Consume one token from the shared auth rate limiter. Called at the /// start of `register`, `login`, and `change_password` so credential /// stuffing / spraying / username-probe loops are throttled by the /// configured budget (default 5/sec with a 10-request burst). /// /// All three endpoints share one bucket — they all expose the same /// argon2-verify-or-create work and the same enumeration channels, so /// any one of them in a tight loop should trip the limit. `endpoint` /// is included in the rate-limit-hit log line so operators can tell /// which endpoint is being probed. fn check_auth_rate_limit(state: &AppState, endpoint: &'static str) -> AppResult<()> { use crate::auth::rate_limit::AcquireResult; match state.auth_limiter.try_acquire() { AcquireResult::Allowed => Ok(()), AcquireResult::Denied { retry_after_secs } => { tracing::warn!( endpoint, retry_after_secs, "auth rate limit hit; returning 429" ); Err(AppError::TooManyRequests { retry_after_secs: Some(retry_after_secs), }) } } } // Exposed pub(crate) so the admin user-create handler can apply the // same rules as self-registration. Keeping the lone canonical // implementation here avoids the two paths drifting on min length / // allowed character set. pub(crate) fn validate_username(u: &str) -> AppResult<()> { if u.is_empty() { return Err(AppError::InvalidInput("username is required".into())); } if u.len() < 3 || u.len() > 32 { return Err(AppError::InvalidInput( "username must be 3-32 characters".into(), )); } if !u.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { return Err(AppError::InvalidInput( "username may only contain letters, digits, _ and -".into(), )); } Ok(()) } pub(crate) fn validate_password(p: &str) -> AppResult<()> { if p.len() < 8 { return Err(AppError::InvalidInput( "password must be at least 8 characters".into(), )); } Ok(()) }