tablo-core 0.10.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
//! Two-factor sign-in: the [`TwoFactor`] a panel installs, the [`AuthFactor`] rows that hold each
//! account's authenticator secret and recovery codes, and the checks the challenge and settings
//! pages run against them.

mod backoff;
mod challenge;
mod cipher;
mod email;
mod pages;
mod settings;
mod totp;

use std::{any::Any, sync::Arc, time::Duration};

use jiff::Timestamp;
use subtle::ConstantTimeEq;
use topcoat::{
    context::{Cx, try_context},
    cookie::Key,
};

pub(crate) use self::{challenge::challenge_url, settings::settings_url};
pub(super) use self::{
    challenge::{begin as begin_challenge, challenge_page, challenge_post},
    settings::{settings_page, settings_post},
};
use super::{LoginThrottle, PanelUser, infrastructure_failure};
use crate::auth::realm::{Realm, panel_prefix};

/// A panel's two-factor sign-in, installed with [`Auth::two_factor`](super::Auth::two_factor):
/// each signed-in user may add an authenticator app, or codes by email when the panel mails them,
/// at `{prefix}/two-factor`, and an account that has one signs in with a code from it after its
/// password.
///
/// ```rust
/// use tablo_core::{Auth, TwoFactor};
///
/// let auth = Auth::password().two_factor(TwoFactor::new().required());
/// # let _ = auth;
/// ```
#[derive(Debug)]
pub struct TwoFactor {
    required: Requirement,
    email: bool,
    pub(in crate::auth) throttle: LoginThrottle,
    pub(in crate::auth) mail_throttle: LoginThrottle,
}

impl TwoFactor {
    /// Two-factor sign-in each user may turn on.
    #[must_use]
    pub fn new() -> Self {
        Self {
            required: Requirement::Nobody,
            email: false,
            throttle: LoginThrottle::new(5, Duration::from_mins(1)),
            mail_throttle: LoginThrottle::new(3, Duration::from_mins(15)),
        }
    }

    /// Sends a signed-in user without a second factor to `{prefix}/two-factor` before anything
    /// else: a page answers with a redirect to it and any other request with 403, after an
    /// unverified address.
    #[must_use]
    pub fn required(mut self) -> Self {
        self.required = Requirement::Everyone;
        self
    }

    /// Requires a second factor of the users `rule` answers `true` for, as
    /// [`required`](Self::required) does of every user, and leaves it optional for the others:
    /// staff, say, or the members of a tenant that asks it of them.
    ///
    /// The rule is asked on every request, so a user it starts to cover is sent to set up a
    /// factor at their next one. A user of another type than `U` is required one: the panel
    /// signs in no such user, so the rule cannot have meant to let them through.
    ///
    /// ```rust
    /// use tablo_core::{Auth, TwoFactor, auth::AdminUser};
    ///
    /// let auth = Auth::password().two_factor(
    ///     TwoFactor::new()
    ///         .required_for(|_cx, user: &AdminUser| user.email.ends_with("@staff.example")),
    /// );
    /// # let _ = auth;
    /// ```
    #[must_use]
    pub fn required_for<U: PanelUser>(
        mut self,
        rule: impl Fn(&Cx, &U) -> bool + Send + Sync + 'static,
    ) -> Self {
        self.required = Requirement::Some(Arc::new(move |cx, user| {
            let user: &dyn Any = user;
            user.downcast_ref::<U>().is_none_or(|user| rule(cx, user))
        }));
        self
    }

    /// Lets a user with an address choose codes mailed to it in place of an authenticator app:
    /// each sign-in mails one, through [`Auth::mail`](super::Auth::mail), which mounting then
    /// requires. A mailed code works once, for ten minutes.
    #[must_use]
    pub fn email_codes(mut self) -> Self {
        self.email = true;
        self
    }

    /// How many codes one account may have mailed in a window; defaults to three in fifteen
    /// minutes.
    #[must_use]
    pub fn mail_throttle(mut self, throttle: LoginThrottle) -> Self {
        self.mail_throttle = throttle;
        self
    }

