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:
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