Skip to main content

tablo_core/auth/
two_factor.rs

1//! Two-factor sign-in: the [`TwoFactor`] a panel installs, the [`AuthFactor`] rows that hold each
2//! account's authenticator secret and recovery codes, and the checks the challenge and settings
3//! pages run against them.
4
5mod 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/// A panel's two-factor sign-in, installed with [`Auth::two_factor`](super::Auth::two_factor):
31/// each signed-in user may add an authenticator app, or codes by email when the panel mails them,
32/// at `{prefix}/two-factor`, and an account that has one signs in with a code from it after its
33/// password.
34///
35/// ```rust
36/// use tablo_core::{Auth, TwoFactor};
37///
38/// let auth = Auth::password().two_factor(TwoFactor::new().required());
39/// # let _ = auth;
40/// ```
41#[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    /// Two-factor sign-in each user may turn on.
51    #[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    /// Sends a signed-in user without a second factor to `{prefix}/two-factor` before anything
62    /// else: a page answers with a redirect to it and any other request with 403, after an
63    /// unverified address.
64    #[must_use]
65    pub fn required(mut self) -> Self {
66        self.required = Requirement::Everyone;
67        self
68    }
69
70    /// Requires a second factor of the users `rule` answers `true` for, as
71    /// [`required`](Self::required) does of every user, and leaves it optional for the others:
72    /// staff, say, or the members of a tenant that asks it of them.
73    ///
74    /// The rule is asked on every request, so a user it starts to cover is sent to set up a
75    /// factor at their next one. A user of another type than `U` is required one: the panel
76    /// signs in no such user, so the rule cannot have meant to let them through.
77    ///
78    /// ```rust
79    /// use tablo_core::{Auth, TwoFactor, auth::AdminUser};
80    ///
81    /// let auth = Auth::password().two_factor(
82    ///     TwoFactor::new()
83    ///         .required_for(|_cx, user: &AdminUser| user.email.ends_with("@staff.example")),
84    /// );
85    /// # let _ = auth;
86    /// ```
87    #[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    /// Lets a user with an address choose codes mailed to it in place of an authenticator app:
100    /// each sign-in mails one, through [`Auth::mail`](super::Auth::mail), which mounting then
101    /// requires. A mailed code works once, for ten minutes.
102    #[must_use]
103    pub fn email_codes(mut self) -> Self {
104        self.email = true;
105        self
106    }
107
108    /// How many codes one account may have mailed in a window; defaults to three in fifteen
109    /// minutes.
110    #[must_use]
111    pub fn mail_throttle(mut self, throttle: LoginThrottle) -> Self {
112        self.mail_throttle = throttle;
113        self
114    }
115
116    /// How many codes one account may try in a window, from any client, at sign-in and on the
117    /// settings page together; defaults to five a minute.
118    #[must_use]
119    pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
120        self.throttle = throttle;
121        self
122    }
123}
124
125impl TwoFactor {
126    /// Whether the panel asks `user` for a second factor before anything else.
127    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    /// Whether the panel mails codes.
136    pub(in crate::auth) fn mails(&self) -> bool {
137        self.email
138    }
139}
140
141/// The rule of [`TwoFactor::required_for`], with its user type erased.
142type Rule = dyn Fn(&Cx, &dyn PanelUser) -> bool + Send + Sync;
143
144/// Whom a [`TwoFactor`] requires a second factor of.
145enum 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/// Shipped second-factor record: an account's authenticator secret, sealed, or the hash of one of
168/// its recovery codes, on the panel that enrolled it.
169#[derive(Debug, Clone, toasty::Model)]
170pub struct AuthFactor {
171    #[key]
172    #[auto]
173    pub id: uuid::Uuid,
174    /// [`PanelUser::user_id`](super::PanelUser::user_id) of the account the factor signs in.
175    #[index]
176    pub user_id: String,
177    /// The prefix of the panel that enrolled the factor: the only panel it signs in to.
178    pub panel: String,
179    pub kind: FactorKind,
180    /// The authenticator secret, sealed under the router's cookie key, or the hash of a recovery
181    /// code, keyed under it.
182    pub secret: String,
183    /// When a code proved the authenticator app holds the secret; `None` while it is being set
184    /// up. A recovery code is confirmed when issued.
185    pub confirmed_at: Option<Timestamp>,
186    /// The last 30-second step an authenticator code signed in with: a code works once.
187    pub last_step: Option<i64>,
188    /// The wrong codes in a row signing in since the last right one, on the confirmed factor.
189    pub failures: i64,
190    /// Until when signing in refuses the confirmed factor's codes after
191    /// [`failures`](Self::failures) wrong ones; a recovery code still works.
192    pub retry_at: Option<Timestamp>,
193    pub created_at: Timestamp,
194}
195
196/// What an [`AuthFactor`] is. Stored as its `snake_case` name in a text column, so a kind a later
197/// release adds needs no migration.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
199#[column(type = text)]
200#[non_exhaustive]
201pub enum FactorKind {
202    /// An authenticator app's RFC 6238 secret.
203    Authenticator,
204    /// A single-use recovery code, for an account that lost its authenticator.
205    RecoveryCode,
206    /// Codes mailed to the account's address, one per sign-in; the row holds no secret.
207    EmailCode,
208}
209
210/// How many recovery codes a set-up or a regeneration issues.
211const RECOVERY_CODES: usize = 8;
212
213/// The characters a recovery code draws from: lowercase base32, which reads without ambiguity.
214const RECOVERY_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
215
216/// The router's cookie key, which seals authenticator secrets.
217fn 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
222/// What a sealed secret is bound to: its account on its panel.
223fn binding(user_id: &str, panel: &str) -> String {
224    format!("{user_id}\n{panel}")
225}
226
227/// Every factor `user_id` holds on the panel at `panel`, read through `ex`.
228async 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/// What `user_id` holds on the request's panel: the confirmed factor, an authenticator or mailed
245/// codes, the one being set up, and how many recovery codes are left.
246#[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
253/// `user_id`'s [`Enrolment`] on the request's panel.
254pub(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
262/// `user_id`'s [`Enrolment`] on the panel at `panel`.
263async 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
279/// The kind of second factor `user` signs in to the request's panel with, if any.
280pub(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
288/// Whether the request's panel offers the signed-in user two-factor sign-in.
289pub(crate) fn offered(cx: &Cx) -> bool {
290    super::signed(cx).is_some_and(|signed| signed.realm.auth.two_factor_config().is_some())
291}
292
293/// Whether `panel` sends `user` to set up an authenticator before anything else: it requires one
294/// of them, and the user holds none.
295pub(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
310/// Starts setting up a factor of `kind` holding `secret` for `user_id`, unconfirmed, in place of
311/// any factor set up before and not confirmed.
312async 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    // A recovery code is confirmed when issued: only a factor being set up matches.
321    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
349/// Starts setting up an authenticator for `user_id`: a new secret, sealed. Answers the secret,
350/// for the QR code.
351async 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
362/// The secret `factor` seals, or `None` when the router's key no longer opens it.
363fn 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
371/// Whether `code` is the current code of `factor`'s authenticator, not used before; a match
372/// records its step through `ex`, so the code works once.
373async 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
400/// A recovery code as typed, read the way it was issued: lowercase, without separators.
401fn 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
408/// A recovery code as `user_id`'s row on `panel` stores it: keyed under the router's cookie key.
409fn 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
417/// Whether `code` is one of `user_id`'s recovery codes on the request's panel; a match is deleted
418/// through `ex`, so each works once.
419async 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
442/// Whether `code` is a code of `factor`, used up: its authenticator's current one, or the one
443/// last mailed for it.
444async 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
457/// Whether `code` reads as a code of the factor rather than a recovery code: apps show a code as
458/// "123 456"; a recovery code is ten characters, so six digits are a code.
459fn 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
464/// Whether `code` proves `user_id` holds their second factor on the request's panel: a code of
465/// their confirmed factor, or one of their recovery codes, which it uses up.
466pub(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
485/// Fresh recovery codes for `user_id` on the request's panel, in place of any earlier ones.
486/// Answers the codes, which are shown once: only their hashes are stored.
487async 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
527/// A random recovery code: ten characters of [`RECOVERY_ALPHABET`], in two groups of five.
528fn recovery_code() -> String {
529    let mut bytes = [0u8; 10];
530    getrandom::fill(&mut bytes).expect("the recovery code randomness source failed");
531    // 256 is a multiple of 32, so the low five bits are uniform.
532    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
539/// Deletes every factor `user_id` holds on the request's panel, through `ex`.
540async 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
553/// The mistakes in a panel's [`Auth::two_factor`](super::Auth::two_factor): two-factor sign-in
554/// without auth, and a router without the cookie key that seals the secrets.
555pub(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    /// A recovery code reads the same however it is typed, and two codes differ.
581    #[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}