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#[derive(Debug)]
21pub enum LoginResult {
22 Success(String),
24 InvalidCredentials {
26 user_message: String,
28 support_code: Option<String>,
30 },
31 InternalError {
33 user_message: String,
35 technical_message: String,
37 support_code: Option<String>,
39 retryable: bool,
41 },
42}
43
44impl LoginResult {
45 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 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 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 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 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 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
111pub 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 pub fn new() -> Self {
142 Self {
143 _phantom: std::marker::PhantomData,
144 }
145 }
146
147 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 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 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 {
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; 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 if i % 2 == 0 {
387 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 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 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 let relative_threshold = 0.75;
482 let absolute_threshold_ms: u128 = 120;
483
484 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}