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 <noreply@anthropic.com>
This commit is contained in:
@ -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 | |
|
||||
|
||||
173
backend/crates/api/src/auth.rs
Normal file
173
backend/crates/api/src/auth.rs
Normal file
@ -0,0 +1,173 @@
|
||||
//! /api/auth: login, refresh, logout, me.
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::user::User;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::error::{simple, ApiError};
|
||||
use crate::extract::{AuthUser, ClientIp};
|
||||
use crate::AppState;
|
||||
|
||||
pub const REFRESH_COOKIE: &str = "refresh_token";
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/refresh", post(refresh))
|
||||
.route("/logout", post(logout))
|
||||
.route("/me", get(me))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserDto {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
#[schema(value_type = String, example = "admin")]
|
||||
pub role: domain::user::Role,
|
||||
pub is_active: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
role: u.role,
|
||||
is_active: u.is_active,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub user: UserDto,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/auth/login", request_body = LoginRequest, tag = "auth",
|
||||
responses((status = 200, body = TokenResponse), (status = 401), (status = 403), (status = 429)))]
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Response {
|
||||
if !state
|
||||
.login_limiter
|
||||
.check(ip.as_deref().unwrap_or("unknown"))
|
||||
{
|
||||
return simple(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limited",
|
||||
"too many login attempts, try again later",
|
||||
);
|
||||
}
|
||||
match state.auth.login(&req.email, &req.password, ip).await {
|
||||
Ok(pair) => token_response(&state, pair).await,
|
||||
Err(e) => ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/auth/refresh", tag = "auth",
|
||||
responses((status = 200, body = TokenResponse), (status = 401)))]
|
||||
async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let Some(token) = cookie(&headers, REFRESH_COOKIE) else {
|
||||
return simple(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid_token",
|
||||
"missing refresh cookie",
|
||||
);
|
||||
};
|
||||
match state.auth.refresh(&token, ip).await {
|
||||
Ok(pair) => token_response(&state, pair).await,
|
||||
Err(e) => ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/auth/logout", tag = "auth", responses((status = 204)))]
|
||||
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, ApiError> {
|
||||
if let Some(token) = cookie(&headers, REFRESH_COOKIE) {
|
||||
state.auth.logout(&token).await?;
|
||||
}
|
||||
Ok((
|
||||
[(header::SET_COOKIE, clear_cookie(state.cfg.cookie_secure))],
|
||||
StatusCode::NO_CONTENT,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/auth/me", tag = "auth", security(("bearer" = [])),
|
||||
responses((status = 200, body = UserDto), (status = 401)))]
|
||||
async fn me(AuthUser(user): AuthUser) -> Json<UserDto> {
|
||||
Json(user.into())
|
||||
}
|
||||
|
||||
async fn token_response(state: &AppState, pair: domain::auth::TokenPair) -> Response {
|
||||
let claims = state.auth.authenticate(&pair.access_token).await;
|
||||
match claims {
|
||||
Ok(user) => (
|
||||
[(
|
||||
header::SET_COOKIE,
|
||||
set_cookie(&pair.refresh_token, state.cfg.cookie_secure),
|
||||
)],
|
||||
Json(TokenResponse {
|
||||
access_token: pair.access_token,
|
||||
user: user.into(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|kv| kv.trim().split_once('='))
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
fn cookie_attrs(secure: bool) -> String {
|
||||
format!(
|
||||
"Path=/api/auth; HttpOnly; SameSite=Strict{}",
|
||||
if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
fn set_cookie(token: &str, secure: bool) -> HeaderValue {
|
||||
let max_age = application::auth_service::REFRESH_TOKEN_TTL_DAYS * 24 * 3600;
|
||||
HeaderValue::from_str(&format!(
|
||||
"{REFRESH_COOKIE}={token}; Max-Age={max_age}; {}",
|
||||
cookie_attrs(secure)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn clear_cookie(secure: bool) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!(
|
||||
"{REFRESH_COOKIE}=; Max-Age=0; {}",
|
||||
cookie_attrs(secure)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
51
backend/crates/api/src/error.rs
Normal file
51
backend/crates/api/src/error.rs
Normal file
@ -0,0 +1,51 @@
|
||||
//! Maps domain errors to HTTP responses of the shape `{"error": code, "message": text}`.
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use domain::DomainError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError(pub DomainError);
|
||||
|
||||
impl From<DomainError> for ApiError {
|
||||
fn from(e: DomainError) -> Self {
|
||||
ApiError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
use DomainError::*;
|
||||
let (status, code) = match &self.0 {
|
||||
NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
EmailTaken => (StatusCode::CONFLICT, "email_taken"),
|
||||
LastAdmin => (StatusCode::CONFLICT, "last_admin"),
|
||||
InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
|
||||
InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"),
|
||||
InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"),
|
||||
Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
|
||||
Storage(msg) => {
|
||||
tracing::error!("storage error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
}
|
||||
};
|
||||
let message = if code == "internal" {
|
||||
"internal error".to_string()
|
||||
} else {
|
||||
self.0.to_string()
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": code, "message": message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simple(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": code, "message": message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
97
backend/crates/api/src/extract.rs
Normal file
97
backend/crates/api/src/extract.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! Request extractors: authenticated user, admin user, client IP.
|
||||
use axum::extract::{ConnectInfo, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
use domain::user::User;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::error::simple;
|
||||
use crate::AppState;
|
||||
|
||||
pub struct AuthUser(pub User);
|
||||
pub struct AdminUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let token = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
simple(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
"missing bearer token",
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.auth
|
||||
.authenticate(token)
|
||||
.await
|
||||
.map(AuthUser)
|
||||
.map_err(|e| crate::error::ApiError(e).into_response_401())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AdminUser {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
|
||||
if !user.is_admin() {
|
||||
return Err(simple(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden",
|
||||
"admin role required",
|
||||
));
|
||||
}
|
||||
Ok(AdminUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::error::ApiError {
|
||||
/// Auth failures on protected routes are always reported as 401 (inactive users included).
|
||||
fn into_response_401(self) -> Response {
|
||||
simple(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
&self.0.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort client IP: `X-Forwarded-For` first hop, else the socket address.
|
||||
pub fn client_ip(parts: &Parts) -> Option<String> {
|
||||
parts
|
||||
.headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.or_else(|| {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ClientIp(pub Option<String>);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(ClientIp(client_ip(parts)))
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,20 @@
|
||||
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod openapi;
|
||||
pub mod rate_limit;
|
||||
pub mod test_support;
|
||||
pub mod users;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{AuthService, UserService};
|
||||
use axum::{routing::get, Json, Router};
|
||||
use infrastructure::{
|
||||
Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
@ -11,6 +23,42 @@ pub use config::Config;
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub cfg: Config,
|
||||
pub auth: Arc<AuthService>,
|
||||
pub users: Arc<UserService>,
|
||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Wire the services on top of a connected database.
|
||||
pub fn new(cfg: Config, pool: DbPool) -> Self {
|
||||
let users = Arc::new(SqliteUsers(pool.clone()));
|
||||
let hasher = Arc::new(Argon2Hasher);
|
||||
let auth = AuthService::new(
|
||||
users.clone(),
|
||||
Arc::new(SqliteRefreshTokens(pool.clone())),
|
||||
Arc::new(SqliteAuditLog(pool)),
|
||||
hasher.clone(),
|
||||
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
|
||||
);
|
||||
Self {
|
||||
cfg,
|
||||
auth: Arc::new(auth),
|
||||
users: Arc::new(UserService::new(users, hasher)),
|
||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||
10,
|
||||
std::time::Duration::from_secs(60),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bootstrap(&self) -> anyhow::Result<()> {
|
||||
if let Some((email, password)) = &self.cfg.bootstrap_admin {
|
||||
if self.users.bootstrap_admin(email, password).await? {
|
||||
tracing::info!("created bootstrap admin {email}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
@ -18,6 +66,9 @@ pub fn build_app(state: AppState) -> Router {
|
||||
let spa = ServeDir::new(&state.cfg.frontend_dir).not_found_service(ServeFile::new(index));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/api/openapi.json", get(openapi::spec))
|
||||
.nest("/api/auth", auth::router())
|
||||
.nest("/api/users", users::router())
|
||||
.fallback_service(spa)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
|
||||
@ -8,8 +8,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
let cfg = Config::from_env()?;
|
||||
if let Some(dir) = cfg
|
||||
.database_url
|
||||
.strip_prefix("sqlite://")
|
||||
.and_then(|p| p.split('?').next())
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
{
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let pool = infrastructure::connect(&cfg.database_url).await?;
|
||||
let state = AppState::new(cfg.clone(), pool);
|
||||
state.bootstrap().await?;
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
|
||||
tracing::info!("listening on http://{}", cfg.bind);
|
||||
axum::serve(listener, build_app(AppState { cfg })).await?;
|
||||
let app = build_app(state).into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
34
backend/crates/api/src/openapi.rs
Normal file
34
backend/crates/api/src/openapi.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use axum::Json;
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
use utoipa::{Modify, OpenApi};
|
||||
|
||||
struct BearerAuth;
|
||||
impl Modify for BearerAuth {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer",
|
||||
SecurityScheme::Http(
|
||||
HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(title = "SoftVisor Monitoring API", version = "0.1.0"),
|
||||
paths(
|
||||
crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me,
|
||||
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
|
||||
),
|
||||
modifiers(&BearerAuth)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
pub async fn spec() -> Json<utoipa::openapi::OpenApi> {
|
||||
Json(ApiDoc::openapi())
|
||||
}
|
||||
43
backend/crates/api/src/rate_limit.rs
Normal file
43
backend/crates/api/src/rate_limit.rs
Normal file
@ -0,0 +1,43 @@
|
||||
//! Minimal fixed-window rate limiter keyed by client identifier (IP).
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct RateLimiter {
|
||||
max: u32,
|
||||
window: Duration,
|
||||
hits: Mutex<HashMap<String, (Instant, u32)>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max: u32, window: Duration) -> Self {
|
||||
Self {
|
||||
max,
|
||||
window,
|
||||
hits: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the request is allowed.
|
||||
pub fn check(&self, key: &str) -> bool {
|
||||
let mut hits = self.hits.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
hits.retain(|_, (start, _)| now.duration_since(*start) < self.window);
|
||||
let entry = hits.entry(key.to_string()).or_insert((now, 0));
|
||||
entry.1 += 1;
|
||||
entry.1 <= self.max
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_after_max_hits_per_key() {
|
||||
let l = RateLimiter::new(3, Duration::from_secs(60));
|
||||
assert!(l.check("a") && l.check("a") && l.check("a"));
|
||||
assert!(!l.check("a"));
|
||||
assert!(l.check("b"));
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
114
backend/crates/api/src/users.rs
Normal file
114
backend/crates/api/src/users.rs
Normal file
@ -0,0 +1,114 @@
|
||||
//! /api/users: admin-only user management.
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::user::{NewUser, Role, UserUpdate};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::UserDto;
|
||||
use crate::error::ApiError;
|
||||
use crate::extract::AdminUser;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list).post(create))
|
||||
.route("/{id}", get(get_one).patch(update))
|
||||
.route("/{id}/password", post(reset_password))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateUserRequest {
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub password: String,
|
||||
#[schema(value_type = String, example = "admin")]
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub display_name: Option<String>,
|
||||
#[schema(value_type = String, example = "admin")]
|
||||
pub role: Option<Role>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PasswordRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/users", tag = "users", security(("bearer" = [])),
|
||||
responses((status = 200, body = Vec<UserDto>), (status = 401), (status = 403)))]
|
||||
async fn list(State(state): State<AppState>, _: AdminUser) -> Result<Json<Vec<UserDto>>, ApiError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.users
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/users", tag = "users", security(("bearer" = [])), request_body = CreateUserRequest,
|
||||
responses((status = 201, body = UserDto), (status = 409), (status = 422)))]
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Json(req): Json<CreateUserRequest>,
|
||||
) -> Result<(StatusCode, Json<UserDto>), ApiError> {
|
||||
let user = state
|
||||
.users
|
||||
.create(NewUser {
|
||||
email: req.email,
|
||||
display_name: req.display_name,
|
||||
password: req.password,
|
||||
role: req.role,
|
||||
})
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(user.into())))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/users/{id}", tag = "users", security(("bearer" = [])),
|
||||
responses((status = 200, body = UserDto), (status = 404)))]
|
||||
async fn get_one(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<UserDto>, ApiError> {
|
||||
Ok(Json(state.users.get(id).await?.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/users/{id}", tag = "users", security(("bearer" = [])), request_body = UpdateUserRequest,
|
||||
responses((status = 200, body = UserDto), (status = 404), (status = 409)))]
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateUserRequest>,
|
||||
) -> Result<Json<UserDto>, ApiError> {
|
||||
let update = UserUpdate {
|
||||
display_name: req.display_name,
|
||||
role: req.role,
|
||||
is_active: req.is_active,
|
||||
};
|
||||
Ok(Json(state.users.update(id, update).await?.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/users/{id}/password", tag = "users", security(("bearer" = [])), request_body = PasswordRequest,
|
||||
responses((status = 204), (status = 404), (status = 422)))]
|
||||
async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<PasswordRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state.users.reset_password(id, &req.password).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@ -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<dyn UserRepository>,
|
||||
pub(crate) refresh: Arc<dyn RefreshTokenRepository>,
|
||||
pub(crate) audit: Arc<dyn AuditLog>,
|
||||
pub(crate) hasher: Arc<dyn PasswordHasher>,
|
||||
pub(crate) tokens: Arc<dyn AccessTokenIssuer>,
|
||||
pub(crate) refresh_ttl: Duration,
|
||||
users: Arc<dyn UserRepository>,
|
||||
refresh: Arc<dyn RefreshTokenRepository>,
|
||||
audit: Arc<dyn AuditLog>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
tokens: Arc<dyn AccessTokenIssuer>,
|
||||
refresh_ttl: Duration,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
@ -39,27 +44,143 @@ impl AuthService {
|
||||
|
||||
pub async fn login(
|
||||
&self,
|
||||
_email: &str,
|
||||
_password: &str,
|
||||
_ip: Option<String>,
|
||||
email: &str,
|
||||
password: &str,
|
||||
ip: Option<String>,
|
||||
) -> Result<TokenPair, DomainError> {
|
||||
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<String>,
|
||||
refresh_token: &str,
|
||||
ip: Option<String>,
|
||||
) -> Result<TokenPair, DomainError> {
|
||||
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<User, DomainError> {
|
||||
todo!()
|
||||
pub async fn authenticate(&self, access_token: &str) -> Result<User, DomainError> {
|
||||
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<TokenPair, DomainError> {
|
||||
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<Uuid>,
|
||||
email: &str,
|
||||
kind: AuthEventKind,
|
||||
ip: Option<String>,
|
||||
) -> 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()))
|
||||
}
|
||||
|
||||
@ -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<dyn UserRepository>,
|
||||
pub(crate) hasher: Arc<dyn PasswordHasher>,
|
||||
users: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
@ -16,31 +17,67 @@ impl UserService {
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<User>, DomainError> {
|
||||
todo!()
|
||||
self.users.list().await
|
||||
}
|
||||
|
||||
pub async fn get(&self, _id: Uuid) -> Result<User, DomainError> {
|
||||
todo!()
|
||||
pub async fn get(&self, id: Uuid) -> Result<User, DomainError> {
|
||||
self.users
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn create(&self, _new: NewUser) -> Result<User, DomainError> {
|
||||
todo!()
|
||||
pub async fn create(&self, new: NewUser) -> Result<User, DomainError> {
|
||||
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<User, DomainError> {
|
||||
todo!()
|
||||
pub async fn update(&self, id: Uuid, update: UserUpdate) -> Result<User, DomainError> {
|
||||
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<bool, DomainError> {
|
||||
todo!()
|
||||
pub async fn bootstrap_admin(&self, email: &str, password: &str) -> Result<bool, DomainError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
17
backend/crates/infrastructure/src/db.rs
Normal file
17
backend/crates/infrastructure/src/db.rs
Normal file
@ -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<DbPool> {
|
||||
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)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
42
backend/crates/infrastructure/src/password.rs
Normal file
42
backend/crates/infrastructure/src/password.rs
Normal file
@ -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<String, DomainError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
282
backend/crates/infrastructure/src/sqlite.rs
Normal file
282
backend/crates/infrastructure/src/sqlite.rs
Normal file
@ -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<Utc> {
|
||||
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::<Uuid, _>("id"),
|
||||
email: r.get("email"),
|
||||
display_name: r.get("display_name"),
|
||||
password_hash: r.get("password_hash"),
|
||||
role: Role::parse(r.get::<String, _>("role").as_str()).unwrap_or(Role::User),
|
||||
is_active: r.get::<bool, _>("is_active"),
|
||||
created_at: parse_ts(r.get::<String, _>("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<Option<User>, 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<Option<User>, 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<Vec<User>, 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<u64, DomainError> {
|
||||
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<u64, DomainError> {
|
||||
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<User, DomainError> {
|
||||
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<Option<RefreshToken>, 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::<String, _>("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());
|
||||
}
|
||||
}
|
||||
89
backend/crates/infrastructure/src/token.rs
Normal file
89
backend/crates/infrastructure/src/token.rs
Normal file
@ -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<String, DomainError> {
|
||||
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<AccessClaims, DomainError> {
|
||||
jsonwebtoken::decode::<AccessClaims>(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);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import Toast from './components/Toast.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
62
frontend/src/api/client.ts
Normal file
62
frontend/src/api/client.ts
Normal file
@ -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<unknown> {
|
||||
if (res.status === 204) return undefined
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : undefined
|
||||
}
|
||||
|
||||
async function toError(res: Response): Promise<ApiError> {
|
||||
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<boolean> {
|
||||
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<T>(method: string, path: string, body?: unknown, retry = true): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = {}
|
||||
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<T>(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: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
}
|
||||
28
frontend/src/api/types.ts
Normal file
28
frontend/src/api/types.ts
Normal file
@ -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
|
||||
}
|
||||
49
frontend/src/components/AppShell.vue
Normal file
49
frontend/src/components/AppShell.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/updates', label: 'Updates' },
|
||||
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
|
||||
{ to: '/backups', label: 'Backups' },
|
||||
{ to: '/users', label: 'Users', admin: true },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
async function signOut() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen bg-gray-50 text-gray-900">
|
||||
<aside class="flex w-56 flex-col border-r border-gray-200 bg-white">
|
||||
<div class="px-5 py-4 text-lg font-semibold">SoftVisor Monitoring</div>
|
||||
<nav class="flex-1 space-y-1 px-3">
|
||||
<template v-for="item in nav" :key="item.to">
|
||||
<RouterLink
|
||||
v-if="!item.admin || auth.isAdmin"
|
||||
:to="item.to"
|
||||
class="block rounded-md px-3 py-2 text-sm hover:bg-gray-100"
|
||||
active-class="bg-gray-100 font-medium"
|
||||
:exact-active-class="item.to === '/' ? 'bg-gray-100 font-medium' : undefined"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="border-t border-gray-200 px-5 py-4 text-sm">
|
||||
<div class="truncate font-medium">{{ auth.user?.display_name }}</div>
|
||||
<div class="truncate text-gray-500">{{ auth.user?.email }}</div>
|
||||
<button class="mt-2 text-blue-600 hover:underline" @click="signOut">Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="flex-1 p-8">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
18
frontend/src/components/Toast.vue
Normal file
18
frontend/src/components/Toast.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '../stores/toast'
|
||||
const toasts = useToastStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed right-4 top-4 z-50 space-y-2">
|
||||
<div
|
||||
v-for="t in toasts.items"
|
||||
:key="t.id"
|
||||
role="status"
|
||||
class="rounded-md px-4 py-2 text-sm text-white shadow"
|
||||
:class="t.kind === 'error' ? 'bg-red-600' : 'bg-green-600'"
|
||||
>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
104
frontend/src/components/UserForm.vue
Normal file
104
frontend/src/components/UserForm.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { CreateUserPayload, Role, UpdateUserPayload, User } from '../api/types'
|
||||
|
||||
const props = defineProps<{ mode: 'create' | 'edit'; user?: User; busy?: boolean }>()
|
||||
const emit = defineEmits<{ submit: [payload: CreateUserPayload | UpdateUserPayload]; cancel: [] }>()
|
||||
|
||||
const email = ref(props.user?.email ?? '')
|
||||
const displayName = ref(props.user?.display_name ?? '')
|
||||
const password = ref('')
|
||||
const role = ref<Role>(props.user?.role ?? 'user')
|
||||
const isActive = ref(props.user?.is_active ?? true)
|
||||
const error = ref('')
|
||||
|
||||
function submit() {
|
||||
error.value = ''
|
||||
if (props.mode === 'create') {
|
||||
if (password.value.length < 12) {
|
||||
error.value = 'Password must have at least 12 characters'
|
||||
return
|
||||
}
|
||||
emit('submit', {
|
||||
email: email.value,
|
||||
display_name: displayName.value,
|
||||
password: password.value,
|
||||
role: role.value,
|
||||
})
|
||||
} else {
|
||||
emit('submit', { display_name: displayName.value, role: role.value, is_active: isActive.value })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<div v-if="mode === 'create'">
|
||||
<label for="uf-email" class="block text-sm font-medium">Email</label>
|
||||
<input
|
||||
id="uf-email"
|
||||
v-model="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="uf-name" class="block text-sm font-medium">Display name</label>
|
||||
<input
|
||||
id="uf-name"
|
||||
v-model="displayName"
|
||||
name="display_name"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mode === 'create'">
|
||||
<label for="uf-password" class="block text-sm font-medium">Password</label>
|
||||
<input
|
||||
id="uf-password"
|
||||
v-model="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="uf-role" class="block text-sm font-medium">Role</label>
|
||||
<select
|
||||
id="uf-role"
|
||||
v-model="role"
|
||||
name="role"
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<label v-if="mode === 'edit'" class="flex items-center gap-2 text-sm">
|
||||
<input v-model="isActive" name="is_active" type="checkbox" />
|
||||
Active
|
||||
</label>
|
||||
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{{ mode === 'create' ? 'Create' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
6
frontend/src/pages/DashboardPage.vue
Normal file
6
frontend/src/pages/DashboardPage.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Server overview, update status, vulnerabilities and backups will appear here.
|
||||
</p>
|
||||
</template>
|
||||
73
frontend/src/pages/LoginPage.vue
Normal file
73
frontend/src/pages/LoginPage.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
await auth.login(email.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code
|
||||
error.value =
|
||||
code === 'invalid_credentials'
|
||||
? 'Invalid email or password'
|
||||
: code === 'inactive_user'
|
||||
? 'This account is deactivated'
|
||||
: code === 'rate_limited'
|
||||
? 'Too many attempts, please wait a minute'
|
||||
: 'Login failed, please try again'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<form class="w-full max-w-sm space-y-4 rounded-lg bg-white p-8 shadow" @submit.prevent="submit">
|
||||
<h1 class="text-xl font-semibold">SoftVisor Monitoring</h1>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
8
frontend/src/pages/PlaceholderPage.vue
Normal file
8
frontend/src/pages/PlaceholderPage.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="text-2xl font-semibold">{{ title }}</h1>
|
||||
<p class="mt-2 text-gray-600">Not implemented yet.</p>
|
||||
</template>
|
||||
178
frontend/src/pages/UsersPage.vue
Normal file
178
frontend/src/pages/UsersPage.vue
Normal file
@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { CreateUserPayload, UpdateUserPayload, User } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import UserForm from '../components/UserForm.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(true)
|
||||
const busy = ref(false)
|
||||
const dialog = ref<
|
||||
{ mode: 'create' } | { mode: 'edit'; user: User } | { mode: 'password'; user: User } | null
|
||||
>(null)
|
||||
const newPassword = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await api.get<User[]>('/api/users')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) load()
|
||||
})
|
||||
|
||||
function fail(e: unknown) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||
}
|
||||
|
||||
async function submit(payload: CreateUserPayload | UpdateUserPayload) {
|
||||
if (!dialog.value || dialog.value.mode === 'password') return
|
||||
busy.value = true
|
||||
try {
|
||||
if (dialog.value.mode === 'create') {
|
||||
await api.post('/api/users', payload)
|
||||
toast.success('User created')
|
||||
} else {
|
||||
await api.patch(`/api/users/${dialog.value.user.id}`, payload)
|
||||
toast.success('User updated')
|
||||
}
|
||||
dialog.value = null
|
||||
await load()
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
if (!dialog.value || dialog.value.mode !== 'password') return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post(`/api/users/${dialog.value.user.id}/password`, { password: newPassword.value })
|
||||
toast.success('Password reset')
|
||||
dialog.value = null
|
||||
newPassword.value = ''
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!auth.isAdmin">
|
||||
<h1 class="text-2xl font-semibold">Users</h1>
|
||||
<p class="mt-2 text-gray-600">You do not have permission to manage users.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Users</h1>
|
||||
<button
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||
@click="dialog = { mode: 'create' }"
|
||||
>
|
||||
New user
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="mt-6 text-gray-500">Loading…</p>
|
||||
<table v-else class="mt-6 w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="border-b border-gray-100">
|
||||
<td class="py-2">{{ u.email }}</td>
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td class="capitalize">{{ u.role }}</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
:status="u.is_active ? 'ok' : 'error'"
|
||||
:label="u.is_active ? 'active' : 'inactive'"
|
||||
/>
|
||||
</td>
|
||||
<td class="space-x-3 text-right">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { mode: 'edit', user: u }"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { mode: 'password', user: u }"
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="dialog" class="fixed inset-0 flex items-center justify-center bg-black/30">
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-lg" role="dialog">
|
||||
<template v-if="dialog.mode === 'password'">
|
||||
<h2 class="mb-4 text-lg font-semibold">Reset password for {{ dialog.user.email }}</h2>
|
||||
<form class="space-y-4" @submit.prevent="resetPassword">
|
||||
<div>
|
||||
<label for="np" class="block text-sm font-medium">New password</label>
|
||||
<input
|
||||
id="np"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
required
|
||||
minlength="12"
|
||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||||
@click="dialog = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="mb-4 text-lg font-semibold">
|
||||
{{ dialog.mode === 'create' ? 'New user' : 'Edit user' }}
|
||||
</h2>
|
||||
<UserForm
|
||||
:key="dialog.mode === 'edit' ? dialog.user.id : 'new'"
|
||||
:mode="dialog.mode"
|
||||
:user="dialog.mode === 'edit' ? dialog.user : undefined"
|
||||
:busy="busy"
|
||||
@submit="submit"
|
||||
@cancel="dialog = null"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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 }> = []
|
||||
|
||||
43
frontend/src/stores/auth.ts
Normal file
43
frontend/src/stores/auth.ts
Normal file
@ -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<string | null>(null)
|
||||
const user = ref<User | null>(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 }
|
||||
})
|
||||
23
frontend/src/stores/toast.ts
Normal file
23
frontend/src/stores/toast.ts
Normal file
@ -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<Toast[]>([])
|
||||
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),
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user