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,173 @@
//! /api/auth: login, refresh, logout, me.
use axum::extract::State;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::user::User;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::{simple, ApiError};
use crate::extract::{AuthUser, ClientIp};
use crate::AppState;
pub const REFRESH_COOKIE: &str = "refresh_token";
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/refresh", post(refresh))
.route("/logout", post(logout))
.route("/me", get(me))
}
#[derive(Serialize, ToSchema)]
pub struct UserDto {
pub id: uuid::Uuid,
pub email: String,
pub display_name: String,
#[schema(value_type = String, example = "admin")]
pub role: domain::user::Role,
pub is_active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<User> for UserDto {
fn from(u: User) -> Self {
Self {
id: u.id,
email: u.email,
display_name: u.display_name,
role: u.role,
is_active: u.is_active,
created_at: u.created_at,
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize, ToSchema)]
pub struct TokenResponse {
pub access_token: String,
pub user: UserDto,
}
#[utoipa::path(post, path = "/api/auth/login", request_body = LoginRequest, tag = "auth",
responses((status = 200, body = TokenResponse), (status = 401), (status = 403), (status = 429)))]
async fn login(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
Json(req): Json<LoginRequest>,
) -> Response {
if !state
.login_limiter
.check(ip.as_deref().unwrap_or("unknown"))
{
return simple(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many login attempts, try again later",
);
}
match state.auth.login(&req.email, &req.password, ip).await {
Ok(pair) => token_response(&state, pair).await,
Err(e) => ApiError(e).into_response(),
}
}
#[utoipa::path(post, path = "/api/auth/refresh", tag = "auth",
responses((status = 200, body = TokenResponse), (status = 401)))]
async fn refresh(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
headers: HeaderMap,
) -> Response {
let Some(token) = cookie(&headers, REFRESH_COOKIE) else {
return simple(
StatusCode::UNAUTHORIZED,
"invalid_token",
"missing refresh cookie",
);
};
match state.auth.refresh(&token, ip).await {
Ok(pair) => token_response(&state, pair).await,
Err(e) => ApiError(e).into_response(),
}
}
#[utoipa::path(post, path = "/api/auth/logout", tag = "auth", responses((status = 204)))]
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, ApiError> {
if let Some(token) = cookie(&headers, REFRESH_COOKIE) {
state.auth.logout(&token).await?;
}
Ok((
[(header::SET_COOKIE, clear_cookie(state.cfg.cookie_secure))],
StatusCode::NO_CONTENT,
)
.into_response())
}
#[utoipa::path(get, path = "/api/auth/me", tag = "auth", security(("bearer" = [])),
responses((status = 200, body = UserDto), (status = 401)))]
async fn me(AuthUser(user): AuthUser) -> Json<UserDto> {
Json(user.into())
}
async fn token_response(state: &AppState, pair: domain::auth::TokenPair) -> Response {
let claims = state.auth.authenticate(&pair.access_token).await;
match claims {
Ok(user) => (
[(
header::SET_COOKIE,
set_cookie(&pair.refresh_token, state.cfg.cookie_secure),
)],
Json(TokenResponse {
access_token: pair.access_token,
user: user.into(),
}),
)
.into_response(),
Err(e) => ApiError(e).into_response(),
}
}
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|kv| kv.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
}
fn cookie_attrs(secure: bool) -> String {
format!(
"Path=/api/auth; HttpOnly; SameSite=Strict{}",
if secure { "; Secure" } else { "" }
)
}
fn set_cookie(token: &str, secure: bool) -> HeaderValue {
let max_age = application::auth_service::REFRESH_TOKEN_TTL_DAYS * 24 * 3600;
HeaderValue::from_str(&format!(
"{REFRESH_COOKIE}={token}; Max-Age={max_age}; {}",
cookie_attrs(secure)
))
.unwrap()
}
fn clear_cookie(secure: bool) -> HeaderValue {
HeaderValue::from_str(&format!(
"{REFRESH_COOKIE}=; Max-Age=0; {}",
cookie_attrs(secure)
))
.unwrap()
}

View File

