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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user