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>
90 lines
2.7 KiB
Rust
90 lines
2.7 KiB
Rust
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);
|
|
}
|
|
}
|