feat: argon2id passwords, session cookies, bot bearer tokens

Adds the full auth flow. Reads stay public; writes (currently only POST
/api/v1/mangas) require a CurrentUser. Both browsers and bot scripts hit
the same endpoints — they just present credentials differently.

Migration 0002_auth.sql introduces users.password_hash, a sessions
table, and an api_tokens table. Sessions and api_tokens store only
sha256(raw_token) — the raw value lives in the cookie or the
Authorization header.

New endpoints under /api/v1/auth/:
- POST /register — argon2id hash, creates a session, sets cookie.
- POST /login — verifies, rotates to a fresh session (old ones expire
  naturally so other devices stay signed in).
- POST /logout — deletes the server-side session row + clears the
  cookie via Max-Age=0.
- GET  /me — current user via the new CurrentUser extractor.
- POST /tokens — issue a bot bearer token; raw value returned exactly
  once at creation.
- DELETE /tokens/{id} — owner-only: 404 if unknown, 403 if it exists
  but belongs to another user, 204 on success.

The CurrentUser axum extractor resolves cookie first, then
Authorization: Bearer; failure → AppError::Unauthenticated (401). New
AppError variants Unauthenticated/Forbidden/Conflict carry the matching
envelope codes; the top-level match in `code()` stays exhaustive.

Backend integration coverage in tests/api_auth.rs: register sets a
HttpOnly SameSite=Lax cookie and never leaks password_hash; duplicate
username → 409; weak password → 400; login rotates the cookie; wrong
password / unknown user → 401; /me with vs without cookie; logout
invalidates the cookie; bot-token roundtrip via Bearer; user A cannot
delete user B's token (403); unknown delete → 404.

Frontend:
- lib/api/auth.ts — typed wrappers; me() returns null on 401.
- lib/session.svelte.ts — per-tab user state with a seq counter to
  guard against an in-flight /me clobbering a fresh setUser.
- lib/api/client.ts — request<T> returns undefined for 204.
- routes/login + routes/register — forms with action="javascript:void(0)"
  so the no-JS path is a no-op (avoids the hydration-race where a
  pre-attach click would submit via the browser default).
- routes/+layout.svelte — session-aware nav: spinner → user + Logout,
  or Login / Register.
- e2e/auth-flow.spec.ts — login flips the layout, logout flips back;
  bad credentials surface the API error message.

Config grows AuthConfig (cookie_secure, cookie_domain, session_ttl_days)
and CORS_ALLOWED_ORIGINS. CORS middleware is mounted in app::build and
stays a no-op (same-origin) until origins are listed.

Lockstep version bump to 0.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 22:04:25 +02:00
parent ce9a01793f
commit 383cfbed3b
36 changed files with 1901 additions and 13 deletions

View File

@@ -1,17 +1,20 @@
use std::sync::Arc;
use axum::http::{HeaderName, HeaderValue, Method};
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use crate::config::Config;
use crate::config::{AuthConfig, Config};
use crate::storage::{LocalStorage, Storage};
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub auth: AuthConfig,
}
pub async fn build(config: Config) -> anyhow::Result<Router> {
@@ -23,7 +26,8 @@ pub async fn build(config: Config) -> anyhow::Result<Router> {
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
Ok(router(AppState { db, storage }))
let state = AppState { db, storage, auth: config.auth.clone() };
Ok(router(state).layer(cors_layer(&config.cors_allowed_origins)))
}
/// Build a router from a pre-assembled state. Used by integration tests
@@ -34,3 +38,22 @@ pub fn router(state: AppState) -> Router {
.with_state(state)
.layer(TraceLayer::new_for_http())
}
fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
if allowed_origins.is_empty() {
// Same-origin only — no CORS headers emitted.
return CorsLayer::new();
}
let origins: Vec<HeaderValue> = allowed_origins
.iter()
.filter_map(|o| HeaderValue::from_str(o).ok())
.collect();
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([
HeaderName::from_static("content-type"),
HeaderName::from_static("authorization"),
])
}