Skip to main content

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