feat: implement authentication module with JWT support and user management

This commit is contained in:
GW_MC
2025-12-15 15:51:45 +08:00
parent 1233f3b736
commit 3354154b87
13 changed files with 1232 additions and 5 deletions

View File

@@ -0,0 +1,269 @@
pub mod strategies;
use std::{collections::HashSet, sync::Arc};
use argon2::password_hash::{SaltString, rand_core::OsRng};
use jsonwebtoken::{
DecodingKey, EncodingKey, Header, Validation, decode, encode,
errors::ErrorKind::{ExpiredSignature, InvalidSubject, InvalidToken},
};
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::errors::service_error::ServiceError;
// Number of requests between invalidation cache cleanups
const INVALIDATE_CACHE_CLEANUP_INTERVAL_REQUESTS: usize = 100; // Cleanup every 100 for invalidation checks
#[derive(Serialize, Deserialize, Clone)]
pub struct Claims {
// Subject - user ID
pub sub: String,
// Issued at as UNIX timestamp
pub iat: u64,
// Expiration time as UNIX timestamp
pub exp: u64,
}
#[async_trait::async_trait]
pub trait AuthenticationService: Send + Sync {
async fn generate_jwt(&self, user_id: Uuid, duration_secs: u64)
-> Result<String, ServiceError>;
async fn is_valid_jwt(
&self,
token: &str,
target_sub: Option<String>,
) -> Result<bool, ServiceError>;
async fn parse_jwt(&self, token: &str) -> Result<Claims, ServiceError>;
async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError>;
async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result<String, ServiceError>;
async fn logout(&self, token: &str) -> Result<(), ServiceError>;
async fn cleanup_invalidation_cache(&self);
}
#[derive(Eq, Hash, PartialEq)]
struct InvalidationEntry {
token: String,
invalidated_at: u64,
valid_until: u64,
}
pub struct AuthenticationServiceImpl {
secret: String,
invalidation_cache: Arc<RwLock<HashSet<InvalidationEntry>>>,
cache_cleanup_counter: Arc<RwLock<usize>>,
}
impl AuthenticationServiceImpl {
pub fn new(secret: Option<String>) -> Self {
let secret = secret.unwrap_or_else(|| {
// generate a random secret if none is provided
SaltString::generate(&mut OsRng).as_str().to_owned()
});
Self {
secret,
invalidation_cache: Arc::new(RwLock::new(HashSet::new())),
cache_cleanup_counter: Arc::new(RwLock::new(0)),
}
}
}
#[async_trait::async_trait]
impl AuthenticationService for AuthenticationServiceImpl {
async fn generate_jwt(
&self,
user_id: Uuid,
duration_secs: u64,
) -> Result<String, ServiceError> {
let header = Header::default();
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::seconds(duration_secs as i64))
.ok_or(ServiceError::InternalError(
"Invalid expiration time".into(),
))?
.timestamp() as u64;
let claims = Claims {
sub: user_id.to_string(),
iat: chrono::Utc::now().timestamp() as u64,
exp: expiration,
};
let token = encode(
&header,
&claims,
&EncodingKey::from_secret(self.secret.as_ref()),
)
.map_err(|e| ServiceError::InternalError(format!("JWT generation error: {}", e)))?;
Ok(token)
}
async fn is_valid_jwt(
&self,
token: &str,
target_sub: Option<String>,
) -> Result<bool, ServiceError> {
let mut validation = Validation::default();
if let Some(expected_sub) = target_sub {
validation.sub = Some(expected_sub);
}
let decoding_key = DecodingKey::from_secret(self.secret.as_ref());
match decode::<Claims>(token, &decoding_key, &validation) {
Ok(_) => Ok(true),
Err(err) => match *err.kind() {
InvalidToken | InvalidSubject | ExpiredSignature => Ok(false),
_ => Err(ServiceError::InternalError(format!(
"JWT validation error: {}",
err
))),
},
}
}
async fn parse_jwt(&self, token: &str) -> Result<Claims, ServiceError> {
let decoding_key = DecodingKey::from_secret(self.secret.as_ref());
let token_data = decode::<Claims>(token, &decoding_key, &Validation::default())
.map_err(|e| ServiceError::InternalError(format!("JWT parsing error: {}", e)))?;
Ok(token_data.claims)
}
async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError> {
let claims = self.parse_jwt(token).await?;
let valid_until = claims.exp;
let invalidated_at = chrono::Utc::now().timestamp() as u64;
let entry = InvalidationEntry {
token: token.to_string(),
invalidated_at,
valid_until,
};
{
self.invalidation_cache.write().await.insert(entry);
}
//
if self.cache_cleanup_counter.read().await.wrapping_add(1)
% INVALIDATE_CACHE_CLEANUP_INTERVAL_REQUESTS
== 0
{
self.cleanup_invalidation_cache().await;
}
//
Ok(())
}
async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result<String, ServiceError> {
let claims = self.parse_jwt(token).await?;
let user_id = Uuid::parse_str(&claims.sub).map_err(|e| {
ServiceError::InternalError(format!("Invalid user ID in JWT claims: {}", e))
})?;
let new_token = self.generate_jwt(user_id, duration_secs).await?;
Ok(new_token)
}
async fn logout(&self, token: &str) -> Result<(), ServiceError> {
self.invalidate_jwt(token).await
}
async fn cleanup_invalidation_cache(&self) {
let now = chrono::Utc::now().timestamp() as u64;
let mut cache = self.invalidation_cache.write().await;
cache.retain(|entry| entry.valid_until > now);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::{Duration, sleep};
#[tokio::test]
async fn test_jwt_generation_and_validation() {
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4();
let token = service
.generate_jwt(user_id, 60)
.await
.expect("generate jwt");
let valid = service
.is_valid_jwt(&token, None)
.await
.expect("validate jwt");
assert!(valid, "Generated token should be valid");
let claims = service.parse_jwt(&token).await.expect("parse jwt");
assert_eq!(claims.sub, user_id.to_string());
}
#[tokio::test]
async fn test_jwt_validation_with_wrong_subject() {
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4();
let token = service.generate_jwt(user_id, 60).await.unwrap();
let other_sub = Uuid::new_v4().to_string();
let valid = service.is_valid_jwt(&token, Some(other_sub)).await.unwrap();
assert!(!valid, "Token should be invalid for a different subject");
}
#[tokio::test]
async fn test_parse_jwt_invalid_token() {
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let res = service.parse_jwt("not_a_token").await;
assert!(matches!(res, Err(ServiceError::InternalError(_))));
}
#[tokio::test]
async fn test_refresh_jwt() {
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4();
let token = service.generate_jwt(user_id, 60).await.unwrap();
let new_token = service.refresh_jwt(&token, 120).await.unwrap();
let claims = service.parse_jwt(&new_token).await.unwrap();
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.exp - claims.iat, 120);
}
#[tokio::test]
async fn test_is_valid_jwt_expired() {
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4();
let token = service.generate_jwt(user_id, 1).await.unwrap();
sleep(Duration::from_secs(2)).await;
let valid = service.is_valid_jwt(&token, None).await.unwrap();
assert!(!valid, "Token should be expired and thus invalid");
}
#[tokio::test]
async fn test_invalidate_and_cleanup() {
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4();
let token = service.generate_jwt(user_id, 1).await.unwrap();
service.invalidate_jwt(&token).await.unwrap();
// ensure entry is present
{
let cache = service.invalidation_cache.read().await;
assert!(cache.iter().any(|e| e.token == token));
}
// wait until token validity ends and cleanup
sleep(Duration::from_secs(2)).await;
service.cleanup_invalidation_cache().await;
let cache = service.invalidation_cache.read().await;
assert!(
cache.is_empty(),
"Cleanup should remove expired invalidation entries"
);
}
}