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