From f1136fdf3d3ec856b203b3eae5a15157359f8ef8 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Wed, 2 Sep 2026 21:37:14 +0200 Subject: [PATCH] WP-01: authentication, user management and application shell 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 --- ROADMAP.md | 8 +- backend/crates/api/src/auth.rs | 173 +++++++++++ backend/crates/api/src/error.rs | 51 ++++ backend/crates/api/src/extract.rs | 97 ++++++ backend/crates/api/src/lib.rs | 51 ++++ backend/crates/api/src/main.rs | 14 +- backend/crates/api/src/openapi.rs | 34 +++ backend/crates/api/src/rate_limit.rs | 43 +++ backend/crates/api/src/test_support.rs | 19 +- backend/crates/api/src/users.rs | 114 +++++++ .../crates/application/src/auth_service.rs | 159 ++++++++-- .../crates/application/src/user_service.rs | 73 +++-- .../migrations/0001_users_and_auth.sql | 28 ++ backend/crates/infrastructure/src/db.rs | 17 ++ backend/crates/infrastructure/src/lib.rs | 11 +- backend/crates/infrastructure/src/password.rs | 42 +++ backend/crates/infrastructure/src/sqlite.rs | 282 ++++++++++++++++++ backend/crates/infrastructure/src/token.rs | 89 ++++++ frontend/src/App.vue | 5 +- frontend/src/api/client.ts | 62 ++++ frontend/src/api/types.ts | 28 ++ frontend/src/components/AppShell.vue | 49 +++ frontend/src/components/Toast.vue | 18 ++ frontend/src/components/UserForm.vue | 104 +++++++ frontend/src/pages/DashboardPage.vue | 6 + frontend/src/pages/LoginPage.vue | 73 +++++ frontend/src/pages/PlaceholderPage.vue | 8 + frontend/src/pages/UsersPage.vue | 178 +++++++++++ frontend/src/router.ts | 41 ++- frontend/src/stores/auth.test.ts | 4 +- frontend/src/stores/auth.ts | 43 +++ frontend/src/stores/toast.ts | 23 ++ 32 files changed, 1899 insertions(+), 48 deletions(-) create mode 100644 backend/crates/api/src/auth.rs create mode 100644 backend/crates/api/src/error.rs create mode 100644 backend/crates/api/src/extract.rs create mode 100644 backend/crates/api/src/openapi.rs create mode 100644 backend/crates/api/src/rate_limit.rs create mode 100644 backend/crates/api/src/users.rs create mode 100644 backend/crates/infrastructure/migrations/0001_users_and_auth.sql create mode 100644 backend/crates/infrastructure/src/db.rs create mode 100644 backend/crates/infrastructure/src/password.rs create mode 100644 backend/crates/infrastructure/src/sqlite.rs create mode 100644 backend/crates/infrastructure/src/token.rs create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/components/AppShell.vue create mode 100644 frontend/src/components/Toast.vue create mode 100644 frontend/src/components/UserForm.vue create mode 100644 frontend/src/pages/DashboardPage.vue create mode 100644 frontend/src/pages/LoginPage.vue create mode 100644 frontend/src/pages/PlaceholderPage.vue create mode 100644 frontend/src/pages/UsersPage.vue create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/toast.ts diff --git a/ROADMAP.md b/ROADMAP.md index 36a8ded..4510282 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SoftVisor Infrastructure Monitoring System – Roadmap -Status: Draft v1 (2026-09-02) +Status: v1.1 (2026-09-02) – Milestone 1 delivered This document is derived from `CLAUDE.md`. It breaks the project into work packages (WPs), fixes the technical decisions that are not dictated by `CLAUDE.md`, and lists the open @@ -352,9 +352,9 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect | WP | Milestone | Status | Notes | |----|-----------|--------|-------| -| WP-00 | M1 | todo | | -| WP-01 | M1 | todo | | -| WP-02 | M1 (shell) / M2 (rest) | todo | | +| WP-00 | M1 | done | 2026-09-02 | +| WP-01 | M1 | done | 2026-09-02 | +| WP-02 | M1 (shell) / M2 (rest) | shell done | shell, toasts, placeholders 2026-09-02; secrets/scheduler/SMTP in M2 | | WP-10 | M2 | todo | | | WP-11 | M2 | todo | | | WP-12 | M2 | todo | | diff --git a/backend/crates/api/src/auth.rs b/backend/crates/api/src/auth.rs new file mode 100644 index 0000000..aee3a1d --- /dev/null +++ b/backend/crates/api/src/auth.rs @@ -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 { + 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, +} + +impl From 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, + ClientIp(ip): ClientIp, + Json(req): Json, +) -> 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, + 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, headers: HeaderMap) -> Result { + 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 { + 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 { + 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() +} diff --git a/backend/crates/api/src/error.rs b/backend/crates/api/src/error.rs new file mode 100644 index 0000000..d08077b --- /dev/null +++ b/backend/crates/api/src/error.rs @@ -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 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() +} diff --git a/backend/crates/api/src/extract.rs b/backend/crates/api/src/extract.rs new file mode 100644 index 0000000..9244033 --- /dev/null +++ b/backend/crates/api/src/extract.rs @@ -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 for AuthUser { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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 for AdminUser { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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 { + 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::>() + .map(|c| c.0.ip().to_string()) + }) +} + +pub struct ClientIp(pub Option); + +impl FromRequestParts for ClientIp { + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + Ok(ClientIp(client_ip(parts))) + } +} diff --git a/backend/crates/api/src/lib.rs b/backend/crates/api/src/lib.rs index abc0265..f9aab7e 100644 --- a/backend/crates/api/src/lib.rs +++ b/backend/crates/api/src/lib.rs @@ -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, + pub users: Arc, + pub login_limiter: Arc, +} + +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) diff --git a/backend/crates/api/src/main.rs b/backend/crates/api/src/main.rs index c326527..0b12a0d 100644 --- a/backend/crates/api/src/main.rs +++ b/backend/crates/api/src/main.rs @@ -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::(); + axum::serve(listener, app).await?; Ok(()) } diff --git a/backend/crates/api/src/openapi.rs b/backend/crates/api/src/openapi.rs new file mode 100644 index 0000000..0669be3 --- /dev/null +++ b/backend/crates/api/src/openapi.rs @@ -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 { + Json(ApiDoc::openapi()) +} diff --git a/backend/crates/api/src/rate_limit.rs b/backend/crates/api/src/rate_limit.rs new file mode 100644 index 0000000..c7956a6 --- /dev/null +++ b/backend/crates/api/src/rate_limit.rs @@ -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>, +} + +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")); + } +} diff --git a/backend/crates/api/src/test_support.rs b/backend/crates/api/src/test_support.rs index d1f91a0..597aaea 100644 --- a/backend/crates/api/src/test_support.rs +++ b/backend/crates/api/src/test_support.rs @@ -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) } diff --git a/backend/crates/api/src/users.rs b/backend/crates/api/src/users.rs new file mode 100644 index 0000000..633a1ee --- /dev/null +++ b/backend/crates/api/src/users.rs @@ -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 { + 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, + #[schema(value_type = String, example = "admin")] + pub role: Option, + pub is_active: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct PasswordRequest { + pub password: String, +} + +#[utoipa::path(get, path = "/api/users", tag = "users", security(("bearer" = [])), + responses((status = 200, body = Vec), (status = 401), (status = 403)))] +async fn list(State(state): State, _: AdminUser) -> Result>, 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, + _: AdminUser, + Json(req): Json, +) -> Result<(StatusCode, Json), 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, + _: AdminUser, + Path(id): Path, +) -> Result, 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, + _: AdminUser, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + _: AdminUser, + Path(id): Path, + Json(req): Json, +) -> Result { + state.users.reset_password(id, &req.password).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/crates/application/src/auth_service.rs b/backend/crates/application/src/auth_service.rs index 9566827..c8decbe 100644 --- a/backend/crates/application/src/auth_service.rs +++ b/backend/crates/application/src/auth_service.rs @@ -1,22 +1,27 @@ use std::sync::Arc; -use chrono::Duration; -use domain::auth::TokenPair; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chrono::{Duration, Utc}; +use domain::auth::{AuthEvent, AuthEventKind, RefreshToken, TokenPair}; use domain::ports::{ AccessTokenIssuer, AuditLog, PasswordHasher, RefreshTokenRepository, UserRepository, }; use domain::user::User; use domain::DomainError; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use uuid::Uuid; pub const REFRESH_TOKEN_TTL_DAYS: i64 = 30; pub struct AuthService { - pub(crate) users: Arc, - pub(crate) refresh: Arc, - pub(crate) audit: Arc, - pub(crate) hasher: Arc, - pub(crate) tokens: Arc, - pub(crate) refresh_ttl: Duration, + users: Arc, + refresh: Arc, + audit: Arc, + hasher: Arc, + tokens: Arc, + refresh_ttl: Duration, } impl AuthService { @@ -39,27 +44,143 @@ impl AuthService { pub async fn login( &self, - _email: &str, - _password: &str, - _ip: Option, + email: &str, + password: &str, + ip: Option, ) -> Result { - todo!() + let email = email.trim().to_lowercase(); + let user = self.users.find_by_email(&email).await?; + let valid = user + .as_ref() + .is_some_and(|u| self.hasher.verify(password, &u.password_hash)); + let Some(user) = user.filter(|_| valid) else { + self.record(None, &email, AuthEventKind::LoginFailed, ip) + .await?; + return Err(DomainError::InvalidCredentials); + }; + if !user.is_active { + self.record(Some(user.id), &email, AuthEventKind::LoginFailed, ip) + .await?; + return Err(DomainError::InactiveUser); + } + let pair = self.issue_pair(&user, Uuid::new_v4()).await?; + self.record(Some(user.id), &email, AuthEventKind::LoginSuccess, ip) + .await?; + Ok(pair) } pub async fn refresh( &self, - _refresh_token: &str, - _ip: Option, + refresh_token: &str, + ip: Option, ) -> Result { - todo!() + let stored = self + .refresh + .find_by_hash(&hash_token(refresh_token)) + .await? + .ok_or(DomainError::InvalidToken)?; + let user = self + .users + .find_by_id(stored.user_id) + .await? + .ok_or(DomainError::InvalidToken)?; + if stored.revoked { + // A revoked token is presented again: someone else may hold the rotated one. + self.refresh.revoke_family(stored.family).await?; + self.record( + Some(user.id), + &user.email, + AuthEventKind::RefreshReuseDetected, + ip, + ) + .await?; + return Err(DomainError::InvalidToken); + } + if !stored.is_valid(Utc::now()) { + return Err(DomainError::InvalidToken); + } + if !user.is_active { + return Err(DomainError::InactiveUser); + } + self.refresh.revoke(stored.id).await?; + let pair = self.issue_pair(&user, stored.family).await?; + self.record(Some(user.id), &user.email, AuthEventKind::Refresh, ip) + .await?; + Ok(pair) } - pub async fn logout(&self, _refresh_token: &str) -> Result<(), DomainError> { - todo!() + pub async fn logout(&self, refresh_token: &str) -> Result<(), DomainError> { + if let Some(stored) = self + .refresh + .find_by_hash(&hash_token(refresh_token)) + .await? + { + self.refresh.revoke_family(stored.family).await?; + if let Some(user) = self.users.find_by_id(stored.user_id).await? { + self.record(Some(user.id), &user.email, AuthEventKind::Logout, None) + .await?; + } + } + Ok(()) } /// Resolve the user behind an access token; fails for invalid tokens and inactive users. - pub async fn authenticate(&self, _access_token: &str) -> Result { - todo!() + pub async fn authenticate(&self, access_token: &str) -> Result { + let claims = self.tokens.verify(access_token)?; + let user = self + .users + .find_by_id(claims.sub) + .await? + .ok_or(DomainError::InvalidToken)?; + if !user.is_active { + return Err(DomainError::InactiveUser); + } + Ok(user) + } + + async fn issue_pair(&self, user: &User, family: Uuid) -> Result { + let raw = random_token(); + self.refresh + .insert(&RefreshToken { + id: Uuid::new_v4(), + user_id: user.id, + family, + token_hash: hash_token(&raw), + expires_at: Utc::now() + self.refresh_ttl, + revoked: false, + }) + .await?; + Ok(TokenPair { + access_token: self.tokens.issue(user)?, + refresh_token: raw, + }) + } + + async fn record( + &self, + user_id: Option, + email: &str, + kind: AuthEventKind, + ip: Option, + ) -> Result<(), DomainError> { + self.audit + .record(&AuthEvent { + user_id, + email: email.into(), + kind, + ip, + at: Utc::now(), + }) + .await } } + +fn random_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn hash_token(raw: &str) -> String { + format!("{:x}", Sha256::digest(raw.as_bytes())) +} diff --git a/backend/crates/application/src/user_service.rs b/backend/crates/application/src/user_service.rs index dc91c15..80e6947 100644 --- a/backend/crates/application/src/user_service.rs +++ b/backend/crates/application/src/user_service.rs @@ -1,13 +1,14 @@ use std::sync::Arc; +use chrono::Utc; use domain::ports::{PasswordHasher, UserRepository}; -use domain::user::{NewUser, User, UserUpdate}; +use domain::user::{validate_email, validate_password, NewUser, Role, User, UserUpdate}; use domain::DomainError; use uuid::Uuid; pub struct UserService { - pub(crate) users: Arc, - pub(crate) hasher: Arc, + users: Arc, + hasher: Arc, } impl UserService { @@ -16,31 +17,67 @@ impl UserService { } pub async fn list(&self) -> Result, DomainError> { - todo!() + self.users.list().await } - pub async fn get(&self, _id: Uuid) -> Result { - todo!() + pub async fn get(&self, id: Uuid) -> Result { + self.users + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound) } - pub async fn create(&self, _new: NewUser) -> Result { - todo!() + pub async fn create(&self, new: NewUser) -> Result { + let email = new.email.trim().to_lowercase(); + validate_email(&email)?; + validate_password(&new.password)?; + if self.users.find_by_email(&email).await?.is_some() { + return Err(DomainError::EmailTaken); + } + let user = User { + id: Uuid::new_v4(), + email, + display_name: new.display_name.trim().to_string(), + password_hash: self.hasher.hash(&new.password)?, + role: new.role, + is_active: true, + created_at: Utc::now(), + }; + self.users.insert(&user).await?; + Ok(user) } - pub async fn update(&self, _id: Uuid, _update: UserUpdate) -> Result { - todo!() + pub async fn update(&self, id: Uuid, update: UserUpdate) -> Result { + let current = self.get(id).await?; + let loses_admin = current.is_admin() + && current.is_active + && (update.role == Some(Role::User) || update.is_active == Some(false)); + if loses_admin && self.users.count_active_admins().await? <= 1 { + return Err(DomainError::LastAdmin); + } + self.users.update(id, &update).await } - pub async fn reset_password(&self, _id: Uuid, _password: &str) -> Result<(), DomainError> { - todo!() + pub async fn reset_password(&self, id: Uuid, password: &str) -> Result<(), DomainError> { + validate_password(password)?; + self.get(id).await?; + self.users + .set_password_hash(id, &self.hasher.hash(password)?) + .await } /// Create the initial admin if the user table is empty. Returns true if created. - pub async fn bootstrap_admin( - &self, - _email: &str, - _password: &str, - ) -> Result { - todo!() + pub async fn bootstrap_admin(&self, email: &str, password: &str) -> Result { + if self.users.count().await? > 0 { + return Ok(false); + } + self.create(NewUser { + email: email.into(), + display_name: "Administrator".into(), + password: password.into(), + role: Role::Admin, + }) + .await?; + Ok(true) } } diff --git a/backend/crates/infrastructure/migrations/0001_users_and_auth.sql b/backend/crates/infrastructure/migrations/0001_users_and_auth.sql new file mode 100644 index 0000000..13e42d5 --- /dev/null +++ b/backend/crates/infrastructure/migrations/0001_users_and_auth.sql @@ -0,0 +1,28 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'user')), + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); + +CREATE TABLE refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + family TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX refresh_tokens_family ON refresh_tokens(family); + +CREATE TABLE auth_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + email TEXT NOT NULL, + kind TEXT NOT NULL, + ip TEXT, + at TEXT NOT NULL +); diff --git a/backend/crates/infrastructure/src/db.rs b/backend/crates/infrastructure/src/db.rs new file mode 100644 index 0000000..2301fec --- /dev/null +++ b/backend/crates/infrastructure/src/db.rs @@ -0,0 +1,17 @@ +use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; + +pub type DbPool = SqlitePool; + +/// Connect and run migrations. In-memory URLs get a single connection so the schema persists. +pub async fn connect(url: &str) -> anyhow::Result { + let in_memory = url.contains(":memory:"); + let pool = SqlitePoolOptions::new() + .max_connections(if in_memory { 1 } else { 5 }) + .connect(url) + .await?; + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await?; + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(pool) +} diff --git a/backend/crates/infrastructure/src/lib.rs b/backend/crates/infrastructure/src/lib.rs index 4aa327b..ec9270f 100644 --- a/backend/crates/infrastructure/src/lib.rs +++ b/backend/crates/infrastructure/src/lib.rs @@ -1 +1,10 @@ -//! infrastructure layer +//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing. +pub mod db; +pub mod password; +pub mod sqlite; +pub mod token; + +pub use db::{connect, DbPool}; +pub use password::Argon2Hasher; +pub use sqlite::{SqliteAuditLog, SqliteRefreshTokens, SqliteUsers}; +pub use token::JwtIssuer; diff --git a/backend/crates/infrastructure/src/password.rs b/backend/crates/infrastructure/src/password.rs new file mode 100644 index 0000000..9f5837a --- /dev/null +++ b/backend/crates/infrastructure/src/password.rs @@ -0,0 +1,42 @@ +use argon2::password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString, +}; +use argon2::Argon2; +use domain::ports::PasswordHasher; +use domain::DomainError; + +#[derive(Default)] +pub struct Argon2Hasher; + +impl PasswordHasher for Argon2Hasher { + fn hash(&self, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| DomainError::Storage(e.to_string())) + } + + fn verify(&self, password: &str, hash: &str) -> bool { + PasswordHash::new(hash).is_ok_and(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_roundtrip() { + let h = Argon2Hasher; + let hash = h.hash("correct-horse-battery").unwrap(); + assert!(hash.starts_with("$argon2id$")); + assert!(h.verify("correct-horse-battery", &hash)); + assert!(!h.verify("wrong", &hash)); + assert!(!h.verify("x", "not-a-hash")); + } +} diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs new file mode 100644 index 0000000..d9921bb --- /dev/null +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -0,0 +1,282 @@ +//! SQLite implementations of the repository ports. +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use domain::auth::{AuthEvent, RefreshToken}; +use domain::ports::{AuditLog, RefreshTokenRepository, UserRepository}; +use domain::user::{Role, User, UserUpdate}; +use domain::DomainError; +use sqlx::sqlite::SqliteRow; +use sqlx::Row; +use uuid::Uuid; + +use crate::DbPool; + +fn storage(e: sqlx::Error) -> DomainError { + DomainError::Storage(e.to_string()) +} + +fn parse_ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s) + .map(|d| d.with_timezone(&Utc)) + .unwrap_or_default() +} + +fn user_from_row(r: &SqliteRow) -> User { + User { + id: r.get::("id"), + email: r.get("email"), + display_name: r.get("display_name"), + password_hash: r.get("password_hash"), + role: Role::parse(r.get::("role").as_str()).unwrap_or(Role::User), + is_active: r.get::("is_active"), + created_at: parse_ts(r.get::("created_at").as_str()), + } +} + +const USER_COLS: &str = "id, email, display_name, password_hash, role, is_active, created_at"; + +pub struct SqliteUsers(pub DbPool); + +#[async_trait] +impl UserRepository for SqliteUsers { + async fn find_by_id(&self, id: Uuid) -> Result, DomainError> { + sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?")) + .bind(id) + .fetch_optional(&self.0) + .await + .map(|r| r.as_ref().map(user_from_row)) + .map_err(storage) + } + + async fn find_by_email(&self, email: &str) -> Result, DomainError> { + sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?")) + .bind(email) + .fetch_optional(&self.0) + .await + .map(|r| r.as_ref().map(user_from_row)) + .map_err(storage) + } + + async fn list(&self) -> Result, DomainError> { + sqlx::query(&format!("SELECT {USER_COLS} FROM users ORDER BY email")) + .fetch_all(&self.0) + .await + .map(|rows| rows.iter().map(user_from_row).collect()) + .map_err(storage) + } + + async fn count(&self) -> Result { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&self.0) + .await + .map_err(storage)?; + Ok(n as u64) + } + + async fn count_active_admins(&self) -> Result { + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = 1") + .fetch_one(&self.0) + .await + .map_err(storage)?; + Ok(n as u64) + } + + async fn insert(&self, u: &User) -> Result<(), DomainError> { + sqlx::query(&format!( + "INSERT INTO users ({USER_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?)" + )) + .bind(u.id) + .bind(&u.email) + .bind(&u.display_name) + .bind(&u.password_hash) + .bind(u.role.as_str()) + .bind(u.is_active) + .bind(u.created_at.to_rfc3339()) + .execute(&self.0) + .await + .map(|_| ()) + .map_err(storage) + } + + async fn update(&self, id: Uuid, up: &UserUpdate) -> Result { + let res = sqlx::query( + "UPDATE users SET display_name = COALESCE(?, display_name), role = COALESCE(?, role), \ + is_active = COALESCE(?, is_active) WHERE id = ?", + ) + .bind(&up.display_name) + .bind(up.role.map(Role::as_str)) + .bind(up.is_active) + .bind(id) + .execute(&self.0) + .await + .map_err(storage)?; + if res.rows_affected() == 0 { + return Err(DomainError::NotFound); + } + self.find_by_id(id).await?.ok_or(DomainError::NotFound) + } + + async fn set_password_hash(&self, id: Uuid, hash: &str) -> Result<(), DomainError> { + let res = sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(hash) + .bind(id) + .execute(&self.0) + .await + .map_err(storage)?; + (res.rows_affected() > 0) + .then_some(()) + .ok_or(DomainError::NotFound) + } +} + +pub struct SqliteRefreshTokens(pub DbPool); + +#[async_trait] +impl RefreshTokenRepository for SqliteRefreshTokens { + async fn insert(&self, t: &RefreshToken) -> Result<(), DomainError> { + sqlx::query("INSERT INTO refresh_tokens (id, user_id, family, token_hash, expires_at, revoked) VALUES (?, ?, ?, ?, ?, ?)") + .bind(t.id) + .bind(t.user_id) + .bind(t.family) + .bind(&t.token_hash) + .bind(t.expires_at.to_rfc3339()) + .bind(t.revoked) + .execute(&self.0) + .await + .map(|_| ()) + .map_err(storage) + } + + async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { + sqlx::query("SELECT id, user_id, family, token_hash, expires_at, revoked FROM refresh_tokens WHERE token_hash = ?") + .bind(hash) + .fetch_optional(&self.0) + .await + .map(|row| { + row.map(|r| RefreshToken { + id: r.get("id"), + user_id: r.get("user_id"), + family: r.get("family"), + token_hash: r.get("token_hash"), + expires_at: parse_ts(r.get::("expires_at").as_str()), + revoked: r.get("revoked"), + }) + }) + .map_err(storage) + } + + async fn revoke(&self, id: Uuid) -> Result<(), DomainError> { + sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?") + .bind(id) + .execute(&self.0) + .await + .map(|_| ()) + .map_err(storage) + } + + async fn revoke_family(&self, family: Uuid) -> Result<(), DomainError> { + sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE family = ?") + .bind(family) + .execute(&self.0) + .await + .map(|_| ()) + .map_err(storage) + } +} + +pub struct SqliteAuditLog(pub DbPool); + +#[async_trait] +impl AuditLog for SqliteAuditLog { + async fn record(&self, e: &AuthEvent) -> Result<(), DomainError> { + sqlx::query("INSERT INTO auth_events (user_id, email, kind, ip, at) VALUES (?, ?, ?, ?, ?)") + .bind(e.user_id) + .bind(&e.email) + .bind(e.kind.as_str()) + .bind(&e.ip) + .bind(e.at.to_rfc3339()) + .execute(&self.0) + .await + .map(|_| ()) + .map_err(storage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn users_roundtrip_and_update() { + let pool = crate::connect("sqlite::memory:").await.unwrap(); + let repo = SqliteUsers(pool); + let u = User { + id: Uuid::new_v4(), + email: "a@x.de".into(), + display_name: "A".into(), + password_hash: "h".into(), + role: Role::Admin, + is_active: true, + created_at: Utc::now(), + }; + repo.insert(&u).await.unwrap(); + assert_eq!( + repo.find_by_email("a@x.de").await.unwrap().unwrap().id, + u.id + ); + assert_eq!(repo.count_active_admins().await.unwrap(), 1); + let updated = repo + .update( + u.id, + &UserUpdate { + is_active: Some(false), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!(!updated.is_active); + assert_eq!(updated.display_name, "A"); + assert_eq!( + repo.update(Uuid::new_v4(), &UserUpdate::default()) + .await + .unwrap_err(), + DomainError::NotFound + ); + } + + #[tokio::test] + async fn refresh_tokens_revoke_by_family() { + let pool = crate::connect("sqlite::memory:").await.unwrap(); + let users = SqliteUsers(pool.clone()); + let u = User { + id: Uuid::new_v4(), + email: "a@x.de".into(), + display_name: "A".into(), + password_hash: "h".into(), + role: Role::User, + is_active: true, + created_at: Utc::now(), + }; + users.insert(&u).await.unwrap(); + let repo = SqliteRefreshTokens(pool); + let family = Uuid::new_v4(); + for h in ["h1", "h2"] { + repo.insert(&RefreshToken { + id: Uuid::new_v4(), + user_id: u.id, + family, + token_hash: h.into(), + expires_at: Utc::now() + chrono::Duration::days(1), + revoked: false, + }) + .await + .unwrap(); + } + repo.revoke_family(family).await.unwrap(); + assert!(repo.find_by_hash("h1").await.unwrap().unwrap().revoked); + assert!(repo.find_by_hash("h2").await.unwrap().unwrap().revoked); + assert!(repo.find_by_hash("nope").await.unwrap().is_none()); + } +} diff --git a/backend/crates/infrastructure/src/token.rs b/backend/crates/infrastructure/src/token.rs new file mode 100644 index 0000000..cfb1c90 --- /dev/null +++ b/backend/crates/infrastructure/src/token.rs @@ -0,0 +1,89 @@ +use chrono::{Duration, Utc}; +use domain::auth::AccessClaims; +use domain::ports::AccessTokenIssuer; +use domain::user::User; +use domain::DomainError; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; + +pub const ACCESS_TOKEN_TTL_MINUTES: i64 = 15; + +pub struct JwtIssuer { + enc: EncodingKey, + dec: DecodingKey, + ttl: Duration, +} + +impl JwtIssuer { + pub fn new(secret: &str) -> Self { + Self { + enc: EncodingKey::from_secret(secret.as_bytes()), + dec: DecodingKey::from_secret(secret.as_bytes()), + ttl: Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), + } + } +} + +impl AccessTokenIssuer for JwtIssuer { + fn issue(&self, user: &User) -> Result { + let claims = AccessClaims { + sub: user.id, + role: user.role, + exp: (Utc::now() + self.ttl).timestamp(), + }; + jsonwebtoken::encode(&Header::default(), &claims, &self.enc) + .map_err(|e| DomainError::Storage(e.to_string())) + } + + fn verify(&self, token: &str) -> Result { + jsonwebtoken::decode::(token, &self.dec, &Validation::default()) + .map(|d| d.claims) + .map_err(|_| DomainError::InvalidToken) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::user::Role; + use uuid::Uuid; + + fn user() -> User { + User { + id: Uuid::new_v4(), + email: "a@x.de".into(), + display_name: "A".into(), + password_hash: String::new(), + role: Role::Admin, + is_active: true, + created_at: Utc::now(), + } + } + + #[test] + fn issue_and_verify_roundtrip() { + let issuer = JwtIssuer::new("0123456789012345678901234567890123456789"); + let u = user(); + let claims = issuer.verify(&issuer.issue(&u).unwrap()).unwrap(); + assert_eq!(claims.sub, u.id); + assert_eq!(claims.role, Role::Admin); + } + + #[test] + fn other_secret_and_expired_tokens_are_rejected() { + let a = JwtIssuer::new("0123456789012345678901234567890123456789"); + let b = JwtIssuer::new("abcdefghijabcdefghijabcdefghijabcdefghij"); + assert_eq!( + b.verify(&a.issue(&user()).unwrap()).unwrap_err(), + DomainError::InvalidToken + ); + let expired = JwtIssuer { + ttl: Duration::minutes(-10), + ..JwtIssuer::new("0123456789012345678901234567890123456789") + }; + assert_eq!( + a.verify(&expired.issue(&user()).unwrap()).unwrap_err(), + DomainError::InvalidToken + ); + assert_eq!(a.verify("garbage").unwrap_err(), DomainError::InvalidToken); + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 613fac0..a892acb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,8 @@ - + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..a31da15 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,62 @@ +import { useAuthStore } from '../stores/auth' +import type { TokenResponse } from './types' + +export class ApiError extends Error { + status: number + code: string + constructor(status: number, code: string, message: string) { + super(message) + this.status = status + this.code = code + } +} + +async function parse(res: Response): Promise { + if (res.status === 204) return undefined + const text = await res.text() + return text ? JSON.parse(text) : undefined +} + +async function toError(res: Response): Promise { + const body = (await parse(res).catch(() => undefined)) as + { error?: string; message?: string } | undefined + return new ApiError(res.status, body?.error ?? 'unknown', body?.message ?? res.statusText) +} + +/** Refresh the access token via the HttpOnly cookie. Returns false if it failed. */ +export async function refreshAccessToken(): Promise { + const auth = useAuthStore() + const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin' }) + if (!res.ok) { + auth.clear() + return false + } + const data = (await parse(res)) as TokenResponse + auth.setSession(data) + return true +} + +async function request(method: string, path: string, body?: unknown, retry = true): Promise { + const auth = useAuthStore() + const headers: Record = {} + if (body !== undefined) headers['Content-Type'] = 'application/json' + if (auth.accessToken) headers['Authorization'] = `Bearer ${auth.accessToken}` + const res = await fetch(path, { + method, + headers, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (res.status === 401 && retry && !path.startsWith('/api/auth/')) { + if (await refreshAccessToken()) return request(method, path, body, false) + throw new ApiError(401, 'unauthorized', 'session expired') + } + if (!res.ok) throw await toError(res) + return (await parse(res)) as T +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + patch: (path: string, body?: unknown) => request('PATCH', path, body), +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..2a962eb --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,28 @@ +export type Role = 'admin' | 'user' + +export interface User { + id: string + email: string + display_name: string + role: Role + is_active: boolean + created_at?: string +} + +export interface TokenResponse { + access_token: string + user: User +} + +export interface CreateUserPayload { + email: string + display_name: string + password: string + role: Role +} + +export interface UpdateUserPayload { + display_name?: string + role?: Role + is_active?: boolean +} diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue new file mode 100644 index 0000000..069eed8 --- /dev/null +++ b/frontend/src/components/AppShell.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/Toast.vue b/frontend/src/components/Toast.vue new file mode 100644 index 0000000..2604875 --- /dev/null +++ b/frontend/src/components/Toast.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/UserForm.vue b/frontend/src/components/UserForm.vue new file mode 100644 index 0000000..9a6e049 --- /dev/null +++ b/frontend/src/components/UserForm.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue new file mode 100644 index 0000000..447b17d --- /dev/null +++ b/frontend/src/pages/DashboardPage.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue new file mode 100644 index 0000000..c432f47 --- /dev/null +++ b/frontend/src/pages/LoginPage.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/pages/PlaceholderPage.vue b/frontend/src/pages/PlaceholderPage.vue new file mode 100644 index 0000000..c08143a --- /dev/null +++ b/frontend/src/pages/PlaceholderPage.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/pages/UsersPage.vue b/frontend/src/pages/UsersPage.vue new file mode 100644 index 0000000..713a4e5 --- /dev/null +++ b/frontend/src/pages/UsersPage.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index a73d72e..fd2fad8 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -1,7 +1,44 @@ import { createRouter, createWebHistory } from 'vue-router' -import HealthPage from './pages/HealthPage.vue' +import { useAuthStore } from './stores/auth' +import { refreshAccessToken } from './api/client' +import AppShell from './components/AppShell.vue' +import LoginPage from './pages/LoginPage.vue' +import DashboardPage from './pages/DashboardPage.vue' +import UsersPage from './pages/UsersPage.vue' +import PlaceholderPage from './pages/PlaceholderPage.vue' export const router = createRouter({ history: createWebHistory(), - routes: [{ path: '/', component: HealthPage }], + routes: [ + { path: '/login', component: LoginPage, meta: { public: true } }, + { + path: '/', + component: AppShell, + children: [ + { path: '', name: 'dashboard', component: DashboardPage }, + { path: 'updates', component: PlaceholderPage, props: { title: 'Updates' } }, + { + path: 'vulnerabilities', + component: PlaceholderPage, + props: { title: 'Vulnerabilities' }, + }, + { path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } }, + { path: 'users', component: UsersPage, meta: { admin: true } }, + { path: 'settings', component: PlaceholderPage, props: { title: 'Settings' } }, + ], + }, + ], +}) + +let sessionRestored = false + +router.beforeEach(async (to) => { + const auth = useAuthStore() + if (!sessionRestored) { + sessionRestored = true + if (!auth.isAuthenticated) await refreshAccessToken().catch(() => false) + } + if (to.meta.public) return auth.isAuthenticated ? '/' : true + if (!auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } } + return true }) diff --git a/frontend/src/stores/auth.test.ts b/frontend/src/stores/auth.test.ts index 1968d2a..8debf0a 100644 --- a/frontend/src/stores/auth.test.ts +++ b/frontend/src/stores/auth.test.ts @@ -2,7 +2,9 @@ import { setActivePinia, createPinia } from 'pinia' import { useAuthStore } from './auth' import { api } from '../api/client' -const admin = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true } +import type { User } from '../api/types' + +const admin: User = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true } function mockFetch(responses: Array<{ status: number; body?: unknown }>) { const calls: Array<{ url: string; init: RequestInit }> = [] diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..46548be --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { api, ApiError } from '../api/client' +import type { TokenResponse, User } from '../api/types' + +export const useAuthStore = defineStore('auth', () => { + const accessToken = ref(null) + const user = ref(null) + const isAuthenticated = computed(() => user.value !== null) + const isAdmin = computed(() => user.value?.role === 'admin') + + function setSession(data: TokenResponse) { + accessToken.value = data.access_token + user.value = data.user + } + + function clear() { + accessToken.value = null + user.value = null + } + + async function login(email: string, password: string) { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ email, password }), + }) + const body = await res.json().catch(() => ({})) + if (!res.ok) throw new ApiError(res.status, body.error ?? 'unknown', body.message ?? '') + setSession(body as TokenResponse) + } + + async function logout() { + try { + await api.post('/api/auth/logout') + } finally { + clear() + } + } + + return { accessToken, user, isAuthenticated, isAdmin, setSession, clear, login, logout } +}) diff --git a/frontend/src/stores/toast.ts b/frontend/src/stores/toast.ts new file mode 100644 index 0000000..dbbd793 --- /dev/null +++ b/frontend/src/stores/toast.ts @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface Toast { + id: number + kind: 'success' | 'error' + message: string +} + +export const useToastStore = defineStore('toast', () => { + const items = ref([]) + let next = 1 + function push(kind: Toast['kind'], message: string) { + const id = next++ + items.value.push({ id, kind, message }) + setTimeout(() => (items.value = items.value.filter((t) => t.id !== id)), 4000) + } + return { + items, + success: (m: string) => push('success', m), + error: (m: string) => push('error', m), + } +})