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::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: bool,
44    email: bool,
45    throttle: LoginThrottle,
46    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: 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    /// 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 = true;
67        self
68    }
69
70    /// Lets a user with an address choose codes mailed to it in place of an authenticator app:
71    /// each sign-in mails one, through [`Auth::mail`](super::Auth::mail), which mounting then
72    /// requires. A mailed code works once, for ten minutes.
73    #[must_use]
74    pub fn email_codes(mut self) -> Self {
75        self.email = true;
76        self
77    }
78
79    /// How many codes one account may have mailed in a window; defaults to three in fifteen
80    /// minutes.
81    #[must_use]
82    pub fn mail_throttle(mut self, throttle: LoginThrottle) -> Self {
83        self.mail_throttle = throttle;
84        self
85    }
86
87    /// How many codes one account may try in a window, from any client, at sign-in and on the
88    /// settings page together; defaults to five a minute.
89    #[must_use]
90    pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
91        self.throttle = throttle;
92        self
93    }
94}
95
96impl TwoFactor {
97    /// Whether the panel mails codes.
98    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/// Shipped second-factor record: an account's authenticator secret, sealed, or the hash of one of
110/// its recovery codes, on the panel that enrolled it.
111#[derive(Debug, Clone, toasty::Model)]
112pub struct AuthFactor {
113    #[key]
114    #[auto]
115    pub id: uuid::Uuid,
116    /// [`PanelUser::user_id`](super::PanelUser::user_id) of the account the factor signs in.
117    #[index]
118    pub user_id: String,
119    /// The prefix of the panel that enrolled the factor: the only panel it signs in to.
120    pub panel: String,
121    pub kind: FactorKind,
122    /// The authenticator secret, sealed under the router's cookie key, or the hash of a recovery
123    /// code, keyed under it.
124    pub secret: String,
125    /// When a code proved the authenticator app holds the secret; `None` while it is being set
126    /// up. A recovery code is confirmed when issued.
127    pub confirmed_at: Option<Timestamp>,
128    /// The last 30-second step an authenticator code signed in with: a code works once.
129    pub last_step: Option<i64>,
130    /// The wrong codes in a row signing in since the last right one, on the confirmed factor.
131    pub failures: i64,
132    /// Until when signing in refuses the confirmed factor's codes after
133    /// [`failures`](Self::failures) wrong ones; a recovery code still works.
134    pub retry_at: Option<Timestamp>,
135    pub created_at: Timestamp,
136}
137
138/// What an [`AuthFactor`] is. Stored as its `snake_case` name in a text column, so a kind a later
139/// release adds needs no migration.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
141#[column(type = text)]
142#[non_exhaustive]
143pub enum FactorKind {
144    /// An authenticator app's RFC 6238 secret.
145    Authenticator,
146    /// A single-use recovery code, for an account that lost its authenticator.
147    RecoveryCode,
148    /// Codes mailed to the account's address, one per sign-in; the row holds no secret.
149    EmailCode,
150}
151
152/// How many recovery codes a set-up or a regeneration issues.
153const RECOVERY_CODES: usize = 8;
154
155/// The characters a recovery code draws from: lowercase base32, which reads without ambiguity.
156const RECOVERY_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
157
158/// The router's cookie key, which seals authenticator secrets.
159fn 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
164/// What a sealed secret is bound to: its account on its panel.
165fn binding(user_id: &str, panel: &str) -> String {
166    format!("{user_id}\n{panel}")
167}
168
169/// Every factor `user_id` holds on the panel at `panel`, read through `ex`.
170async 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/// What `user_id` holds on the request's panel: the confirmed factor, an authenticator or mailed
187/// codes, the one being set up, and how many recovery codes are left.
188#[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
195/// `user_id`'s [`Enrolment`] on the request's panel.
196pub(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
204/// `user_id`'s [`Enrolment`] on the panel at `panel`.
205async 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
221/// The kind of second factor `user` signs in to the request's panel with, if any.
222pub(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
230/// Whether the request's panel offers the signed-in user two-factor sign-in.
231pub(crate) fn offered(cx: &Cx) -> bool {
232    super::signed(cx).is_some_and(|signed| signed.realm.auth.two_factor_config().is_some())
233}
234
235/// Whether `panel` sends `user` to set up an authenticator before anything else: it requires one,
236/// and the user holds none.
237pub(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
252/// Starts setting up a factor of `kind` holding `secret` for `user_id`, unconfirmed, in place of
253/// any factor set up before and not confirmed.
254async 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    // A recovery code is confirmed when issued: only a factor being set up matches.
263    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
291/// Starts setting up an authenticator for `user_id`: a new secret, sealed. Answers the secret,
292/// for the QR code.
293async 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
304/// The secret `factor` seals, or `None` when the router's key no longer opens it.
305fn 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
313/// Whether `code` is the current code of `factor`'s authenticator, not used before; a match
314/// records its step through `ex`, so the code works once.
315async 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
342/// A recovery code as typed, read the way it was issued: lowercase, without separators.
343fn 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
350/// A recovery code as `user_id`'s row on `panel` stores it: keyed under the router's cookie key.
351fn 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
359/// Whether `code` is one of `user_id`'s recovery codes on the request's panel; a match is deleted
360/// through `ex`, so each works once.
361async 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
384/// Whether `code` is a code of `factor`, used up: its authenticator's current one, or the one
385/// last mailed for it.
386async 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
399/// Whether `code` reads as a code of the factor rather than a recovery code: apps show a code as
400/// "123 456"; a recovery code is ten characters, so six digits are a code.
401fn 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
406/// Whether `code` proves `user_id` holds their second factor on the request's panel: a code of
407/// their confirmed factor, or one of their recovery codes, which it uses up.
408pub(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
427/// Fresh recovery codes for `user_id` on the request's panel, in place of any earlier ones.
428/// Answers the codes, which are shown once: only their hashes are stored.
429async 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
469/// A random recovery code: ten characters of [`RECOVERY_ALPHABET`], in two groups of five.
470fn recovery_code() -> String {
471    let mut bytes = [0u8; 10];
472    getrandom::fill(&mut bytes).expect("the recovery code randomness source failed");
473    // 256 is a multiple of 32, so the low five bits are uniform.
474    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
481/// Deletes every factor `user_id` holds on the request's panel, through `ex`.
482async 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
495/// The mistakes in a panel's [`Auth::two_factor`](super::Auth::two_factor): two-factor sign-in
496/// without auth, and a router without the cookie key that seals the secrets.
497pub(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    /// A recovery code reads the same however it is typed, and two codes differ.
523    #[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}