Files
Mangalord/backend/src/api/auth.rs
MechaCat02 8667f8b957 bugfix: tighten validation, drop dead sendBeacon, NUL byte (0.34.1)
Five small fixes from REVIEW.md §2/§4/§8:

- attach_tag: 64-char cap at the handler so the validation error
  envelope matches username/collection-name.
- create_token: same 64-char cap on bot token names.
- LocalStorage::resolve rejects NUL bytes explicitly so callers see
  BadKey instead of an opaque IO error.
- sendBeacon dropped from the reader's pagehide flush — it's POST-only
  and the server's read-progress route is PUT, so every page-close
  was logging a 405 then falling through to the same keepalive fetch
  anyway. Keepalive fetch is now the only path.
- Frontend logout sets content-type: application/json for symmetry
  with the other mutation helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:04:16 +02:00

337 lines
11 KiB
Rust

//! 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 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<AppState> {
Router::new()
.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))
}
#[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<String>,
pub reader_page_gap: Option<String>,
}
#[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<AppState>,
jar: CookieJar,
Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> {
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<AppState>,
jar: CookieJar,
Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> {
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?
.ok_or(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 })))
}
async fn logout(
State(state): State<AppState>,
jar: CookieJar,
) -> AppResult<impl IntoResponse> {
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<Json<AuthResponse>> {
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<AppState>,
CurrentUser(user): CurrentUser,
jar: CookieJar,
Json(input): Json<ChangePassword>,
) -> AppResult<impl IntoResponse> {
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<AppState>,
CurrentUser(user): CurrentUser,
) -> AppResult<Json<UserPreferences>> {
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<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<UpdatePreferences>,
) -> AppResult<Json<UserPreferences>> {
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<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<CreateTokenInput>,
) -> AppResult<impl IntoResponse> {
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<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
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<CookieJar> {
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)))
}
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()
}
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(())
}
fn validate_password(p: &str) -> AppResult<()> {
if p.len() < 8 {
return Err(AppError::InvalidInput(
"password must be at least 8 characters".into(),
));
}
Ok(())
}