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:
207
backend/src/api/auth.rs
Normal file
207
backend/src/api/auth.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! 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, 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::{ApiToken, User};
|
||||
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/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, 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 }))
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(input): Json<CreateTokenInput>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::InvalidInput("token name is required".into()));
|
||||
}
|
||||
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(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::manga::{Manga, NewManga};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
@@ -54,6 +55,7 @@ async fn get_one(
|
||||
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Json(input): Json<NewManga>,
|
||||
) -> AppResult<Json<Manga>> {
|
||||
if input.title.trim().is_empty() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod files;
|
||||
pub mod health;
|
||||
pub mod mangas;
|
||||
@@ -12,4 +13,5 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(health::routes())
|
||||
.merge(mangas::routes())
|
||||
.merge(files::routes())
|
||||
.merge(auth::routes())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user