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