    /// How many codes one account may try in a window, from any client, at sign-in and on the
    /// settings page together; defaults to five a minute.
    #[must_use]
    pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
        self.throttle = throttle;
        self
    }
}

impl TwoFactor {
    /// Whether the panel asks `user` for a second factor before anything else.
    fn requires(&self, cx: &Cx, user: &dyn PanelUser) -> bool {
        match &self.required {
            Requirement::Nobody => false,
            Requirement::Everyone => true,
            Requirement::Some(rule) => rule(cx, user),
        }
    }

    /// Whether the panel mails codes.
    pub(in crate::auth) fn mails(&self) -> bool {
        self.email
    }
}

/// The rule of [`TwoFactor::required_for`], with its user type erased.
type Rule = dyn Fn(&Cx, &dyn PanelUser) -> bool + Send + Sync;

/// Whom a [`TwoFactor`] requires a second factor of.
enum Requirement {
    Nobody,
    Everyone,
    Some(Arc<Rule>),
}

impl std::fmt::Debug for Requirement {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::Nobody => "Nobody",
            Self::Everyone => "Everyone",
            Self::Some(_) => "Some(..)",
        })
    }
}

impl Default for TwoFactor {
    fn default() -> Self {
        Self::new()
    }
}

/// Shipped second-factor record: an account's authenticator secret, sealed, or the hash of one of
/// its recovery codes, on the panel that enrolled it.
#[derive(Debug, Clone, toasty::Model)]
pub struct AuthFactor {
    #[key]
    #[auto]
    pub id: uuid::Uuid,
    /// [`PanelUser::user_id`](super::PanelUser::user_id) of the account the factor signs in.
    #[index]
    pub user_id: String,
    /// The prefix of the panel that enrolled the factor: the only panel it signs in to.
    pub panel: String,
    pub kind: FactorKind,
    /// The authenticator secret, sealed under the router's cookie key, or the hash of a recovery
    /// code, keyed under it.
    pub secret: String,
    /// When a code proved the authenticator app holds the secret; `None` while it is being set
    /// up. A recovery code is confirmed when issued.
    pub confirmed_at: Option<Timestamp>,
    /// The last 30-second step an authenticator code signed in with: a code works once.
    pub last_step: Option<i64>,
    /// The wrong codes in a row signing in since the last right one, on the confirmed factor.
    pub failures: i64,
    /// Until when signing in refuses the confirmed factor's codes after
    /// [`failures`](Self::failures) wrong ones; a recovery code still works.
    pub retry_at: Option<Timestamp>,
    pub created_at: Timestamp,
}

/// What an [`AuthFactor`] is. Stored as its `snake_case` name in a text column, so a kind a later
/// release adds needs no migration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
#[column(type = text)]
#[non_exhaustive]
pub enum FactorKind {
    /// An authenticator app's RFC 6238 secret.
    Authenticator,
    /// A single-use recovery code, for an account that lost its authenticator.
    RecoveryCode,
    /// Codes mailed to the account's address, one per sign-in; the row holds no secret.
    EmailCode,
}

/// How many recovery codes a set-up or a regeneration issues.
const RECOVERY_CODES: usize = 8;

/// The characters a recovery code draws from: lowercase base32, which reads without ambiguity.
const RECOVERY_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";

/// The router's cookie key, which seals authenticator secrets.
fn key(cx: &Cx) -> topcoat::Result<&Key> {
    try_context::<Key>(cx)
        .ok_or_else(|| crate::error::declaration("two-factor sign-in needs the router's `Key`"))
}

/// What a sealed secret is bound to: its account on its panel.
fn binding(user_id: &str, panel: &str) -> String {
    format!("{user_id}\n{panel}")
}

/// Every factor `user_id` holds on the panel at `panel`, read through `ex`.
async fn factors(
    ex: &mut dyn toasty::Executor,
    panel: &str,
    user_id: &str,
) -> topcoat::Result<Vec<AuthFactor>> {
    AuthFactor::filter(
        AuthFactor::fields()
            .user_id()
            .eq(user_id.to_string())
            .and(AuthFactor::fields().panel().eq(panel.to_string())),
    )
    .exec(ex)
    .await
    .map_err(infrastructure_failure)
}

