1mod backoff;
6mod challenge;
7mod cipher;
8mod email;
9mod pages;
10mod settings;
11mod totp;
12
13use std::time::Duration;
14
15use jiff::Timestamp;
16use subtle::ConstantTimeEq;
17use topcoat::{
18 context::{Cx, try_context},
19 cookie::Key,
20};
21
22pub(crate) use self::{challenge::challenge_url, settings::settings_url};
23pub(super) use self::{
24 challenge::{begin as begin_challenge, challenge_page, challenge_post},
25 settings::{settings_page, settings_post},
26};
27use super::{LoginThrottle, PanelUser, infrastructure_failure};
28use crate::auth::realm::{Realm, panel_prefix};
29
30#[derive(Debug)]
42pub struct TwoFactor {
43 required: bool,
44 email: bool,
45 throttle: LoginThrottle,
46 mail_throttle: LoginThrottle,
47}
48
49impl TwoFactor {
50 #[must_use]
52 pub fn new() -> Self {
53 Self {
54 required: false,
55 email: false,
56 throttle: LoginThrottle::new(5, Duration::from_mins(1)),
57 mail_throttle: LoginThrottle::new(3, Duration::from_mins(15)),
58 }
59 }
60
61 #[must_use]
65 pub fn required(mut self) -> Self {
66 self.required = true;
67 self
68 }
69
70 #[must_use]
74 pub fn email_codes(mut self) -> Self {
75 self.email = true;
76 self
77 }
78
79 #[must_use]
82 pub fn mail_throttle(mut self, throttle: LoginThrottle) -> Self {
83 self.mail_throttle = throttle;
84 self
85 }
86
87 #[must_use]
90 pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
91 self.throttle = throttle;
92 self
93 }
94}
95
96impl TwoFactor {
97 pub(in crate::auth) fn mails(&self) -> bool {
99 self.email
100 }
101}
102
103impl Default for TwoFactor {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109#[derive(Debug, Clone, toasty::Model)]
112pub struct AuthFactor {
113 #[key]
114 #[auto]
115 pub id: uuid::Uuid,
116 #[index]
118 pub user_id: String,
119 pub panel: String,
121 pub kind: FactorKind,
122 pub secret: String,
125 pub confirmed_at: Option<Timestamp>,
128 pub last_step: Option<i64>,
130 pub failures: i64,
132 pub retry_at: Option<Timestamp>,
135 pub created_at: Timestamp,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
141#[column(type = text)]
142#[non_exhaustive]
143pub enum FactorKind {
144 Authenticator,
146 RecoveryCode,
148 EmailCode,
150}
151
152const RECOVERY_CODES: usize = 8;
154
155const RECOVERY_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
157
158fn key(cx: &Cx) -> topcoat::Result<&Key> {
160 try_context::<Key>(cx)
161 .ok_or_else(|| crate::error::declaration("two-factor sign-in needs the router's `Key`"))
162}
163
164fn binding(user_id: &str, panel: &str) -> String {
166 format!("{user_id}\n{panel}")
167}
168
169async fn factors(
171 ex: &mut dyn toasty::Executor,
172 panel: &str,
173 user_id: &str,
174) -> topcoat::Result<Vec<AuthFactor>> {
175 AuthFactor::filter(
176 AuthFactor::fields()
177 .user_id()
178 .eq(user_id.to_string())
179 .and(AuthFactor::fields().panel().eq(panel.to_string())),
180 )
181 .exec(ex)
182 .await
183 .map_err(infrastructure_failure)
184}
185
186#[derive(Debug, Default)]
189pub(super) struct Enrolment {
190 pub(super) confirmed: Option<AuthFactor>,
191 pub(super) unconfirmed: Option<AuthFactor>,
192 pub(super) recovery_codes: usize,
193}
194
195pub(super) async fn enrolment(
197 cx: &Cx,
198 ex: &mut dyn toasty::Executor,
199 user_id: &str,
200) -> topcoat::Result<Enrolment> {
201 enrolment_on(ex, &panel_prefix(cx), user_id).await
202}
203
204async fn enrolment_on(
206 ex: &mut dyn toasty::Executor,
207 panel: &str,
208 user_id: &str,
209) -> topcoat::Result<Enrolment> {
210 let mut enrolment = Enrolment::default();
211 for factor in factors(ex, panel, user_id).await? {
212 match (factor.kind, factor.confirmed_at.is_some()) {
213 (FactorKind::RecoveryCode, _) => enrolment.recovery_codes += 1,
214 (_, true) => enrolment.confirmed = Some(factor),
215 (_, false) => enrolment.unconfirmed = Some(factor),
216 }
217 }
218 Ok(enrolment)
219}
220
221pub(super) async fn enrolled(cx: &Cx, user: &dyn PanelUser) -> topcoat::Result<Option<FactorKind>> {
223 let mut db = crate::db::db(cx)?;
224 Ok(enrolment(cx, &mut db, &user.user_id())
225 .await?
226 .confirmed
227 .map(|factor| factor.kind))
228}
229
230pub(crate) fn offered(cx: &Cx) -> bool {
232 super::signed(cx).is_some_and(|signed| signed.realm.auth.two_factor_config().is_some())
233}
234
235pub(super) async fn setup_pending(
238 cx: &Cx,
239 panel: &Realm,
240 user: &dyn PanelUser,
241) -> topcoat::Result<bool> {
242 match panel.auth.two_factor_config() {
243 Some(two_factor) if two_factor.required => {
244 let mut db = crate::db::db(cx)?;
245 let enrolment = enrolment_on(&mut db, &panel.prefix, &user.user_id()).await?;
246 Ok(enrolment.confirmed.is_none())
247 }
248 _ => Ok(false),
249 }
250}
251
252async fn start(
255 cx: &Cx,
256 ex: &mut dyn toasty::Executor,
257 user_id: &str,
258 kind: FactorKind,
259 secret: String,
260) -> topcoat::Result<()> {
261 let panel = panel_prefix(cx);
262 AuthFactor::filter(
264 AuthFactor::fields()
265 .user_id()
266 .eq(user_id.to_string())
267 .and(AuthFactor::fields().panel().eq(panel.clone()))
268 .and(AuthFactor::fields().confirmed_at().is_none()),
269 )
270 .delete()
271 .exec(&mut *ex)
272 .await
273 .map_err(infrastructure_failure)?;
274 toasty::create!(AuthFactor {
275 user_id: user_id.to_string(),
276 panel,
277 kind,
278 secret,
279 confirmed_at: None,
280 last_step: None,
281 failures: 0,
282 retry_at: None,
283 created_at: Timestamp::now(),
284 })
285 .exec(ex)
286 .await
287 .map_err(infrastructure_failure)?;
288 Ok(())
289}
290
291async fn start_authenticator(
294 cx: &Cx,
295 ex: &mut dyn toasty::Executor,
296 user_id: &str,
297) -> topcoat::Result<Vec<u8>> {
298 let secret = totp::secret();
299 let sealed = cipher::seal(key(cx)?, &secret, &binding(user_id, &panel_prefix(cx)));
300 start(cx, ex, user_id, FactorKind::Authenticator, sealed).await?;
301 Ok(secret.to_vec())
302}
303
304fn secret(cx: &Cx, factor: &AuthFactor) -> topcoat::Result<Option<Vec<u8>>> {
306 Ok(cipher::open(
307 key(cx)?,
308 &factor.secret,
309 &binding(&factor.user_id, &factor.panel),
310 ))
311}
312
313async fn accept_code(
316 cx: &Cx,
317 ex: &mut dyn toasty::Executor,
318 factor: &AuthFactor,
319 code: &str,
320) -> topcoat::Result<bool> {
321 let Some(secret) = secret(cx, factor)? else {
322 tracing::error!(panel = %factor.panel, "a two-factor secret does not open under the key");
323 return Ok(false);
324 };
325 let now = std::time::SystemTime::now()
326 .duration_since(std::time::UNIX_EPOCH)
327 .map_err(|_| crate::error::declaration("the clock is before 1970"))?
328 .as_secs();
329 let used = factor.last_step.and_then(|step| u64::try_from(step).ok());
330 let Some(step) = totp::verify(&secret, code, totp::step(now), used) else {
331 return Ok(false);
332 };
333 AuthFactor::filter(AuthFactor::fields().id().eq(factor.id))
334 .update()
335 .last_step(i64::try_from(step).ok())
336 .exec(ex)
337 .await
338 .map_err(infrastructure_failure)?;
339 Ok(true)
340}
341
342fn normalize_recovery_code(code: &str) -> String {
344 code.chars()
345 .filter(char::is_ascii_alphanumeric)
346 .map(|c| c.to_ascii_lowercase())
347 .collect()
348}
349
350fn recovery_hash(cx: &Cx, user_id: &str, panel: &str, code: &str) -> topcoat::Result<String> {
352 Ok(cipher::recovery_hash(
353 key(cx)?,
354 &binding(user_id, panel),
355 &normalize_recovery_code(code),
356 ))
357}
358
359async fn spend_recovery_code(
362 cx: &Cx,
363 ex: &mut dyn toasty::Executor,
364 user_id: &str,
365 code: &str,
366) -> topcoat::Result<bool> {
367 let panel = panel_prefix(cx);
368 let hash = recovery_hash(cx, user_id, &panel, code)?;
369 let codes = factors(&mut *ex, &panel, user_id).await?;
370 let Some(matched) = codes.iter().find(|factor| {
371 factor.kind == FactorKind::RecoveryCode
372 && bool::from(factor.secret.as_bytes().ct_eq(hash.as_bytes()))
373 }) else {
374 return Ok(false);
375 };
376 AuthFactor::filter(AuthFactor::fields().id().eq(matched.id))
377 .delete()
378 .exec(ex)
379 .await
380 .map_err(infrastructure_failure)?;
381 Ok(true)
382}
383
384async fn accept_factor(
387 cx: &Cx,
388 ex: &mut dyn toasty::Executor,
389 factor: &AuthFactor,
390 code: &str,
391) -> topcoat::Result<bool> {
392 match factor.kind {
393 FactorKind::Authenticator => accept_code(cx, ex, factor, code).await,
394 FactorKind::EmailCode => email::accept(cx, ex, &factor.user_id, code).await,
395 FactorKind::RecoveryCode => Ok(false),
396 }
397}
398
399fn factor_code(code: &str) -> Option<String> {
402 let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
403 (code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code)
404}
405
406pub(super) async fn accept(
409 cx: &Cx,
410 ex: &mut dyn toasty::Executor,
411 user_id: &str,
412 code: &str,
413) -> topcoat::Result<bool> {
414 let enrolment = enrolment(cx, &mut *ex, user_id).await?;
415 let Some(factor) = enrolment.confirmed else {
416 return Ok(false);
417 };
418 match factor_code(code) {
419 Some(code) => accept_factor(cx, ex, &factor, &code).await,
420 None => {
421 let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
422 spend_recovery_code(cx, ex, user_id, &code).await
423 }
424 }
425}
426
427async fn issue_recovery_codes(
430 cx: &Cx,
431 ex: &mut dyn toasty::Executor,
432 user_id: &str,
433) -> topcoat::Result<Vec<String>> {
434 let panel = panel_prefix(cx);
435 AuthFactor::filter(
436 AuthFactor::fields()
437 .user_id()
438 .eq(user_id.to_string())
439 .and(AuthFactor::fields().panel().eq(panel.clone()))
440 .and(AuthFactor::fields().kind().eq(FactorKind::RecoveryCode)),
441 )
442 .delete()
443 .exec(&mut *ex)
444 .await
445 .map_err(infrastructure_failure)?;
446 let mut codes = Vec::with_capacity(RECOVERY_CODES);
447 let now = Timestamp::now();
448 for _ in 0..RECOVERY_CODES {
449 let code = recovery_code();
450 toasty::create!(AuthFactor {
451 user_id: user_id.to_string(),
452 panel: panel.clone(),
453 kind: FactorKind::RecoveryCode,
454 secret: recovery_hash(cx, user_id, &panel, &code)?,
455 confirmed_at: Some(now),
456 last_step: None,
457 failures: 0,
458 retry_at: None,
459 created_at: now,
460 })
461 .exec(&mut *ex)
462 .await
463 .map_err(infrastructure_failure)?;
464 codes.push(code);
465 }
466 Ok(codes)
467}
468
469fn recovery_code() -> String {
471 let mut bytes = [0u8; 10];
472 getrandom::fill(&mut bytes).expect("the recovery code randomness source failed");
473 let chars: String = bytes
475 .iter()
476 .map(|byte| char::from(RECOVERY_ALPHABET[usize::from(byte & 31)]))
477 .collect();
478 format!("{}-{}", &chars[..5], &chars[5..])
479}
480
481async fn remove_all(cx: &Cx, ex: &mut dyn toasty::Executor, user_id: &str) -> topcoat::Result<()> {
483 AuthFactor::filter(
484 AuthFactor::fields()
485 .user_id()
486 .eq(user_id.to_string())
487 .and(AuthFactor::fields().panel().eq(panel_prefix(cx))),
488 )
489 .delete()
490 .exec(ex)
491 .await
492 .map_err(infrastructure_failure)
493}
494
495pub(super) fn declaration_errors(
498 auth: &super::AuthConfig,
499 has_key: bool,
500) -> Vec<crate::DeclarationErrorKind> {
501 use crate::{DeclarationErrorKind, declaration::TwoFactorFault};
502
503 if auth.two_factor.is_none() {
504 return Vec::new();
505 }
506 if auth.authenticator().is_none() {
507 return vec![DeclarationErrorKind::TwoFactor(
508 TwoFactorFault::AuthDisabled,
509 )];
510 }
511 if has_key {
512 Vec::new()
513 } else {
514 vec![DeclarationErrorKind::TwoFactor(TwoFactorFault::MissingKey)]
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
524 fn a_recovery_code_matches_however_it_is_typed() {
525 let code = recovery_code();
526 assert_eq!(code.len(), 11);
527 let read = normalize_recovery_code(&code);
528 assert_eq!(normalize_recovery_code(&code.to_uppercase()), read);
529 assert_eq!(normalize_recovery_code(&code.replace('-', " ")), read);
530 assert_ne!(normalize_recovery_code(&recovery_code()), read);
531 }
532}