Skip to main content

tablo_core/auth/throttle/
shared.rs

1//! The counts of a [`LoginThrottle::in_database`](super::LoginThrottle::in_database): one
2//! [`AuthThrottle`] row per login and address, which every process reads.
3
4use std::{fmt::Write as _, net::IpAddr};
5
6use jiff::{SignedDuration, Timestamp};
7use sha2::{Digest, Sha256};
8use topcoat::context::Cx;
9
10use super::Limit;
11use crate::auth::{infrastructure_failure, realm::panel_prefix, token::run_serializable};
12
13/// Shipped sign-in throttle record: how many attempts one login made from one address since the
14/// window began, for a [`LoginThrottle`](super::LoginThrottle) that counts in the database.
15#[derive(Debug, Clone, toasty::Model)]
16pub struct AuthThrottle {
17    /// Hex-encoded SHA-256 of the throttle, the login and the address, so the table holds no
18    /// login in the clear.
19    #[key]
20    pub key: String,
21    /// The panel's prefix and what the throttle counts, such as `/admin:sign-in`.
22    #[index]
23    pub scope: String,
24    pub attempts: i64,
25    /// When the window began: the first attempt it counts.
26    pub since: Timestamp,
27}
28
29/// Where a count lives: its throttle's scope and its own key.
30pub(super) struct Slot {
31    scope: String,
32    key: String,
33}
34
35impl Slot {
36    /// The count of `login` from `client` for the throttle of `scope` on the request's panel.
37    pub(super) fn of(cx: &Cx, scope: &str, login: &str, client: Option<IpAddr>) -> Self {
38        let scope = format!("{}:{scope}", panel_prefix(cx));
39        let client = client.map(|client| client.to_string()).unwrap_or_default();
40        let mut hash = Sha256::new();
41        for part in [
42            scope.as_str(),
43            login.trim().to_lowercase().as_str(),
44            &client,
45        ] {
46            // The length first, so no two splits of the same bytes hash alike.
47            hash.update(part.len().to_be_bytes());
48            hash.update(part.as_bytes());
49        }
50        let mut key = String::with_capacity(64);
51        for byte in hash.finalize() {
52            write!(key, "{byte:02x}").expect("writing to a String cannot fail");
53        }
54        Self { scope, key }
55    }
56}
57
58/// Counts an attempt in `slot`, or answers `false` when it is past `limit`.
59pub(super) async fn attempt(cx: &Cx, limit: Limit, slot: &Slot) -> topcoat::Result<bool> {
60    let now = Timestamp::now();
61    let began = now
62        .checked_sub(SignedDuration::try_from(limit.window).map_err(topcoat::Error::from)?)
63        .map_err(topcoat::Error::from)?;
64    let fresh = run_serializable!(cx, |tx| async {
65        let row = AuthThrottle::filter(AuthThrottle::fields().key().eq(slot.key.clone()))
66            .first()
67            .exec(&mut *tx)
68            .await
69            .map_err(infrastructure_failure)?;
70        match row {
71            Some(row) if row.since > began => {
72                if row.attempts >= i64::from(limit.attempts) {
73                    return Ok(None);
74                }
75                AuthThrottle::filter(AuthThrottle::fields().key().eq(slot.key.clone()))
76                    .update()
77                    .attempts(row.attempts + 1)
78                    .exec(&mut *tx)
79                    .await
80                    .map_err(infrastructure_failure)?;
81                Ok(Some(false))
82            }
83            stale => {
84                if stale.is_some() {
85                    AuthThrottle::filter(AuthThrottle::fields().key().eq(slot.key.clone()))
86                        .delete()
87                        .exec(&mut *tx)
88                        .await
89                        .map_err(infrastructure_failure)?;
90                }
91                toasty::create!(AuthThrottle {
92                    key: slot.key.clone(),
93                    scope: slot.scope.clone(),
94                    attempts: 1,
95                    since: now,
96                })
97                .exec(&mut *tx)
98                .await
99                .map_err(infrastructure_failure)?;
100                Ok(Some(true))
101            }
102        }
103    })?;
104    let Some(fresh) = fresh else {
105        return Ok(false);
106    };
107    if fresh {
108        // Outside the transaction: a sweep inside it would make concurrent attempts conflict.
109        prune(cx, &slot.scope, began).await?;
110    }
111    Ok(true)
112}
113
114/// Deletes the count in `slot`, after its login signs in.
115pub(super) async fn clear(cx: &Cx, slot: &Slot) -> topcoat::Result<()> {
116    let mut db = crate::db::db(cx)?;
117    AuthThrottle::filter(AuthThrottle::fields().key().eq(slot.key.clone()))
118        .delete()
119        .exec(&mut db)
120        .await
121        .map_err(infrastructure_failure)
122}
123
124/// Deletes the counts of `scope` whose window began before `began`, so logins that never come
125/// back leave no rows.
126async fn prune(cx: &Cx, scope: &str, began: Timestamp) -> topcoat::Result<()> {
127    let mut db = crate::db::db(cx)?;
128    AuthThrottle::filter(
129        AuthThrottle::fields()
130            .scope()
131            .eq(scope.to_owned())
132            .and(AuthThrottle::fields().since().le(began)),
133    )
134    .delete()
135    .exec(&mut db)
136    .await
137    .map_err(infrastructure_failure)
138}