WP-01: authentication, user management and application shell
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Argon2id password hashing, JWT access tokens with rotating HttpOnly refresh
cookies and reuse detection, login rate limiting, auth audit log, bootstrap
admin, admin-only user CRUD and password reset, OpenAPI spec. Vue login page,
auth store with automatic token refresh, route guards, sidebar shell with
toasts and placeholder pages, user management page.

All tests green: 40 backend, 12 Vitest, 4 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 21:37:14 +02:00
parent 83aa500f5d
commit f1136fdf3d
32 changed files with 1899 additions and 48 deletions

View File

@ -0,0 +1,97 @@
//! Request extractors: authenticated user, admin user, client IP.
use axum::extract::{ConnectInfo, FromRequestParts};
use axum::http::request::Parts;
use axum::http::{header, StatusCode};
use axum::response::Response;
use domain::user::User;
use std::net::SocketAddr;
use crate::error::simple;
use crate::AppState;
pub struct AuthUser(pub User);
pub struct AdminUser(pub User);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
simple(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing bearer token",
)
})?;
state
.auth
.authenticate(token)
.await
.map(AuthUser)
.map_err(|e| crate::error::ApiError(e).into_response_401())
}
}
impl FromRequestParts<AppState> for AdminUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
if !user.is_admin() {
return Err(simple(
StatusCode::FORBIDDEN,
"forbidden",
"admin role required",
));
}
Ok(AdminUser(user))
}
}
impl crate::error::ApiError {
/// Auth failures on protected routes are always reported as 401 (inactive users included).
fn into_response_401(self) -> Response {
simple(
StatusCode::UNAUTHORIZED,
"unauthorized",
&self.0.to_string(),
)
}
}
/// Best-effort client IP: `X-Forwarded-For` first hop, else the socket address.
pub fn client_ip(parts: &Parts) -> Option<String> {
parts
.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|s| s.trim().to_string())
.or_else(|| {
parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip().to_string())
})
}
pub struct ClientIp(pub Option<String>);
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
Ok(ClientIp(client_ip(parts)))
}
}