@ -0,0 +1,51 @@
//! Maps domain errors to HTTP responses of the shape `{"error": code, "message": text}`.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use domain::DomainError;
#[derive(Debug)]
pub struct ApiError(pub DomainError);
impl From<DomainError> for ApiError {
fn from(e: DomainError) -> Self {
ApiError(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
use DomainError::*;
let (status, code) = match &self.0 {
NotFound => (StatusCode::NOT_FOUND, "not_found"),
EmailTaken => (StatusCode::CONFLICT, "email_taken"),
LastAdmin => (StatusCode::CONFLICT, "last_admin"),
InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"),
InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"),
Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
Storage(msg) => {
tracing::error!("storage error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
};
let message = if code == "internal" {
"internal error".to_string()
} else {
self.0.to_string()
};
(
status,
Json(serde_json::json!({ "error": code, "message": message })),
)
.into_response()
}
}
pub fn simple(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
Json(serde_json::json!({ "error": code, "message": message })),
)
.into_response()
}

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)))
}
}

View File

@ -1,8 +1,20 @@
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
pub mod auth;
pub mod config;
pub mod error;
pub mod extract;
pub mod openapi;
pub mod rate_limit;
pub mod test_support;
pub mod users;
use std::sync::Arc;
use application::{AuthService, UserService};
use axum::{routing::get, Json, Router};
use infrastructure::{
Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers,
};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
@ -11,6 +23,42 @@ pub use config::Config;
#[derive(Clone)]
pub struct AppState {
pub cfg: Config,
pub auth: Arc<AuthService>,
pub users: Arc<UserService>,
pub login_limiter: Arc<rate_limit::RateLimiter>,
}
impl AppState {
/// Wire the services on top of a connected database.
pub fn new(cfg: Config, pool: DbPool) -> Self {
let users = Arc::new(SqliteUsers(pool.clone()));
let hasher = Arc::new(Argon2Hasher);
let auth = AuthService::new(
users.clone(),
Arc::new(SqliteRefreshTokens(pool.clone())),
Arc::new(SqliteAuditLog(pool)),
hasher.clone(),
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
);
Self {
cfg,
auth: Arc::new(auth),
users: Arc::new(UserService::new(users, hasher)),
login_limiter: Arc::new(rate_limit::RateLimiter::new(
10,
std::time::Duration::from_secs(60),
)),
}
}
pub async fn bootstrap(&self) -> anyhow::Result<()> {
if let Some((email, password)) = &self.cfg.bootstrap_admin {
if self.users.bootstrap_admin(email, password).await? {
tracing::info!("created bootstrap admin {email}");
}
}
Ok(())
}
}
pub fn build_app(state: AppState) -> Router {
@ -18,6 +66,9 @@ pub fn build_app(state: AppState) -> Router {
let spa = ServeDir::new(&state.cfg.frontend_dir).not_found_service(ServeFile::new(index));
Router::new()
.route("/healthz", get(healthz))
.route("/api/openapi.json", get(openapi::spec))
.nest("/api/auth", auth::router())
.nest("/api/users", users::router())
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state)

View File

@ -8,8 +8,20 @@ async fn main() -> anyhow::Result<()> {
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
let cfg = Config::from_env()?;
if let Some(dir) = cfg
.database_url
.strip_prefix("sqlite://")
.and_then(|p| p.split('?').next())
.and_then(|p| std::path::Path::new(p).parent())
{
std::fs::create_dir_all(dir)?;
}
let pool = infrastructure::connect(&cfg.database_url).await?;
let state = AppState::new(cfg.clone(), pool);
state.bootstrap().await?;
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, build_app(AppState { cfg })).await?;
let app = build_app(state).into_make_service_with_connect_info::<std::net::SocketAddr>();
axum::serve(listener, app).await?;
Ok(())
}

View File

@ -0,0 +1,34 @@
use axum::Json;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
struct BearerAuth;
impl Modify for BearerAuth {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
#[derive(OpenApi)]
#[openapi(
info(title = "SoftVisor Monitoring API", version = "0.1.0"),
paths(
crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me,
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
),
modifiers(&BearerAuth)
)]
pub struct ApiDoc;
pub async fn spec() -> Json<utoipa::openapi::OpenApi> {
Json(ApiDoc::openapi())
}

View File

