1mod backoff;
6mod challenge;
7mod cipher;
8mod email;
9mod pages;
10mod settings;
11mod totp;
12
13use std::{any::Any, sync::Arc, 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: Requirement,
44 email: bool,
45 pub(in crate::auth) throttle: LoginThrottle,
46 pub(in crate::auth) mail_throttle: LoginThrottle,
47}
48
49impl TwoFactor {
50 #[must_use]
52 pub fn new() -> Self {
53 Self {
54 required: Requirement::Nobody,
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 = Requirement::Everyone;
67 self
68 }
69
70 #[must_use]
88 pub fn required_for<U: PanelUser>(
89 mut self,
90 rule: impl Fn(&Cx, &U) -> bool + Send + Sync + 'static,
91 ) -> Self {
92 self.required = Requirement::Some(Arc::new(move |cx, user| {
93 let user: &dyn Any = user;
94 user.downcast_ref::<U>().is_none_or(|user| rule(cx, user))
95 }));
96 self
97 }
98
99 #[must_use]
103 pub fn email_codes(mut self) -> Self {
104 self.email = true;
105 self
106 }
107
108 #[must_use]
111 pub fn mail_throttle(mut self, throttle: LoginThrottle) -> Self {
112 self.mail_throttle = throttle;
113 self
114 }
115
116 #[must_use]
119 pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
120 self.throttle = throttle;
121 self
122 }
123}
124
125impl TwoFactor {
126 fn requires(&self, cx: &Cx, user: &dyn PanelUser) -> bool {
128 match &self.required {
129 Requirement::Nobody => false,
130 Requirement::Everyone => true,
131 Requirement::Some(rule) => rule(cx, user),
132 }
133 }
134
135 pub(in crate::auth) fn mails(&self) -> bool {
137 self.email
138 }
139}
140
141type Rule = dyn Fn(&Cx, &dyn PanelUser) -> bool + Send + Sync;
143
144enum Requirement {
146 Nobody,
147 Everyone,
148 Some(Arc<Rule>),
149}
150
151impl std::fmt::Debug for Requirement {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.write_str(match self {
154 Self::Nobody => "Nobody",
155 Self::Everyone => "Everyone",
156 Self::Some(_) => "Some(..)",
157 })
158 }
159}
160
161impl Default for TwoFactor {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167#[derive(Debug, Clone, toasty::Model)]
170pub struct AuthFactor {
171 #[key]
172 #[auto]
173 pub id: uuid::Uuid,
174 #[index]
176 pub user_id: String,
177 pub panel: String,
179 pub kind: FactorKind,
180 pub secret: String,
183 pub confirmed_at: Option<Timestamp>,
186 pub last_step: Option<i64>,
188 pub failures: i64,
190 pub retry_at: Option<Timestamp>,
193 pub created_at: Timestamp,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
199#[column(type = text)]
200#[non_exhaustive]
201pub enum FactorKind {
202 Authenticator,
204 RecoveryCode,
206 EmailCode,
208}
209
210const RECOVERY_CODES: usize = 8;
212
213const RECOVERY_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
215
216fn key(cx: &Cx) -> topcoat::Result<&Key> {
218 try_context::<Key>(cx)
219 .ok_or_else(|| crate::error::declaration("two-factor sign-in needs the router's `Key`"))
220}
221
222fn binding(user_id: &str, panel: &str) -> String {
224 format!("{user_id}\n{panel}")
225}
226
227async fn factors(
229 ex: &mut dyn toasty::Executor,
230 panel: &str,
231 user_id: &str,
232) -> topcoat::Result<Vec<AuthFactor>> {
233 AuthFactor::filter(
234 AuthFactor::fields()
235 .user_id()
236 .eq(user_id.to_string())
237 .and(AuthFactor::fields().panel().eq(panel.to_string())),
238 )
239 .exec(ex)
240 .await
241 .map_err(infrastructure_failure)
242}
243
244#[derive(Debug, Default)]
247pub(super) struct Enrolment {
248 pub(super) confirmed: Option<AuthFactor>,
249 pub(super) unconfirmed: Option<AuthFactor>,
250 pub(super) recovery_codes: usize,
251}
252
253pub(super) async fn enrolment(
255 cx: &Cx,
256 ex: &mut dyn toasty::Executor,
257 user_id: &str,
258) -> topcoat::Result<Enrolment> {
259 enrolment_on(ex, &panel_prefix(cx), user_id).await
260}
261
262async fn enrolment_on(
264 ex: &mut dyn toasty::Executor,
265 panel: &str,
266 user_id: &str,
267) -> topcoat::Result<Enrolment> {
268 let mut enrolment = Enrolment::default();
269 for factor in factors(ex, panel, user_id).await? {
270 match (factor.kind, factor.confirmed_at.is_some()) {
271 (FactorKind::RecoveryCode, _) => enrolment.recovery_codes += 1,
272 (_, true) => enrolment.confirmed = Some(factor),
273 (_, false) => enrolment.unconfirmed = Some(factor),
274 }
275 }
276 Ok(enrolment)
277}
278
279pub(super) async fn enrolled(cx: &Cx, user: &dyn PanelUser) -> topcoat::Result<Option<FactorKind>> {
281 let mut db = crate::db::db(cx)?;
282 Ok(enrolment(cx, &mut db, &user.user_id())
283 .await?
284 .confirmed
285 .map(|factor| factor.kind))
286}
287
288pub(crate) fn offered(cx: &Cx) -> bool {
290 super::signed(cx).is_some_and(|signed| signed.realm.auth.two_factor_config().is_some())
291}
292
293pub(super) async fn setup_pending(
296 cx: &Cx,
297 panel: &Realm,
298 user: &dyn PanelUser,
299) -> topcoat::Result<bool> {
300 match panel.auth.two_factor_config() {
301 Some(two_factor) if two_factor.requires(cx, user) => {
302 let mut db = crate::db::db(cx)?;
303 let enrolment = enrolment_on(&mut db, &panel.prefix, &user.user_id()).await?;
304 Ok(enrolment.confirmed.is_none())
305 }
306 _ => Ok(false),
307 }
308}
309
310async fn start(
313 cx: &Cx,
314 ex: &mut dyn toasty::Executor,
315 user_id: &str,
316 kind: FactorKind,
317 secret: String,
318) -> topcoat::Result<()> {
319 let panel = panel_prefix(cx);
320 AuthFactor::filter(
322 AuthFactor::fields()
323 .user_id()
324 .eq(user_id.to_string())
325 .and(AuthFactor::fields().panel().eq(panel.clone()))
326 .and(AuthFactor::fields().confirmed_at().is_none()),
327 )
328 .delete()
329 .exec(&mut *ex)
330 .await
331 .map_err(infrastructure_failure)?;
332 toasty::create!(AuthFactor {
333 user_id: user_id.to_string(),
334 panel,
335 kind,
336 secret,
337 confirmed_at: None,
338 last_step: None,
339 failures: 0,
340 retry_at: None,
341 created_at: Timestamp::now(),
342 })
343 .exec(ex)
344 .await
345 .map_err(infrastructure_failure)?;
346 Ok(())
347}
348
349async fn start_authenticator(
352 cx: &Cx,
353 ex: &mut dyn toasty::Executor,
354 user_id: &str,
355) -> topcoat::Result<Vec<u8>> {
356 let secret = totp::secret();
357 let sealed = cipher::seal(key(cx)?, &secret, &binding(user_id, &panel_prefix(cx)));
358 start(cx, ex, user_id, FactorKind::Authenticator, sealed).await?;
359 Ok(secret.to_vec())
360}
361
362fn secret(cx: &Cx, factor: &AuthFactor) -> topcoat::Result<Option<Vec<u8>>> {
364 Ok(cipher::open(
365 key(cx)?,
366 &factor.secret,
367 &binding(&factor.user_id, &factor.panel),
368 ))
369}
370
371async fn accept_code(
374 cx: &Cx,
375 ex: &mut dyn toasty::Executor,
376 factor: &AuthFactor,
377 code: &str,
378) -> topcoat::Result<bool> {
379 let Some(secret) = secret(cx, factor)? else {
380 tracing::error!(panel = %factor.panel, "a two-factor secret does not open under the key");
381 return Ok(false);
382 };
383 let now = std::time::SystemTime::now()
384 .duration_since(std::time::UNIX_EPOCH)
385 .map_err(|_| crate::error::declaration("the clock is before 1970"))?
386 .as_secs();
387 let used = factor.last_step.and_then(|step| u64::try_from(step).ok());
388 let Some(step) = totp::verify(&secret, code, totp::step(now), used) else {
389 return Ok(false);
390 };
391 AuthFactor::filter(AuthFactor::fields().id().eq(factor.id))
392 .update()
393 .last_step(i64::try_from(step).ok())
394 .exec(ex)
395 .await
396 .map_err(infrastructure_failure)?;
397 Ok(true)
398}
399
400fn normalize_recovery_code(code: &str) -> String {
402 code.chars()
403 .filter(char::is_ascii_alphanumeric)
404 .map(|c| c.to_ascii_lowercase())
405 .collect()
406}
407
408fn recovery_hash(cx: &Cx, user_id: &str, panel: &str, code: &str) -> topcoat::Result<String> {
410 Ok(cipher::recovery_hash(
411 key(cx)?,
412 &binding(user_id, panel),
413 &normalize_recovery_code(code),
414 ))
415}
416
417async fn spend_recovery_code(
420 cx: &Cx,
421 ex: &mut dyn toasty::Executor,
422 user_id: &str,
423 code: &str,
424) -> topcoat::Result<bool> {
425 let panel = panel_prefix(cx);
426 let hash = recovery_hash(cx, user_id, &panel, code)?;
427 let codes = factors(&mut *ex, &panel, user_id).await?;
428 let Some(matched) = codes.iter().find(|factor| {
429 factor.kind == FactorKind::RecoveryCode
430 && bool::from(factor.secret.as_bytes().ct_eq(hash.as_bytes()))
431 }) else {
432 return Ok(false);
433 };
434 AuthFactor::filter(AuthFactor::fields().id().eq(matched.id))
435 .delete()
436 .exec(ex)
437 .await
438 .map_err(infrastructure_failure)?;
439 Ok(true)
440}
441
442async fn accept_factor(
445 cx: &Cx,
446 ex: &mut dyn toasty::Executor,
447 factor: &AuthFactor,
448 code: &str,
449) -> topcoat::Result<bool> {
450 match factor.kind {
451 FactorKind::Authenticator => accept_code(cx, ex, factor, code).await,
452 FactorKind::EmailCode => email::accept(cx, ex, &factor.user_id, code).await,
453 FactorKind::RecoveryCode => Ok(false),
454 }
455}
456
457fn factor_code(code: &str) -> Option<String> {
460 let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
461 (code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code)
462}
463
464pub(super) async fn accept(
467 cx: &Cx,
468 ex: &mut dyn toasty::Executor,
469 user_id: &str,
470 code: &str,
471) -> topcoat::Result<bool> {
472 let enrolment = enrolment(cx, &mut *ex, user_id).await?;
473 let Some(factor) = enrolment.confirmed else {
474 return Ok(false);
475 };
476 match factor_code(code) {
477 Some(code) => accept_factor(cx, ex, &factor, &code).await,
478 None => {
479 let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
480 spend_recovery_code(cx, ex, user_id, &code).await
481 }
482 }
483}
484
485async fn issue_recovery_codes(
488 cx: &Cx,
489 ex: &mut dyn toasty::Executor,
490 user_id: &str,
491) -> topcoat::Result<Vec<String>> {
492 let panel = panel_prefix(cx);
493 AuthFactor::filter(
494 AuthFactor::fields()
495 .user_id()
496 .eq(user_id.to_string())
497 .and(AuthFactor::fields().panel().eq(panel.clone()))
498 .and(AuthFactor::fields().kind().eq(FactorKind::RecoveryCode)),
499 )
500 .delete()
501 .exec(&mut *ex)
502 .await
503 .map_err(infrastructure_failure)?;
504 let mut codes = Vec::with_capacity(RECOVERY_CODES);
505 let now = Timestamp::now();
506 for _ in 0..RECOVERY_CODES {
507 let code = recovery_code();
508 toasty::create!(AuthFactor {
509 user_id: user_id.to_string(),
510 panel: panel.clone(),
511 kind: FactorKind::RecoveryCode,
512 secret: recovery_hash(cx, user_id, &panel, &code)?,
513 confirmed_at: Some(now),
514 last_step: None,
515 failures: 0,
516 retry_at: None,
517 created_at: now,
518 })
519 .exec(&mut *ex)
520 .await
521 .map_err(infrastructure_failure)?;
522 codes.push(code);
523 }
524 Ok(codes)
525}
526
527fn recovery_code() -> String {
529 let mut bytes = [0u8; 10];
530 getrandom::fill(&mut bytes).expect("the recovery code randomness source failed");
531 let chars: String = bytes
533 .iter()
534 .map(|byte| char::from(RECOVERY_ALPHABET[usize::from(byte & 31)]))
535 .collect();
536 format!("{}-{}", &chars[..5], &chars[5..])
537}
538
539async fn remove_all(cx: &Cx, ex: &mut dyn toasty::Executor, user_id: &str) -> topcoat::Result<()> {
541 AuthFactor::filter(
542 AuthFactor::fields()
543 .user_id()
544 .eq(user_id.to_string())
545 .and(AuthFactor::fields().panel().eq(panel_prefix(cx))),
546 )
547 .delete()
548 .exec(ex)
549 .await
550 .map_err(infrastructure_failure)
551}
552
553pub(super) fn declaration_errors(
556 auth: &super::AuthConfig,
557 has_key: bool,
558) -> Vec<crate::DeclarationErrorKind> {
559 use crate::{DeclarationErrorKind, declaration::TwoFactorFault};
560
561 if auth.two_factor.is_none() {
562 return Vec::new();
563 }
564 if auth.authenticator().is_none() {
565 return vec![DeclarationErrorKind::TwoFactor(
566 TwoFactorFault::AuthDisabled,
567 )];
568 }
569 if has_key {
570 Vec::new()
571 } else {
572 vec![DeclarationErrorKind::TwoFactor(TwoFactorFault::MissingKey)]
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
582 fn a_recovery_code_matches_however_it_is_typed() {
583 let code = recovery_code();
584 assert_eq!(code.len(), 11);
585 let read = normalize_recovery_code(&code);
586 assert_eq!(normalize_recovery_code(&code.to_uppercase()), read);
587 assert_eq!(normalize_recovery_code(&code.replace('-', " ")), read);
588 assert_ne!(normalize_recovery_code(&recovery_code()), read);
589 }
590}