- Web: Setup Storybook, added addons, configured Tailwind, added stories for UI components. - Backend: Updated API router, database, workers, and auth in common. - Stream Server: Removed SQLx queries and updated auth. - Docs & Scripts: Updated documentation and recovery scripts.
371 lines
9.8 KiB
Rust
371 lines
9.8 KiB
Rust
//! Authentication utilities for Veza Rust services
|
|
//!
|
|
//! This module provides authentication-related utilities and helpers.
|
|
|
|
use uuid::Uuid;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{VezaError, VezaResult};
|
|
|
|
/// JWT claims structure
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JwtClaims {
|
|
pub sub: Uuid, // Subject (user ID)
|
|
pub iss: String, // Issuer
|
|
pub aud: String, // Audience
|
|
pub exp: u64, // Expiration time
|
|
pub iat: u64, // Issued at
|
|
pub nbf: Option<u64>, // Not before
|
|
pub jti: Option<String>, // JWT ID
|
|
pub username: String,
|
|
pub email: String,
|
|
pub roles: Vec<String>,
|
|
}
|
|
|
|
impl JwtClaims {
|
|
/// Create new JWT claims
|
|
pub fn new(
|
|
user_id: Uuid,
|
|
username: String,
|
|
email: String,
|
|
roles: Vec<String>,
|
|
issuer: String,
|
|
audience: String,
|
|
ttl_seconds: u64,
|
|
) -> Self {
|
|
let now = Utc::now().timestamp() as u64;
|
|
|
|
Self {
|
|
sub: user_id,
|
|
iss: issuer,
|
|
aud: audience,
|
|
exp: now + ttl_seconds,
|
|
iat: now,
|
|
nbf: Some(now),
|
|
jti: Some(uuid::Uuid::new_v4().to_string()),
|
|
username,
|
|
email,
|
|
roles,
|
|
}
|
|
}
|
|
|
|
/// Check if claims are expired
|
|
pub fn is_expired(&self) -> bool {
|
|
let now = Utc::now().timestamp() as u64;
|
|
self.exp < now
|
|
}
|
|
|
|
/// Check if claims are valid (not before)
|
|
pub fn is_valid(&self) -> bool {
|
|
let now = Utc::now().timestamp() as u64;
|
|
if let Some(nbf) = self.nbf {
|
|
now >= nbf
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Check if user has role
|
|
pub fn has_role(&self, role: &str) -> bool {
|
|
self.roles.contains(&role.to_string())
|
|
}
|
|
|
|
/// Check if user has any of the roles
|
|
pub fn has_any_role(&self, roles: &[&str]) -> bool {
|
|
roles.iter().any(|role| self.has_role(role))
|
|
}
|
|
}
|
|
|
|
/// Authentication result
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AuthResult {
|
|
pub access_token: String,
|
|
pub refresh_token: String,
|
|
pub token_type: String,
|
|
pub expires_in: u64,
|
|
pub user_id: Uuid,
|
|
pub username: String,
|
|
pub email: String,
|
|
pub roles: Vec<String>,
|
|
}
|
|
|
|
/// Password validation result
|
|
#[derive(Debug, Clone)]
|
|
pub struct PasswordValidation {
|
|
pub is_valid: bool,
|
|
pub errors: Vec<String>,
|
|
pub strength_score: u8, // 0-100
|
|
}
|
|
|
|
impl PasswordValidation {
|
|
/// Create new password validation
|
|
pub fn new(password: &str) -> Self {
|
|
let mut errors = Vec::new();
|
|
let mut strength_score = 0u8;
|
|
|
|
// Length check
|
|
if password.len() >= 8 {
|
|
strength_score += 20;
|
|
} else {
|
|
errors.push("Password must be at least 8 characters long".to_string());
|
|
}
|
|
|
|
if password.len() >= 12 {
|
|
strength_score += 10;
|
|
}
|
|
|
|
// Character variety checks
|
|
if password.chars().any(|c| c.is_uppercase()) {
|
|
strength_score += 20;
|
|
} else {
|
|
errors.push("Password must contain at least one uppercase letter".to_string());
|
|
}
|
|
|
|
if password.chars().any(|c| c.is_lowercase()) {
|
|
strength_score += 20;
|
|
} else {
|
|
errors.push("Password must contain at least one lowercase letter".to_string());
|
|
}
|
|
|
|
if password.chars().any(|c| c.is_numeric()) {
|
|
strength_score += 20;
|
|
} else {
|
|
errors.push("Password must contain at least one digit".to_string());
|
|
}
|
|
|
|
if password.chars().any(|c| c.is_ascii_punctuation()) {
|
|
strength_score += 20;
|
|
} else {
|
|
errors.push("Password must contain at least one special character".to_string());
|
|
}
|
|
|
|
// Additional strength checks
|
|
if password.len() >= 16 {
|
|
strength_score += 10;
|
|
}
|
|
|
|
if password.chars().filter(|c| c.is_ascii_punctuation()).count() >= 2 {
|
|
strength_score += 10;
|
|
}
|
|
|
|
let is_valid = errors.is_empty();
|
|
|
|
Self {
|
|
is_valid,
|
|
errors,
|
|
strength_score,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session information
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SessionInfo {
|
|
pub session_id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub ip_address: Option<String>,
|
|
pub user_agent: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub expires_at: DateTime<Utc>,
|
|
pub last_activity: DateTime<Utc>,
|
|
pub is_active: bool,
|
|
}
|
|
|
|
impl SessionInfo {
|
|
/// Check if session is expired
|
|
pub fn is_expired(&self) -> bool {
|
|
Utc::now() > self.expires_at
|
|
}
|
|
|
|
/// Check if session is valid
|
|
pub fn is_valid(&self) -> bool {
|
|
self.is_active && !self.is_expired()
|
|
}
|
|
|
|
/// Update last activity
|
|
pub fn update_activity(&mut self) {
|
|
self.last_activity = Utc::now();
|
|
}
|
|
}
|
|
|
|
/// Permission check result
|
|
#[derive(Debug, Clone)]
|
|
pub struct PermissionCheck {
|
|
pub allowed: bool,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
impl PermissionCheck {
|
|
/// Create allowed permission check
|
|
pub fn allowed() -> Self {
|
|
Self {
|
|
allowed: true,
|
|
reason: None,
|
|
}
|
|
}
|
|
|
|
/// Create denied permission check
|
|
pub fn denied(reason: String) -> Self {
|
|
Self {
|
|
allowed: false,
|
|
reason: Some(reason),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Role-based access control
|
|
pub struct RBAC {
|
|
roles: std::collections::HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
impl RBAC {
|
|
/// Create new RBAC instance
|
|
pub fn new() -> Self {
|
|
Self {
|
|
roles: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Add role with permissions
|
|
pub fn add_role(&mut self, role: &str, permissions: Vec<String>) {
|
|
self.roles.insert(role.to_string(), permissions);
|
|
}
|
|
|
|
/// Check if role has permission
|
|
pub fn has_permission(&self, role: &str, permission: &str) -> bool {
|
|
self.roles
|
|
.get(role)
|
|
.map(|permissions| permissions.contains(&permission.to_string()))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Check if user has permission (check all roles)
|
|
pub fn user_has_permission(&self, user_roles: &[String], permission: &str) -> bool {
|
|
user_roles.iter().any(|role| self.has_permission(role, permission))
|
|
}
|
|
|
|
/// Get all permissions for a role
|
|
pub fn get_role_permissions(&self, role: &str) -> Vec<String> {
|
|
self.roles
|
|
.get(role)
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl Default for RBAC {
|
|
fn default() -> Self {
|
|
let mut rbac = Self::new();
|
|
|
|
// Add default roles
|
|
rbac.add_role("admin", vec![
|
|
"user:read".to_string(),
|
|
"user:write".to_string(),
|
|
"user:delete".to_string(),
|
|
"conversation:read".to_string(),
|
|
"conversation:write".to_string(),
|
|
"conversation:delete".to_string(),
|
|
"track:read".to_string(),
|
|
"track:write".to_string(),
|
|
"track:delete".to_string(),
|
|
"system:admin".to_string(),
|
|
]);
|
|
|
|
rbac.add_role("user", vec![
|
|
"user:read".to_string(),
|
|
"conversation:read".to_string(),
|
|
"conversation:write".to_string(),
|
|
"track:read".to_string(),
|
|
"track:write".to_string(),
|
|
]);
|
|
|
|
rbac.add_role("guest", vec![
|
|
"track:read".to_string(),
|
|
]);
|
|
|
|
rbac
|
|
}
|
|
}
|
|
|
|
/// Generate secure random password
|
|
pub fn generate_secure_password(length: usize) -> VezaResult<String> {
|
|
use rand::Rng;
|
|
|
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
|
let mut rng = rand::thread_rng();
|
|
|
|
let password: String = (0..length)
|
|
.map(|_| {
|
|
let idx = rng.gen_range(0..CHARSET.len());
|
|
CHARSET[idx] as char
|
|
})
|
|
.collect();
|
|
|
|
Ok(password)
|
|
}
|
|
|
|
/// Generate recovery code
|
|
pub fn generate_recovery_code() -> VezaResult<String> {
|
|
use rand::Rng;
|
|
|
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
let mut rng = rand::thread_rng();
|
|
|
|
let code: String = (0..8)
|
|
.map(|_| {
|
|
let idx = rng.gen_range(0..CHARSET.len());
|
|
CHARSET[idx] as char
|
|
})
|
|
.collect();
|
|
|
|
Ok(code)
|
|
}
|
|
|
|
/// Generate TOTP secret
|
|
pub fn generate_totp_secret() -> VezaResult<String> {
|
|
use rand::Rng;
|
|
|
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
let mut rng = rand::thread_rng();
|
|
|
|
let secret: String = (0..32)
|
|
.map(|_| {
|
|
let idx = rng.gen_range(0..CHARSET.len());
|
|
CHARSET[idx] as char
|
|
})
|
|
.collect();
|
|
|
|
Ok(secret)
|
|
}
|
|
|
|
/// Validate TOTP code
|
|
pub fn validate_totp_code(secret: &str, code: &str, _window: i64) -> VezaResult<bool> {
|
|
use totp_rs::{TOTP, Algorithm, Secret};
|
|
|
|
// Use Secret::Encoded to handle base32 string directly
|
|
let secret_obj = Secret::Encoded(secret.to_string());
|
|
|
|
// Use TOTP::new with 5 arguments (basic validation)
|
|
let totp = TOTP::new(
|
|
Algorithm::SHA1,
|
|
6,
|
|
1,
|
|
30,
|
|
secret_obj.to_bytes()
|
|
.map_err(|e| VezaError::Auth(format!("Invalid TOTP secret: {}", e)))?,
|
|
).map_err(|e| VezaError::Auth(format!("Invalid TOTP secret: {}", e)))?;
|
|
|
|
let is_valid = totp.check_current(code)
|
|
.map_err(|e| VezaError::Auth(format!("TOTP validation error: {}", e)))?;
|
|
|
|
Ok(is_valid)
|
|
}
|
|
|
|
/// Generate QR code data for TOTP setup
|
|
pub fn generate_totp_qr_data(secret: &str, username: &str, issuer: &str) -> String {
|
|
format!(
|
|
"otpauth://totp/{}:{}?secret={}&issuer={}",
|
|
issuer, username, secret, issuer
|
|
)
|
|
}
|