Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
429 lines
15 KiB
Rust
429 lines
15 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 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<AppState> {
|
|
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<AppState>) -> Json<AuthConfigResponse> {
|
|
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<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> {
|
|
// 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<AppState>,
|
|
jar: CookieJar,
|
|
Json(input): Json<Credentials>,
|
|
) -> AppResult<impl IntoResponse> {
|
|
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<String> = 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<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> {
|
|
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<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)))
|
|
}
|
|
|
|
// 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(())
|
|
}
|