Skip to main content

axum_gate/authn/
login.rs

1use crate::accounts::{Account, AccountRepository};
2use crate::authz::AccessHierarchy;
3use crate::codecs::Codec;
4use crate::codecs::jwt::{JwtClaims, RegisteredClaims};
5use crate::credentials::Credentials;
6use crate::credentials::CredentialsVerifier;
7use crate::verification_result::VerificationResult;
8
9use std::sync::Arc;
10
11use subtle::Choice;
12use tracing::{debug, error};
13use uuid::Uuid;
14
15/// Result of a login attempt produced by [`LoginService::authenticate`].
16///
17/// The variants deliberately avoid revealing whether an account exists to
18/// mitigate username enumeration attacks. Both an unknown user and an
19/// incorrect password are collapsed into [`LoginResult::InvalidCredentials`].
20#[derive(Debug)]
21pub enum LoginResult {
22    /// Authentication succeeded and contains the issued JWT (already UTF‑8).
23    Success(String),
24    /// Credentials were invalid (unknown user OR wrong password).
25    InvalidCredentials {
26        /// User-friendly message
27        user_message: String,
28        /// Support reference code
29        support_code: Option<String>,
30    },
31    /// An internal / infrastructural error (repository failure, hashing, JWT, etc.).
32    InternalError {
33        /// User-friendly message
34        user_message: String,
35        /// Technical message for developers
36        technical_message: String,
37        /// Support reference code
38        support_code: Option<String>,
39        /// Whether this error is retryable
40        retryable: bool,
41    },
42}
43
44impl LoginResult {
45    /// Create an invalid credentials result with user-friendly messaging
46    pub fn invalid_credentials(user_message: Option<String>, support_code: Option<String>) -> Self {
47        LoginResult::InvalidCredentials {
48            user_message: user_message.unwrap_or_else(|| {
49                "The username or password you entered is incorrect. Please check your credentials and try again.".to_string()
50            }),
51            support_code,
52        }
53    }
54
55    /// Create an internal error result with comprehensive messaging
56    pub fn internal_error(
57        user_message: impl Into<String>,
58        technical_message: impl Into<String>,
59        support_code: Option<&str>,
60        retryable: bool,
61    ) -> Self {
62        LoginResult::InternalError {
63            user_message: user_message.into(),
64            technical_message: technical_message.into(),
65            support_code: support_code.map(|s| s.to_string()),
66            retryable,
67        }
68    }
69
70    /// Get user-friendly message for any result type
71    pub fn user_message(&self) -> String {
72        match self {
73            LoginResult::Success(_) => "Sign-in successful! Welcome back.".to_string(),
74            LoginResult::InvalidCredentials { user_message, .. } => user_message.clone(),
75            LoginResult::InternalError { user_message, .. } => user_message.clone(),
76        }
77    }
78
79    /// Get support code if available
80    pub fn support_code(&self) -> Option<String> {
81        match self {
82            LoginResult::Success(_) => None,
83            LoginResult::InvalidCredentials { support_code, .. } => support_code.clone(),
84            LoginResult::InternalError { support_code, .. } => support_code.clone(),
85        }
86    }
87
88    /// Get technical details for developers/logs
89    pub fn technical_message(&self) -> Option<String> {
90        match self {
91            LoginResult::Success(_) => None,
92            LoginResult::InvalidCredentials { .. } => {
93                Some("Invalid credentials provided".to_string())
94            }
95            LoginResult::InternalError {
96                technical_message, ..
97            } => Some(technical_message.clone()),
98        }
99    }
100
101    /// Whether this result indicates a retryable error
102    pub fn is_retryable(&self) -> bool {
103        match self {
104            LoginResult::Success(_) => false,
105            LoginResult::InvalidCredentials { .. } => true,
106            LoginResult::InternalError { retryable, .. } => *retryable,
107        }
108    }
109}
110
111/// Stateless service implementing constant‑time, enumeration‑resistant
112/// authentication logic.
113///
114/// It always performs:
115/// 1. Account lookup by user identifier.
116/// 2. Credential verification against either the real account UUID or a
117///    fixed dummy UUID when the account is absent.
118///
119/// This equalises timing characteristics between "user not found" and
120/// "wrong password" cases.
121///
122/// Type Params:
123/// * `R` - Role type implementing [`AccessHierarchy`]
124/// * `G` - Group type
125pub struct LoginService<R, G>
126where
127    R: AccessHierarchy + Eq,
128    G: Eq + Clone,
129{
130    _phantom: std::marker::PhantomData<(R, G)>,
131}
132
133impl<R, G> LoginService<R, G>
134where
135    R: AccessHierarchy + Eq,
136    G: Eq + Clone,
137{
138    /// Creates a new stateless `LoginService`.
139    ///
140    /// The instance holds no mutable state; it is cheap to clone or recreate.
141    pub fn new() -> Self {
142        Self {
143            _phantom: std::marker::PhantomData,
144        }
145    }
146
147    /// Authenticates a user in a timing‑safe, enumeration‑resistant fashion.
148    ///
149    /// Steps:
150    /// 1. Query account repository by user identifier.
151    /// 2. Always invoke credential verification using either the real account
152    ///    UUID or a fixed dummy UUID when the user is absent / lookup failed.
153    /// 3. Combine (user_exists AND password_matches) with constant‑time bit logic.
154    /// 4. On success, encode a JWT with the supplied registered claims.
155    ///
156    /// Returns:
157    /// * [`LoginResult::Success`] with a JWT string on success.
158    /// * [`LoginResult::InvalidCredentials`] for any auth failure that should not leak detail.
159    /// * [`LoginResult::InternalError`] for infrastructural issues (these should be logged).
160    ///
161    /// Security: Avoids early returns that would create observable timing
162    /// differences between "user not found" and "wrong password".
163    pub async fn authenticate<CredVeri, AccRepo, C>(
164        &self,
165        credentials: Credentials<String>,
166        registered_claims: RegisteredClaims,
167        credentials_verifier: Arc<CredVeri>,
168        account_repository: Arc<AccRepo>,
169        codec: Arc<C>,
170    ) -> LoginResult
171    where
172        CredVeri: CredentialsVerifier<Uuid>,
173        AccRepo: AccountRepository<R, G>,
174        C: Codec<Payload = JwtClaims<Account<R, G>>>,
175    {
176        #[cfg(feature = "audit-logging")]
177        let _audit_span =
178            tracing::span!(tracing::Level::INFO, "auth.login", user_id = %credentials.id);
179        #[cfg(feature = "audit-logging")]
180        let _audit_enter = _audit_span.enter();
181        #[cfg(feature = "audit-logging")]
182        tracing::info!(user_id = %credentials.id, "login_attempt");
183        let account_query_result = account_repository
184            .query_account_by_user_id(&credentials.id)
185            .await;
186
187        let (account_opt, verification_uuid, user_exists_choice, query_error_opt) =
188            match account_query_result {
189                Ok(Some(acc)) => {
190                    debug!("Account found for user_id: {}", credentials.id);
191                    (Some(acc.clone()), acc.account_id, Choice::from(1u8), None)
192                }
193                Ok(None) => {
194                    debug!("Account not found for user_id: {}", credentials.id);
195                    let dummy_uuid = Uuid::from_bytes([
196                        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x70, 0x00, 0x80, 0x00, 0x00, 0x00,
197                        0x00, 0x00, 0x00, 0x01,
198                    ]);
199                    (None, dummy_uuid, Choice::from(0u8), None)
200                }
201                Err(e) => {
202                    error!("Error querying account: {}", e);
203                    let dummy_uuid = Uuid::from_bytes([
204                        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x70, 0x00, 0x80, 0x00, 0x00, 0x00,
205                        0x00, 0x00, 0x00, 0x01,
206                    ]);
207                    (None, dummy_uuid, Choice::from(0u8), Some(e))
208                }
209            };
210
211        if let Some(error) = query_error_opt {
212            return LoginResult::internal_error(
213                "We're experiencing technical difficulties. Please try signing in again.",
214                format!("Account repository query failed: {}", error),
215                Some("repository_query"),
216                true,
217            );
218        }
219
220        let creds_to_verify = Credentials::new(&verification_uuid, &credentials.secret);
221        let verification_result = credentials_verifier
222            .verify_credentials(creds_to_verify)
223            .await;
224
225        let auth_success_choice = match verification_result {
226            Ok(VerificationResult::Ok) => {
227                debug!(
228                    "Credentials verified successfully for UUID: {}",
229                    verification_uuid
230                );
231                Choice::from(1u8)
232            }
233            Ok(VerificationResult::Unauthorized) => {
234                debug!(
235                    "Credentials verification failed for UUID: {}",
236                    verification_uuid
237                );
238                Choice::from(0u8)
239            }
240            Err(e) => {
241                error!("Error verifying credentials: {}", e);
242                return LoginResult::internal_error(
243                    "We're having trouble with the authentication system. Please try again.",
244                    format!("Credential verification failed: {}", e),
245                    Some("credential_verification"),
246                    true,
247                );
248            }
249        };
250
251        let final_success_choice = user_exists_choice & auth_success_choice;
252        let login_successful: bool = final_success_choice.into();
253
254        if login_successful {
255            if let Some(account) = account_opt {
256                let claims = JwtClaims::new(account, registered_claims);
257                let jwt = match codec.encode(&claims) {
258                    Ok(token) => token,
259                    Err(e) => {
260                        error!("Error encoding JWT: {}", e);
261                        return LoginResult::internal_error(
262                            "We're having trouble completing your sign-in. Please try again.",
263                            format!("JWT encoding failed: {}", e),
264                            Some("jwt_encoding"),
265                            true,
266                        );
267                    }
268                };
269                let jwt_string = match String::from_utf8(jwt) {
270                    Ok(s) => s,
271                    Err(e) => {
272                        error!("Error converting JWT to string: {}", e);
273                        return LoginResult::internal_error(
274                            "We're having trouble completing your sign-in. Please try again.",
275                            format!("JWT string conversion failed: {}", e),
276                            Some("jwt_conversion"),
277                            true,
278                        );
279                    }
280                };
281                debug!("Login successful, JWT generated");
282                LoginResult::Success(jwt_string)
283            } else {
284                error!("Internal error: login marked successful but no account available");
285                LoginResult::internal_error(
286                    "There was an unexpected issue with your authentication. Please try signing in again.",
287                    "Authentication state inconsistency: successful verification but no account data",
288                    Some("auth_state_inconsistency"),
289                    false,
290                )
291            }
292        } else {
293            debug!("Login failed - invalid credentials");
294            LoginResult::invalid_credentials(None, None)
295        }
296    }
297}
298
299impl<R, G> Default for LoginService<R, G>
300where
301    R: AccessHierarchy + Eq,
302    G: Eq + Clone,
303{
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::codecs::jwt::{JsonWebToken, JwtClaims};
313    use crate::groups::Group;
314    use crate::hashing::argon2::Argon2Hasher;
315    use crate::repositories::memory::{MemoryAccountRepository, MemorySecretRepository};
316    use crate::roles::Role;
317    use crate::secrets::Secret;
318    use std::time::{Duration, Instant};
319
320    fn median(durs: &[Duration]) -> Duration {
321        let mut v = durs.to_vec();
322        v.sort();
323        v[v.len() / 2]
324    }
325
326    #[tokio::test]
327    #[allow(clippy::unwrap_used)]
328    #[allow(clippy::expect_used)]
329    async fn test_timing_attack_protection() {
330        use crate::secrets::SecretRepository;
331        // Setup
332        let account_repo = Arc::new(MemoryAccountRepository::<Role, Group>::default());
333        let secret_repo = Arc::new(MemorySecretRepository::new_with_argon2_hasher().unwrap());
334        let jwt_codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
335        let login_service = LoginService::new();
336
337        // Account + secret
338        let existing_user = "existing@example.com";
339        let password = "test_password";
340        let account = Account::new(existing_user, &[Role::User], &[Group::new("test-group")]);
341        let stored_account = account_repo.store_account(account).await.unwrap().unwrap();
342
343        let secret = Secret::new(
344            &stored_account.account_id,
345            password,
346            Argon2Hasher::new_recommended().unwrap(),
347        )
348        .expect("secret");
349        secret_repo.store_secret(secret).await.unwrap();
350
351        let registered_claims = crate::codecs::jwt::RegisteredClaims::new(
352            "test-issuer",
353            chrono::Utc::now().timestamp() as u64 + 3600,
354        );
355
356        // Warm-up both failure paths (first Argon2 invocation can include allocation cost)
357        {
358            let creds = Credentials::new(&"nonexistent@example.com".to_string(), "pw");
359            let _ = login_service
360                .authenticate(
361                    creds,
362                    registered_claims.clone(),
363                    secret_repo.clone(),
364                    account_repo.clone(),
365                    jwt_codec.clone(),
366                )
367                .await;
368            let creds = Credentials::new(&existing_user.to_string(), "wrong_pw");
369            let _ = login_service
370                .authenticate(
371                    creds,
372                    registered_claims.clone(),
373                    secret_repo.clone(),
374                    account_repo.clone(),
375                    jwt_codec.clone(),
376                )
377                .await;
378        }
379
380        let iterations = 6; // keep total runtime reasonable
381        let mut nonexistent_times = Vec::with_capacity(iterations);
382        let mut wrong_times = Vec::with_capacity(iterations);
383
384        for i in 0..iterations {
385            // Alternate ordering to reduce systemic bias
386            if i % 2 == 0 {
387                // nonexistent first
388                let creds = Credentials::new(&"nonexistent@example.com".to_string(), "any_pw");
389                let start = Instant::now();
390                let r = login_service
391                    .authenticate(
392                        creds,
393                        registered_claims.clone(),
394                        secret_repo.clone(),
395                        account_repo.clone(),
396                        jwt_codec.clone(),
397                    )
398                    .await;
399                assert!(matches!(r, LoginResult::InvalidCredentials { .. }));
400                nonexistent_times.push(start.elapsed());
401
402                let creds = Credentials::new(&existing_user.to_string(), "wrong_pw");
403                let start = Instant::now();
404                let r = login_service
405                    .authenticate(
406                        creds,
407                        registered_claims.clone(),
408                        secret_repo.clone(),
409                        account_repo.clone(),
410                        jwt_codec.clone(),
411                    )
412                    .await;
413                assert!(matches!(r, LoginResult::InvalidCredentials { .. }));
414                wrong_times.push(start.elapsed());
415            } else {
416                // wrong first
417                let creds = Credentials::new(&existing_user.to_string(), "wrong_pw");
418                let start = Instant::now();
419                let r = login_service
420                    .authenticate(
421                        creds,
422                        registered_claims.clone(),
423                        secret_repo.clone(),
424                        account_repo.clone(),
425                        jwt_codec.clone(),
426                    )
427                    .await;
428                assert!(matches!(r, LoginResult::InvalidCredentials { .. }));
429                wrong_times.push(start.elapsed());
430
431                let creds = Credentials::new(&"nonexistent@example.com".to_string(), "any_pw");
432                let start = Instant::now();
433                let r = login_service
434                    .authenticate(
435                        creds,
436                        registered_claims.clone(),
437                        secret_repo.clone(),
438                        account_repo.clone(),
439                        jwt_codec.clone(),
440                    )
441                    .await;
442                assert!(matches!(r, LoginResult::InvalidCredentials { .. }));
443                nonexistent_times.push(start.elapsed());
444            }
445        }
446
447        // Measure success path (informational)
448        let mut success_times = Vec::new();
449        for _ in 0..3 {
450            let creds = Credentials::new(&existing_user.to_string(), password);
451            let start = Instant::now();
452            let r = login_service
453                .authenticate(
454                    creds,
455                    registered_claims.clone(),
456                    secret_repo.clone(),
457                    account_repo.clone(),
458                    jwt_codec.clone(),
459                )
460                .await;
461            assert!(matches!(r, LoginResult::Success(_)));
462            success_times.push(start.elapsed());
463        }
464
465        let med_nonexistent = median(&nonexistent_times);
466        let med_wrong = median(&wrong_times);
467        let med_success = median(&success_times);
468
469        let (fast, slow) = if med_nonexistent < med_wrong {
470            (med_nonexistent, med_wrong)
471        } else {
472            (med_wrong, med_nonexistent)
473        };
474        let diff = slow - fast;
475        let relative = diff.as_secs_f64() / fast.as_secs_f64().max(1e-9);
476
477        // Thresholds:
478        // - relative difference must stay below 0.75 (75%)
479        // - absolute diff below 120ms (very generous for noisy CI)
480        // If Argon2 is accidentally skipped for one path, relative diff will approach 1.0
481        let relative_threshold = 0.75;
482        let absolute_threshold_ms: u128 = 120;
483
484        // Minimal expected Argon2 duration (debug fast preset vs release high security):
485        let min_expected_ms: u128 = if cfg!(debug_assertions) { 2 } else { 5 };
486        assert!(
487            med_nonexistent.as_millis() >= min_expected_ms,
488            "Nonexistent path too fast ({} ms) - Argon2 likely skipped",
489            med_nonexistent.as_millis()
490        );
491        assert!(
492            med_wrong.as_millis() >= min_expected_ms,
493            "Wrong-password path too fast ({} ms) - Argon2 likely skipped",
494            med_wrong.as_millis()
495        );
496
497        println!(
498            "Timing medians -> nonexistent: {:?}, wrong: {:?}, success: {:?}, diff: {:?} ({} ms), rel: {:.2}",
499            med_nonexistent,
500            med_wrong,
501            med_success,
502            diff,
503            diff.as_millis(),
504            relative
505        );
506
507        assert!(
508            diff.as_millis() < absolute_threshold_ms || relative < relative_threshold,
509            "Timing difference suspicious: diff={}ms (limit {}ms), rel={:.2} (limit {:.2}). \
510             Nonexistent samples: {:?} Wrong samples: {:?}",
511            diff.as_millis(),
512            absolute_threshold_ms,
513            relative,
514            relative_threshold,
515            nonexistent_times,
516            wrong_times
517        );
518    }
519
520    #[tokio::test]
521    #[allow(clippy::unwrap_used)]
522    async fn test_login_result_no_user_enumeration() {
523        let account_repo = Arc::new(MemoryAccountRepository::<Role, Group>::default());
524        let secret_repo = Arc::new(MemorySecretRepository::new_with_argon2_hasher().unwrap());
525        let jwt_codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
526        let login_service = LoginService::new();
527
528        let registered_claims = crate::codecs::jwt::RegisteredClaims::new(
529            "test-issuer",
530            chrono::Utc::now().timestamp() as u64 + 3600,
531        );
532
533        let nonexistent_credentials =
534            Credentials::new(&"nonexistent@example.com".to_string(), "password");
535        let result = login_service
536            .authenticate(
537                nonexistent_credentials,
538                registered_claims,
539                secret_repo,
540                account_repo,
541                jwt_codec,
542            )
543            .await;
544
545        assert!(matches!(result, LoginResult::InvalidCredentials { .. }));
546    }
547}