/// What `user_id` holds on the request's panel: the confirmed factor, an authenticator or mailed
/// codes, the one being set up, and how many recovery codes are left.
#[derive(Debug, Default)]
pub(super) struct Enrolment {
    pub(super) confirmed: Option<AuthFactor>,
    pub(super) unconfirmed: Option<AuthFactor>,
    pub(super) recovery_codes: usize,
}

/// `user_id`'s [`Enrolment`] on the request's panel.
pub(super) async fn enrolment(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    user_id: &str,
) -> topcoat::Result<Enrolment> {
    enrolment_on(ex, &panel_prefix(cx), user_id).await
}

/// `user_id`'s [`Enrolment`] on the panel at `panel`.
async fn enrolment_on(
    ex: &mut dyn toasty::Executor,
    panel: &str,
    user_id: &str,
) -> topcoat::Result<Enrolment> {
    let mut enrolment = Enrolment::default();
    for factor in factors(ex, panel, user_id).await? {
        match (factor.kind, factor.confirmed_at.is_some()) {
            (FactorKind::RecoveryCode, _) => enrolment.recovery_codes += 1,
            (_, true) => enrolment.confirmed = Some(factor),
            (_, false) => enrolment.unconfirmed = Some(factor),
        }
    }
    Ok(enrolment)
}

/// The kind of second factor `user` signs in to the request's panel with, if any.
pub(super) async fn enrolled(cx: &Cx, user: &dyn PanelUser) -> topcoat::Result<Option<FactorKind>> {
    let mut db = crate::db::db(cx)?;
    Ok(enrolment(cx, &mut db, &user.user_id())
        .await?
        .confirmed
        .map(|factor| factor.kind))
}

/// Whether the request's panel offers the signed-in user two-factor sign-in.
pub(crate) fn offered(cx: &Cx) -> bool {
    super::signed(cx).is_some_and(|signed| signed.realm.auth.two_factor_config().is_some())
}

/// Whether `panel` sends `user` to set up an authenticator before anything else: it requires one
/// of them, and the user holds none.
pub(super) async fn setup_pending(
    cx: &Cx,
    panel: &Realm,
    user: &dyn PanelUser,
) -> topcoat::Result<bool> {
    match panel.auth.two_factor_config() {
        Some(two_factor) if two_factor.requires(cx, user) => {
            let mut db = crate::db::db(cx)?;
            let enrolment = enrolment_on(&mut db, &panel.prefix, &user.user_id()).await?;
            Ok(enrolment.confirmed.is_none())
        }
        _ => Ok(false),
    }
}

/// Starts setting up a factor of `kind` holding `secret` for `user_id`, unconfirmed, in place of
/// any factor set up before and not confirmed.
async fn start(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    user_id: &str,
    kind: FactorKind,
    secret: String,
) -> topcoat::Result<()> {
    let panel = panel_prefix(cx);
    // A recovery code is confirmed when issued: only a factor being set up matches.
    AuthFactor::filter(
        AuthFactor::fields()
            .user_id()
            .eq(user_id.to_string())
            .and(AuthFactor::fields().panel().eq(panel.clone()))
            .and(AuthFactor::fields().confirmed_at().is_none()),
    )
    .delete()
    .exec(&mut *ex)
    .await
    .map_err(infrastructure_failure)?;
    toasty::create!(AuthFactor {
        user_id: user_id.to_string(),
        panel,
        kind,
        secret,
        confirmed_at: None,
        last_step: None,
        failures: 0,
        retry_at: None,
        created_at: Timestamp::now(),
    })
    .exec(ex)
    .await
    .map_err(infrastructure_failure)?;
    Ok(())
}

/// Starts setting up an authenticator for `user_id`: a new secret, sealed. Answers the secret,
/// for the QR code.
async fn start_authenticator(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    user_id: &str,
) -> topcoat::Result<Vec<u8>> {
    let secret = totp::secret();
    let sealed = cipher::seal(key(cx)?, &secret, &binding(user_id, &panel_prefix(cx)));
    start(cx, ex, user_id, FactorKind::Authenticator, sealed).await?;
    Ok(secret.to_vec())
}

