mockforge_collab/
auth.rs

1//! Authentication and authorization
2
3use crate::error::{CollabError, Result};
4use crate::models::User;
5use argon2::{
6    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
7    Argon2,
8};
9use chrono::{DateTime, Duration, Utc};
10use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// JWT claims for authentication tokens
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Claims {
17    /// Subject (user ID)
18    pub sub: String,
19    /// Username
20    pub username: String,
21    /// Expiration time
22    pub exp: i64,
23    /// Issued at
24    pub iat: i64,
25}
26
27impl Claims {
28    /// Create new claims for a user
29    pub fn new(user_id: Uuid, username: String, expires_in: Duration) -> Self {
30        let now = Utc::now();
31        Self {
32            sub: user_id.to_string(),
33            username,
34            exp: (now + expires_in).timestamp(),
35            iat: now.timestamp(),
36        }
37    }
38
39    /// Check if the token is expired
40    pub fn is_expired(&self) -> bool {
41        Utc::now().timestamp() > self.exp
42    }
43}
44
45/// Authentication token
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Token {
48    /// The JWT token string
49    pub access_token: String,
50    /// Token type (always "Bearer")
51    pub token_type: String,
52    /// Expiration time
53    pub expires_at: DateTime<Utc>,
54}
55
56/// User credentials for login
57#[derive(Debug, Clone, Deserialize)]
58pub struct Credentials {
59    /// Username or email
60    pub username: String,
61    /// Password
62    pub password: String,
63}
64
65/// Active user session
66#[derive(Debug, Clone)]
67pub struct Session {
68    /// User ID
69    pub user_id: Uuid,
70    /// Username
71    pub username: String,
72    /// Session expiration
73    pub expires_at: DateTime<Utc>,
74}
75
76/// Authentication service
77pub struct AuthService {
78    /// JWT secret for signing tokens
79    jwt_secret: String,
80    /// Token expiration duration (default: 24 hours)
81    token_expiration: Duration,
82}
83
84impl AuthService {
85    /// Create a new authentication service
86    pub fn new(jwt_secret: String) -> Self {
87        Self {
88            jwt_secret,
89            token_expiration: Duration::hours(24),
90        }
91    }
92
93    /// Set custom token expiration
94    pub fn with_expiration(mut self, expiration: Duration) -> Self {
95        self.token_expiration = expiration;
96        self
97    }
98
99    /// Hash a password using Argon2
100    pub fn hash_password(&self, password: &str) -> Result<String> {
101        let salt = SaltString::generate(&mut OsRng);
102        let argon2 = Argon2::default();
103
104        let password_hash = argon2
105            .hash_password(password.as_bytes(), &salt)
106            .map_err(|e| CollabError::Internal(format!("Password hashing failed: {}", e)))?
107            .to_string();
108
109        Ok(password_hash)
110    }
111
112    /// Verify a password against a hash
113    pub fn verify_password(&self, password: &str, hash: &str) -> Result<bool> {
114        let parsed_hash = PasswordHash::new(hash)
115            .map_err(|e| CollabError::Internal(format!("Invalid password hash: {}", e)))?;
116
117        let argon2 = Argon2::default();
118
119        Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok())
120    }
121
122    /// Generate a JWT token for a user
123    pub fn generate_token(&self, user: &User) -> Result<Token> {
124        let claims = Claims::new(user.id, user.username.clone(), self.token_expiration);
125        let expires_at = Utc::now() + self.token_expiration;
126
127        let token = encode(
128            &Header::default(),
129            &claims,
130            &EncodingKey::from_secret(self.jwt_secret.as_bytes()),
131        )
132        .map_err(|e| CollabError::Internal(format!("Token generation failed: {}", e)))?;
133
134        Ok(Token {
135            access_token: token,
136            token_type: "Bearer".to_string(),
137            expires_at,
138        })
139    }
140
141    /// Verify and decode a JWT token
142    pub fn verify_token(&self, token: &str) -> Result<Claims> {
143        let token_data = decode::<Claims>(
144            token,
145            &DecodingKey::from_secret(self.jwt_secret.as_bytes()),
146            &Validation::default(),
147        )
148        .map_err(|e| CollabError::AuthenticationFailed(format!("Invalid token: {}", e)))?;
149
150        if token_data.claims.is_expired() {
151            return Err(CollabError::AuthenticationFailed("Token expired".to_string()));
152        }
153
154        Ok(token_data.claims)
155    }
156
157    /// Create a session from a token
158    pub fn create_session(&self, token: &str) -> Result<Session> {
159        let claims = self.verify_token(token)?;
160
161        let user_id = Uuid::parse_str(&claims.sub)
162            .map_err(|e| CollabError::Internal(format!("Invalid user ID in token: {}", e)))?;
163
164        Ok(Session {
165            user_id,
166            username: claims.username,
167            expires_at: DateTime::from_timestamp(claims.exp, 0)
168                .ok_or_else(|| CollabError::Internal("Invalid timestamp".to_string()))?,
169        })
170    }
171
172    /// Generate a random invitation token
173    pub fn generate_invitation_token(&self) -> String {
174        use blake3::hash;
175        let random_data =
176            format!("{}{}", Uuid::new_v4(), Utc::now().timestamp_nanos_opt().unwrap_or(0));
177        hash(random_data.as_bytes()).to_hex().to_string()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_password_hashing() {
187        let auth = AuthService::new("test_secret".to_string());
188        let password = "test_password_123";
189
190        let hash = auth.hash_password(password).unwrap();
191        assert!(auth.verify_password(password, &hash).unwrap());
192        assert!(!auth.verify_password("wrong_password", &hash).unwrap());
193    }
194
195    #[test]
196    fn test_token_generation() {
197        let auth = AuthService::new("test_secret".to_string());
198        let user =
199            User::new("testuser".to_string(), "test@example.com".to_string(), "hash".to_string());
200
201        let token = auth.generate_token(&user).unwrap();
202        assert_eq!(token.token_type, "Bearer");
203        assert!(!token.access_token.is_empty());
204    }
205
206    #[test]
207    fn test_token_verification() {
208        let auth = AuthService::new("test_secret".to_string());
209        let user =
210            User::new("testuser".to_string(), "test@example.com".to_string(), "hash".to_string());
211
212        let token = auth.generate_token(&user).unwrap();
213        let claims = auth.verify_token(&token.access_token).unwrap();
214
215        assert_eq!(claims.username, "testuser");
216        assert!(!claims.is_expired());
217    }
218
219    #[test]
220    fn test_session_creation() {
221        let auth = AuthService::new("test_secret".to_string());
222        let user =
223            User::new("testuser".to_string(), "test@example.com".to_string(), "hash".to_string());
224
225        let token = auth.generate_token(&user).unwrap();
226        let session = auth.create_session(&token.access_token).unwrap();
227
228        assert_eq!(session.username, "testuser");
229    }
230
231    #[test]
232    fn test_invitation_token_generation() {
233        let auth = AuthService::new("test_secret".to_string());
234        let token1 = auth.generate_invitation_token();
235        let token2 = auth.generate_invitation_token();
236
237        assert!(!token1.is_empty());
238        assert!(!token2.is_empty());
239        assert_ne!(token1, token2); // Should be unique
240    }
241}