Skip to content
267 linesCodeBlameRaw

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

This file's history is long; its oldest lines are credited to the oldest commit read.