/// The secret `factor` seals, or `None` when the router's key no longer opens it.
fn secret(cx: &Cx, factor: &AuthFactor) -> topcoat::Result<Option<Vec<u8>>> {
    Ok(cipher::open(
        key(cx)?,
        &factor.secret,
        &binding(&factor.user_id, &factor.panel),
    ))
}

/// Whether `code` is the current code of `factor`'s authenticator, not used before; a match
/// records its step through `ex`, so the code works once.
async fn accept_code(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    factor: &AuthFactor,
    code: &str,
) -> topcoat::Result<bool> {
    let Some(secret) = secret(cx, factor)? else {
        tracing::error!(panel = %factor.panel, "a two-factor secret does not open under the key");
        return Ok(false);
    };
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|_| crate::error::declaration("the clock is before 1970"))?
        .as_secs();
    let used = factor.last_step.and_then(|step| u64::try_from(step).ok());
    let Some(step) = totp::verify(&secret, code, totp::step(now), used) else {
        return Ok(false);
    };
    AuthFactor::filter(AuthFactor::fields().id().eq(factor.id))
        .update()
        .last_step(i64::try_from(step).ok())
        .exec(ex)
        .await
        .map_err(infrastructure_failure)?;
    Ok(true)
}

/// A recovery code as typed, read the way it was issued: lowercase, without separators.
fn normalize_recovery_code(code: &str) -> String {
    code.chars()
        .filter(char::is_ascii_alphanumeric)
        .map(|c| c.to_ascii_lowercase())
        .collect()
}

/// A recovery code as `user_id`'s row on `panel` stores it: keyed under the router's cookie key.
fn recovery_hash(cx: &Cx, user_id: &str, panel: &str, code: &str) -> topcoat::Result<String> {
    Ok(cipher::recovery_hash(
        key(cx)?,
        &binding(user_id, panel),
        &normalize_recovery_code(code),
    ))
}

/// Whether `code` is one of `user_id`'s recovery codes on the request's panel; a match is deleted
/// through `ex`, so each works once.
async fn spend_recovery_code(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    user_id: &str,
    code: &str,
) -> topcoat::Result<bool> {
    let panel = panel_prefix(cx);
    let hash = recovery_hash(cx, user_id, &panel, code)?;
    let codes = factors(&mut *ex, &panel, user_id).await?;
    let Some(matched) = codes.iter().find(|factor| {
        factor.kind == FactorKind::RecoveryCode
            && bool::from(factor.secret.as_bytes().ct_eq(hash.as_bytes()))
    }) else {
        return Ok(false);
    };
    AuthFactor::filter(AuthFactor::fields().id().eq(matched.id))
        .delete()
        .exec(ex)
        .await
        .map_err(infrastructure_failure)?;
    Ok(true)
}

/// Whether `code` is a code of `factor`, used up: its authenticator's current one, or the one
/// last mailed for it.
async fn accept_factor(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    factor: &AuthFactor,
    code: &str,
) -> topcoat::Result<bool> {
    match factor.kind {
        FactorKind::Authenticator => accept_code(cx, ex, factor, code).await,
        FactorKind::EmailCode => email::accept(cx, ex, &factor.user_id, code).await,
        FactorKind::RecoveryCode => Ok(false),
    }
}

/// Whether `code` reads as a code of the factor rather than a recovery code: apps show a code as
/// "123 456"; a recovery code is ten characters, so six digits are a code.
fn factor_code(code: &str) -> Option<String> {
    let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
    (code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code)
}

/// Whether `code` proves `user_id` holds their second factor on the request's panel: a code of
/// their confirmed factor, or one of their recovery codes, which it uses up.
pub(super) async fn accept(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    user_id: &str,
    code: &str,
) -> topcoat::Result<bool> {
    let enrolment = enrolment(cx, &mut *ex, user_id).await?;
    let Some(factor) = enrolment.confirmed else {
        return Ok(false);
    };
    match factor_code(code) {
        Some(code) => accept_factor(cx, ex, &factor, &code).await,
        None => {
            let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
            spend_recovery_code(cx, ex, user_id, &code).await
        }
    }
}

