Skip to main content

reasonkit_web/portal/
auth.rs

1//! # Authentication Module
2//!
3//! JWT-based authentication with OAuth 2.0 and 2FA support.
4//!
5//! ## Features
6//!
7//! - Email/password registration and login
8//! - OAuth 2.0 providers (GitHub, Google, Microsoft)
9//! - TOTP-based 2FA
10//! - JWT access tokens with refresh token rotation
11//! - Session management
12
13use axum::{
14    extract::{Json, Path},
15    http::StatusCode,
16    response::IntoResponse,
17};
18use chrono::{Duration, Utc};
19use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
20use serde::{Deserialize, Serialize};
21
22/// JWT Claims structure
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Claims {
25    /// Subject (user ID)
26    pub sub: String,
27    /// Email
28    pub email: String,
29    /// Issued at timestamp
30    pub iat: i64,
31    /// Expiration timestamp
32    pub exp: i64,
33    /// Token type (access or refresh)
34    pub token_type: TokenType,
35    /// Scopes/permissions
36    pub scopes: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40#[serde(rename_all = "lowercase")]
41pub enum TokenType {
42    Access,
43    Refresh,
44}
45
46/// Access and refresh token pair
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TokenPair {
49    pub access_token: String,
50    pub refresh_token: String,
51    pub token_type: String,
52    pub expires_in: u64,
53}
54
55/// Authentication configuration
56#[derive(Debug, Clone)]
57pub struct AuthConfig {
58    pub jwt_secret: String,
59    pub access_token_expiry: Duration,
60    pub refresh_token_expiry: Duration,
61    pub issuer: String,
62    pub audience: String,
63}
64
65impl Default for AuthConfig {
66    fn default() -> Self {
67        Self {
68            jwt_secret: std::env::var("JWT_SECRET")
69                .unwrap_or_else(|_| "change-me-in-production".to_string()),
70            access_token_expiry: Duration::hours(1),
71            refresh_token_expiry: Duration::days(7),
72            issuer: "reasonkit.sh".to_string(),
73            audience: "reasonkit-api".to_string(),
74        }
75    }
76}
77
78/// Authentication service
79pub struct AuthService {
80    config: AuthConfig,
81    encoding_key: EncodingKey,
82    decoding_key: DecodingKey,
83}
84
85impl AuthService {
86    pub fn new(config: AuthConfig) -> Self {
87        let encoding_key = EncodingKey::from_secret(config.jwt_secret.as_bytes());
88        let decoding_key = DecodingKey::from_secret(config.jwt_secret.as_bytes());
89        Self {
90            config,
91            encoding_key,
92            decoding_key,
93        }
94    }
95
96    /// Generate a token pair for a user
97    pub fn generate_token_pair(
98        &self,
99        user_id: &str,
100        email: &str,
101        scopes: Vec<String>,
102    ) -> TokenPair {
103        let now = Utc::now();
104
105        // Access token
106        let access_claims = Claims {
107            sub: user_id.to_string(),
108            email: email.to_string(),
109            iat: now.timestamp(),
110            exp: (now + self.config.access_token_expiry).timestamp(),
111            token_type: TokenType::Access,
112            scopes: scopes.clone(),
113        };
114
115        let access_token = encode(&Header::default(), &access_claims, &self.encoding_key)
116            .expect("Failed to encode access token");
117
118        // Refresh token
119        let refresh_claims = Claims {
120            sub: user_id.to_string(),
121            email: email.to_string(),
122            iat: now.timestamp(),
123            exp: (now + self.config.refresh_token_expiry).timestamp(),
124            token_type: TokenType::Refresh,
125            scopes,
126        };
127
128        let refresh_token = encode(&Header::default(), &refresh_claims, &self.encoding_key)
129            .expect("Failed to encode refresh token");
130
131        TokenPair {
132            access_token,
133            refresh_token,
134            token_type: "Bearer".to_string(),
135            expires_in: self.config.access_token_expiry.num_seconds() as u64,
136        }
137    }
138
139    /// Validate and decode a token
140    pub fn validate_token(&self, token: &str) -> Result<Claims, AuthError> {
141        let validation = Validation::default();
142        decode::<Claims>(token, &self.decoding_key, &validation)
143            .map(|data| data.claims)
144            .map_err(|e| AuthError::InvalidToken(e.to_string()))
145    }
146
147    /// Hash a password using argon2
148    pub fn hash_password(password: &str) -> Result<String, AuthError> {
149        use argon2::Argon2;
150        use password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
151
152        let salt = SaltString::generate(&mut OsRng);
153        let argon2 = Argon2::default();
154
155        argon2
156            .hash_password(password.as_bytes(), &salt)
157            .map(|hash| hash.to_string())
158            .map_err(|e| AuthError::PasswordHashError(e.to_string()))
159    }
160
161    /// Verify a password against a hash
162    pub fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
163        use argon2::Argon2;
164        use password_hash::{PasswordHash, PasswordVerifier};
165
166        let parsed_hash =
167            PasswordHash::new(hash).map_err(|e| AuthError::PasswordHashError(e.to_string()))?;
168
169        Ok(Argon2::default()
170            .verify_password(password.as_bytes(), &parsed_hash)
171            .is_ok())
172    }
173}
174
175/// Authentication errors
176#[derive(Debug, thiserror::Error)]
177pub enum AuthError {
178    #[error("Invalid credentials")]
179    InvalidCredentials,
180    #[error("Invalid token: {0}")]
181    InvalidToken(String),
182    #[error("Token expired")]
183    TokenExpired,
184    #[error("User not found")]
185    UserNotFound,
186    #[error("Email already registered")]
187    EmailAlreadyExists,
188    #[error("Password hash error: {0}")]
189    PasswordHashError(String),
190    #[error("2FA required")]
191    TwoFactorRequired,
192    #[error("Invalid 2FA code")]
193    Invalid2FACode,
194    #[error("Database error: {0}")]
195    DatabaseError(String),
196}
197
198/// HTTP handlers for authentication endpoints
199pub mod handlers {
200    use super::*;
201
202    #[derive(Debug, Deserialize)]
203    pub struct RegisterRequest {
204        pub email: String,
205        pub password: String,
206        pub name: Option<String>,
207    }
208
209    #[derive(Debug, Deserialize)]
210    pub struct LoginRequest {
211        pub email: String,
212        pub password: String,
213        pub totp_code: Option<String>,
214    }
215
216    #[derive(Debug, Serialize)]
217    pub struct AuthResponse {
218        pub success: bool,
219        pub tokens: Option<TokenPair>,
220        pub user_id: Option<String>,
221        pub requires_2fa: bool,
222        pub message: String,
223    }
224
225    /// Register a new user
226    pub async fn register(Json(_req): Json<RegisterRequest>) -> impl IntoResponse {
227        // TODO: Implement with database
228        let response = AuthResponse {
229            success: true,
230            tokens: None,
231            user_id: Some("user_placeholder".to_string()),
232            requires_2fa: false,
233            message: "Registration successful. Please verify your email.".to_string(),
234        };
235        (StatusCode::CREATED, Json(response))
236    }
237
238    /// Login with email and password
239    pub async fn login(Json(_req): Json<LoginRequest>) -> impl IntoResponse {
240        // TODO: Implement with database
241        let response = AuthResponse {
242            success: true,
243            tokens: Some(TokenPair {
244                access_token: "placeholder".to_string(),
245                refresh_token: "placeholder".to_string(),
246                token_type: "Bearer".to_string(),
247                expires_in: 3600,
248            }),
249            user_id: Some("user_placeholder".to_string()),
250            requires_2fa: false,
251            message: "Login successful".to_string(),
252        };
253        (StatusCode::OK, Json(response))
254    }
255
256    /// Logout (invalidate refresh token)
257    pub async fn logout() -> impl IntoResponse {
258        (StatusCode::OK, Json(serde_json::json!({"success": true})))
259    }
260
261    /// Refresh access token
262    pub async fn refresh_token() -> impl IntoResponse {
263        // TODO: Implement token refresh
264        StatusCode::NOT_IMPLEMENTED
265    }
266
267    /// Request password reset email
268    pub async fn request_password_reset() -> impl IntoResponse {
269        StatusCode::NOT_IMPLEMENTED
270    }
271
272    /// Reset password with token
273    pub async fn reset_password(Path(_token): Path<String>) -> impl IntoResponse {
274        StatusCode::NOT_IMPLEMENTED
275    }
276
277    /// Setup 2FA (returns QR code)
278    pub async fn setup_2fa() -> impl IntoResponse {
279        StatusCode::NOT_IMPLEMENTED
280    }
281
282    /// Verify 2FA code
283    pub async fn verify_2fa() -> impl IntoResponse {
284        StatusCode::NOT_IMPLEMENTED
285    }
286}