flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/identity/src/throttle.rs

230 lines9,322 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Slowing down password guessing, and mail sent on request.
2//!
3//! Every check of a password (signing in on the site, git over HTTPS with a
4//! password, confirming the password for a sensitive change) counts its
5//! failures twice: against the account (or, for a name no account has,
6//! against that name, so the answer never says which exist) and against
7//! the client's IP address when the caller gives one. Past a key's limit in
8//! its window, the key is locked for a minute, then twice as long after
9//! each further failure, up to an hour ([`lockout_seconds`]). While either
10//! key is locked, no password is even checked, and the answer is the same
11//! [`THROTTLED`] for every account. A correct password clears the
12//! account's count, never the client's. The first time an account is
13//! locked in an hour, its primary and backup addresses are told.
14//!
15//! Requests that send mail (password resets, confirmation links) are
16//! counted the same way, by address, account and client; past the limit
17//! they quietly send nothing.
18//!
19//! Counts live in `auth_throttle` (migration 0019), one row per key.
20
21use g1t_contracts::time::rfc3339;
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::Result;
25
26use crate::{Identity, crypto};
27
28/// What anyone hears while a key is locked.
29pub const THROTTLED: &str = "Too many attempts. Wait a few minutes and try again, or reset your password.";
30
31/// How many hits a key may have in its window.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct Rule {
34 pub name: &'static str,
35 pub limit: u32,
36 pub window_seconds: u64,
37}
38
39const HOUR: u64 = 60 * 60;
40
41/// Wrong passwords for one account (or one name), from anywhere.
42pub const PASSWORD_ACCOUNT: Rule = Rule { name: "password.account", limit: 10, window_seconds: HOUR };
43/// Wrong passwords from one client, for any accounts.
44pub const PASSWORD_CLIENT: Rule = Rule { name: "password.client", limit: 30, window_seconds: HOUR };
45/// Password resets asked for one address.
46pub const RESET_EMAIL: Rule = Rule { name: "reset.email", limit: 5, window_seconds: HOUR };
47/// Password resets asked from one client.
48pub const RESET_CLIENT: Rule = Rule { name: "reset.client", limit: 20, window_seconds: HOUR };
49/// Confirmation links one account asks for.
50pub const CONFIRM_ACCOUNT: Rule = Rule { name: "confirm.account", limit: 10, window_seconds: HOUR };
51
52// One account is held to less than one client, which may be an office.
53const _: () = assert!(PASSWORD_ACCOUNT.limit < PASSWORD_CLIENT.limit && RESET_EMAIL.limit < RESET_CLIENT.limit);
54
55/// How long a key with `hits` in its window is locked: not at all below
56/// the limit, a minute at it, doubling with each hit past it, up to an hour.
57pub fn lockout_seconds(hits: u32, limit: u32) -> u64 {
58 if hits < limit {
59 return 0;
60 }
61 (60u64 << (hits - limit).min(6)).min(HOUR)
62}
63
64/// A key's name in `auth_throttle`. Addresses, IPs and names are hashed,
65/// so the table holds nothing to read back.
66pub fn key(rule: Rule, subject: &str) -> String {
67 format!("{}:{}", rule.name, crypto::sha256_hex(&subject.trim().to_lowercase()))
68}
69
70#[derive(Deserialize)]
71struct Hits {
72 hits: f64,
73}
74
75/// What one more failure did.
76#[derive(Debug, PartialEq, Eq)]
77pub struct Counted {
78 pub hits: u32,
79 /// This failure is the one that locked the key.
80 pub locked_now: bool,
81}
82
83impl Identity {
84 /// Whether `key` is locked.
85 pub async fn locked(&self, key: &str) -> Result<bool> {
86 let now = rfc3339(now_ms());
87 let row = self
88 .db
89 .prepare("SELECT 1 AS hits FROM auth_throttle WHERE key = ? AND locked_until > ?")
90 .bind(&[key.into(), now.as_str().into()])?
91 .first::<Hits>(None)
92 .await?;
93 Ok(row.is_some())
94 }
95
96 /// Counts one more hit on `key`, starting a new window when the last
97 /// one has passed, and locks it past `rule`'s limit.
98 pub async fn count(&self, rule: Rule, key: &str) -> Result<Counted> {
99 let now = now_ms();
100 let stamp = rfc3339(now);
101 let window_start = rfc3339(now.saturating_sub(rule.window_seconds * 1000));
102 let hits = self
103 .db
104 .prepare(
105 "INSERT INTO auth_throttle (key, hits, window_start) VALUES (?1, 1, ?2)
106 ON CONFLICT (key) DO UPDATE SET
107 hits = CASE WHEN auth_throttle.window_start < ?3 THEN 1 ELSE auth_throttle.hits + 1 END,
108 window_start = CASE WHEN auth_throttle.window_start < ?3 THEN ?2 ELSE auth_throttle.window_start END
109 RETURNING hits",
110 )
111 .bind(&[key.into(), stamp.as_str().into(), window_start.as_str().into()])?
112 .first::<Hits>(None)
113 .await?
114 .map_or(1, |row| row.hits as u32);
115 let lock = lockout_seconds(hits, rule.limit);
116 if lock > 0 {
117 self.db
118 .prepare("UPDATE auth_throttle SET locked_until = ? WHERE key = ?")
119 .bind(&[rfc3339(now + lock * 1000).into(), key.into()])?
120 .run()
121 .await?;
122 }
123 if hits == 1 {
124 // A new window: forget keys a day quiet.
125 self.db
126 .prepare("DELETE FROM auth_throttle WHERE window_start < ? AND (locked_until IS NULL OR locked_until < ?)")
127 .bind(&[rfc3339(now.saturating_sub(24 * HOUR * 1000)).into(), stamp.as_str().into()])?
128 .run()
129 .await?;
130 }
131 Ok(Counted { hits, locked_now: hits == rule.limit })
132 }
133
134 /// Counts a request that sends mail; false once past the limit.
135 pub async fn allow(&self, rule: Rule, subject: &str) -> Result<bool> {
136 Ok(self.count(rule, &key(rule, subject)).await?.hits <= rule.limit)
137 }
138
139 /// Forgets a key's failures: its owner got the password right.
140 pub async fn clear(&self, key: &str) -> Result<()> {
141 self.db.prepare("DELETE FROM auth_throttle WHERE key = ?").bind(&[key.into()])?.run().await?;
142 Ok(())
143 }
144
145 /// The keys a password check for `login` (a username or an address)
146 /// from `client` counts against: the account's, or the name's when no
147 /// account has it, and the client's.
148 pub fn password_keys(account: &str, client: Option<&str>) -> (String, Option<String>) {
149 (
150 key(PASSWORD_ACCOUNT, account),
151 client.filter(|client| !client.trim().is_empty()).map(|client| key(PASSWORD_CLIENT, client)),
152 )
153 }
154
155 /// Whether a password check may run now.
156 pub async fn password_locked(&self, account_key: &str, client_key: Option<&str>) -> Result<bool> {
157 if self.locked(account_key).await? {
158 return Ok(true);
159 }
160 match client_key {
161 Some(client_key) => self.locked(client_key).await,
162 None => Ok(false),
163 }
164 }
165
166 /// Counts a wrong password, and tells the account's owner the first
167 /// time in an hour that it locks their account.
168 pub async fn password_failed(&self, account_key: &str, client_key: Option<&str>, user: Option<(&str, &str)>) -> Result<()> {
169 let counted = self.count(PASSWORD_ACCOUNT, account_key).await?;
170 if let Some(client_key) = client_key {
171 self.count(PASSWORD_CLIENT, client_key).await?;
172 }
173 if counted.locked_now
174 && let Some((user_id, username)) = user
175 && self.first_notice(account_key).await?
176 {
177 self.log_security(user_id, "password_locked", Some(&format!("{} wrong passwords", counted.hits)), None)
178 .await;
179 self.tell_primary_and_backup(
180 user_id,
181 username,
182 &format!("Password sign-in was paused after {} wrong passwords", counted.hits),
183 )
184 .await;
185 }
186 Ok(())
187 }
188
189 /// Marks that the owner was told about a lock; false when they were in
190 /// the last hour.
191 async fn first_notice(&self, key: &str) -> Result<bool> {
192 let now = now_ms();
193 let row = self
194 .db
195 .prepare(
196 "UPDATE auth_throttle SET notified_at = ?1
197 WHERE key = ?2 AND (notified_at IS NULL OR notified_at < ?3) RETURNING 1 AS hits",
198 )
199 .bind(&[rfc3339(now).into(), key.into(), rfc3339(now.saturating_sub(HOUR * 1000)).into()])?
200 .first::<Hits>(None)
201 .await?;
202 Ok(row.is_some())
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn a_key_locks_at_its_limit_and_backs_off_to_an_hour() {
212 assert_eq!(lockout_seconds(0, 10), 0);
213 assert_eq!(lockout_seconds(9, 10), 0);
214 assert_eq!(lockout_seconds(10, 10), 60);
215 assert_eq!(lockout_seconds(11, 10), 120);
216 assert_eq!(lockout_seconds(12, 10), 240);
217 assert_eq!(lockout_seconds(15, 10), 1920);
218 assert_eq!(lockout_seconds(16, 10), 3600);
219 assert_eq!(lockout_seconds(1000, 10), 3600);
220 }
221
222 #[test]
223 fn keys_hash_their_subject_and_ignore_case() {
224 let a = key(PASSWORD_ACCOUNT, "Ada@Example.com ");
225 assert_eq!(a, key(PASSWORD_ACCOUNT, "ada@example.com"));
226 assert!(a.starts_with("password.account:"));
227 assert!(!a.contains("ada"));
228 assert_ne!(a, key(RESET_EMAIL, "ada@example.com"));
229 }
230}