/// Fresh recovery codes for `user_id` on the request's panel, in place of any earlier ones.
/// Answers the codes, which are shown once: only their hashes are stored.
async fn issue_recovery_codes(
    cx: &Cx,
    ex: &mut dyn toasty::Executor,
    user_id: &str,
) -> topcoat::Result<Vec<String>> {
    let panel = panel_prefix(cx);
    AuthFactor::filter(
        AuthFactor::fields()
            .user_id()
            .eq(user_id.to_string())
            .and(AuthFactor::fields().panel().eq(panel.clone()))
            .and(AuthFactor::fields().kind().eq(FactorKind::RecoveryCode)),
    )
    .delete()
    .exec(&mut *ex)
    .await
    .map_err(infrastructure_failure)?;
    let mut codes = Vec::with_capacity(RECOVERY_CODES);
    let now = Timestamp::now();
    for _ in 0..RECOVERY_CODES {
        let code = recovery_code();
        toasty::create!(AuthFactor {
            user_id: user_id.to_string(),
            panel: panel.clone(),
            kind: FactorKind::RecoveryCode,
            secret: recovery_hash(cx, user_id, &panel, &code)?,
            confirmed_at: Some(now),
            last_step: None,
            failures: 0,
            retry_at: None,
            created_at: now,
        })
        .exec(&mut *ex)
        .await
        .map_err(infrastructure_failure)?;
        codes.push(code);
    }
    Ok(codes)
}

/// A random recovery code: ten characters of [`RECOVERY_ALPHABET`], in two groups of five.
fn recovery_code() -> String {
    let mut bytes = [0u8; 10];
    getrandom::fill(&mut bytes).expect("the recovery code randomness source failed");
    // 256 is a multiple of 32, so the low five bits are uniform.
    let chars: String = bytes
        .iter()
        .map(|byte| char::from(RECOVERY_ALPHABET[usize::from(byte & 31)]))
        .collect();
    format!("{}-{}", &chars[..5], &chars[5..])
}

/// Deletes every factor `user_id` holds on the request's panel, through `ex`.
async fn remove_all(cx: &Cx, ex: &mut dyn toasty::Executor, user_id: &str) -> topcoat::Result<()> {
    AuthFactor::filter(
        AuthFactor::fields()
            .user_id()
            .eq(user_id.to_string())
            .and(AuthFactor::fields().panel().eq(panel_prefix(cx))),
    )
    .delete()
    .exec(ex)
    .await
    .map_err(infrastructure_failure)
}

/// The mistakes in a panel's [`Auth::two_factor`](super::Auth::two_factor): two-factor sign-in
/// without auth, and a router without the cookie key that seals the secrets.
pub(super) fn declaration_errors(
    auth: &super::AuthConfig,
    has_key: bool,
) -> Vec<crate::DeclarationErrorKind> {
    use crate::{DeclarationErrorKind, declaration::TwoFactorFault};

    if auth.two_factor.is_none() {
        return Vec::new();
    }
    if auth.authenticator().is_none() {
        return vec![DeclarationErrorKind::TwoFactor(
            TwoFactorFault::AuthDisabled,
        )];
    }
    if has_key {
        Vec::new()
    } else {
        vec![DeclarationErrorKind::TwoFactor(TwoFactorFault::MissingKey)]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A recovery code reads the same however it is typed, and two codes differ.
    #[test]
    fn a_recovery_code_matches_however_it_is_typed() {
        let code = recovery_code();
        assert_eq!(code.len(), 11);
        let read = normalize_recovery_code(&code);
        assert_eq!(normalize_recovery_code(&code.to_uppercase()), read);
        assert_eq!(normalize_recovery_code(&code.replace('-', " ")), read);
        assert_ne!(normalize_recovery_code(&recovery_code()), read);
    }
}