@ -0,0 +1,43 @@
//! Minimal fixed-window rate limiter keyed by client identifier (IP).
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
max: u32,
window: Duration,
hits: Mutex<HashMap<String, (Instant, u32)>>,
}
impl RateLimiter {
pub fn new(max: u32, window: Duration) -> Self {
Self {
max,
window,
hits: Mutex::new(HashMap::new()),
}
}
/// Returns true if the request is allowed.
pub fn check(&self, key: &str) -> bool {
let mut hits = self.hits.lock().unwrap();
let now = Instant::now();
hits.retain(|_, (start, _)| now.duration_since(*start) < self.window);
let entry = hits.entry(key.to_string()).or_insert((now, 0));
entry.1 += 1;
entry.1 <= self.max
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_after_max_hits_per_key() {
let l = RateLimiter::new(3, Duration::from_secs(60));
assert!(l.check("a") && l.check("a") && l.check("a"));
assert!(!l.check("a"));
assert!(l.check("b"));
}
}

View File

@ -14,5 +14,22 @@ pub fn test_config() -> Config {
}
pub async fn build_test_app() -> Router {
build_app(AppState { cfg: test_config() })
build_test_app_with(test_config()).await
}
pub async fn build_test_app_with_admin(email: &str, password: &str) -> Router {
let cfg = Config {
bootstrap_admin: Some((email.into(), password.into())),
..test_config()
};
build_test_app_with(cfg).await
}
async fn build_test_app_with(cfg: Config) -> Router {
let pool = infrastructure::connect(&cfg.database_url)
.await
.expect("db");
let state = AppState::new(cfg, pool);
state.bootstrap().await.expect("bootstrap");
build_app(state)
}

View File

@ -0,0 +1,114 @@
//! /api/users: admin-only user management.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::user::{NewUser, Role, UserUpdate};
use serde::Deserialize;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::auth::UserDto;
use crate::error::ApiError;
use crate::extract::AdminUser;
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list).post(create))
.route("/{id}", get(get_one).patch(update))
.route("/{id}/password", post(reset_password))
}
#[derive(Deserialize, ToSchema)]
pub struct CreateUserRequest {
pub email: String,
pub display_name: String,
pub password: String,
#[schema(value_type = String, example = "admin")]
pub role: Role,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateUserRequest {
pub display_name: Option<String>,
#[schema(value_type = String, example = "admin")]
pub role: Option<Role>,
pub is_active: Option<bool>,
}
#[derive(Deserialize, ToSchema)]
pub struct PasswordRequest {
pub password: String,
}
#[utoipa::path(get, path = "/api/users", tag = "users", security(("bearer" = [])),
responses((status = 200, body = Vec<UserDto>), (status = 401), (status = 403)))]
async fn list(State(state): State<AppState>, _: AdminUser) -> Result<Json<Vec<UserDto>>, ApiError> {
Ok(Json(
state
.users
.list()
.await?
.into_iter()
.map(Into::into)
.collect(),
))
}
#[utoipa::path(post, path = "/api/users", tag = "users", security(("bearer" = [])), request_body = CreateUserRequest,
responses((status = 201, body = UserDto), (status = 409), (status = 422)))]
async fn create(
State(state): State<AppState>,
_: AdminUser,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserDto>), ApiError> {
let user = state
.users
.create(NewUser {
email: req.email,
display_name: req.display_name,
password: req.password,
role: req.role,
})
.await?;
Ok((StatusCode::CREATED, Json(user.into())))
}
#[utoipa::path(get, path = "/api/users/{id}", tag = "users", security(("bearer" = [])),
responses((status = 200, body = UserDto), (status = 404)))]
async fn get_one(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
) -> Result<Json<UserDto>, ApiError> {
Ok(Json(state.users.get(id).await?.into()))
}
#[utoipa::path(patch, path = "/api/users/{id}", tag = "users", security(("bearer" = [])), request_body = UpdateUserRequest,
responses((status = 200, body = UserDto), (status = 404), (status = 409)))]
async fn update(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserDto>, ApiError> {
let update = UserUpdate {
display_name: req.display_name,
role: req.role,
is_active: req.is_active,
};
Ok(Json(state.users.update(id, update).await?.into()))
}
#[utoipa::path(post, path = "/api/users/{id}/password", tag = "users", security(("bearer" = [])), request_body = PasswordRequest,
responses((status = 204), (status = 404), (status = 422)))]
async fn reset_password(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<PasswordRequest>,
) -> Result<StatusCode, ApiError> {
state.users.reset_password(id, &req.password).await?;
Ok(StatusCode::NO_CONTENT)
}