| 1 | //! Two-factor authentication: a time-based code from an authenticator app |
| 2 | //! (TOTP, RFC 6238, with HMAC-SHA1, six digits and 30-second steps, as every |
| 3 | //! app reads it), and recovery codes for when the app is lost. |
| 4 | //! |
| 5 | //! **Turning it on.** `two_factor_start` makes a secret, sealed at rest |
| 6 | //! with `IDENTITY_KEY` and bound to the account (`g1t_secrets::Sealer`), |
| 7 | //! and returns it with an `otpauth://` address for a QR code. A code from |
| 8 | //! the app given to `two_factor_enable` confirms it; ten recovery codes are |
| 9 | //! made then and shown once, kept only as their SHA-256. Both, and turning |
| 10 | //! it off, need the person to prove it is them (security.rs, "sudo mode"); |
| 11 | //! turning it off also needs a code. |
| 12 | //! |
| 13 | //! **Signing in.** With it on, a right password makes no session: |
| 14 | //! `sign_in` returns a challenge (lib.rs, `start_session`) that |
| 15 | //! `two_factor_sign_in` trades, with a code, for the session. A challenge |
| 16 | //! lasts ten minutes and [`TWO_FACTOR_ATTEMPTS`] wrong codes. Git over HTTPS |
| 17 | //! takes an access token then, never the password. |
| 18 | //! |
| 19 | //! **Codes.** A code is accepted for its own step and one either side, for |
| 20 | //! clocks a little off, and never twice: `two_factor.last_step` keeps the |
| 21 | //! last step used, and only a later one is accepted after it. A recovery |
| 22 | //! code works once. |
| 23 | //! |
| 24 | //! Each change is in the account's security log, mailed to its primary and |
| 25 | //! backup addresses, and recorded in the audit log of every workspace the |
| 26 | //! person belongs to. |
| 27 | |
| 28 | use g1t_contracts::accounts::*; |
| 29 | use g1t_contracts::identity::{SignedIn, UserArgs}; |
| 30 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; |
| 31 | use g1t_contracts::{FailureCode, Outcome, Role, User}; |
| 32 | use g1t_kit::now_ms; |
| 33 | use g1t_secrets::Sealer; |
| 34 | use hmac::{Hmac, Mac}; |
| 35 | use serde::Deserialize; |
| 36 | use sha1::Sha1; |
| 37 | use worker::Result; |
| 38 | |
| 39 | use crate::security::{PEOPLE_ONLY, is_person}; |
| 40 | use crate::{Identity, crypto}; |
| 41 | |
| 42 | const NOT_ON: &str = "Two-factor authentication is not on for this account."; |
| 43 | const WRONG_CODE: &str = "That code is not right. Use the current code from your authenticator app, or a recovery code."; |
| 44 | const NO_KEY: &str = "Two-factor authentication is not available right now."; |
| 45 | |
| 46 | // --- TOTP, RFC 6238 --- |
| 47 | |
| 48 | const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; |
| 49 | |
| 50 | /// RFC 4648 base32, without padding, as authenticator apps take secrets. |
| 51 | pub fn base32_encode(bytes: &[u8]) -> String { |
| 52 | let mut out = String::new(); |
| 53 | let mut buffer: u32 = 0; |
| 54 | let mut bits = 0; |
| 55 | for &byte in bytes { |
| 56 | buffer = (buffer << 8) | byte as u32; |
| 57 | bits += 8; |
| 58 | while bits >= 5 { |
| 59 | out.push(BASE32[((buffer >> (bits - 5)) & 31) as usize] as char); |
| 60 | bits -= 5; |
| 61 | } |
| 62 | } |
| 63 | if bits > 0 { |
| 64 | out.push(BASE32[((buffer << (5 - bits)) & 31) as usize] as char); |
| 65 | } |
| 66 | out |
| 67 | } |
| 68 | |
| 69 | /// The bytes of a base32 secret; spaces, case and padding ignored. |
| 70 | pub fn base32_decode(text: &str) -> Option<Vec<u8>> { |
| 71 | let mut out = Vec::new(); |
| 72 | let mut buffer: u32 = 0; |
| 73 | let mut bits = 0; |
| 74 | for c in text.chars().filter(|c| !c.is_whitespace() && *c != '=') { |
| 75 | let value = BASE32.iter().position(|&b| b as char == c.to_ascii_uppercase())? as u32; |
| 76 | buffer = (buffer << 5) | value; |
| 77 | bits += 5; |
| 78 | if bits >= 8 { |
| 79 | out.push(((buffer >> (bits - 8)) & 0xff) as u8); |
| 80 | bits -= 8; |
| 81 | } |
| 82 | } |
| 83 | Some(out) |
| 84 | } |
| 85 | |
| 86 | /// The HOTP value (RFC 4226) of `secret` at `counter`, as six digits. |
| 87 | pub fn hotp(secret: &[u8], counter: u64) -> String { |
| 88 | let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(secret).expect("HMAC takes any key length"); |
| 89 | mac.update(&counter.to_be_bytes()); |
| 90 | let digest = mac.finalize().into_bytes(); |
| 91 | let offset = (digest[19] & 0x0f) as usize; |
| 92 | let binary = ((digest[offset] as u32 & 0x7f) << 24) |
| 93 | | ((digest[offset + 1] as u32) << 16) |
| 94 | | ((digest[offset + 2] as u32) << 8) |
| 95 | | digest[offset + 3] as u32; |
| 96 | format!("{:0width$}", binary % 10u32.pow(TOTP_DIGITS), width = TOTP_DIGITS as usize) |
| 97 | } |
| 98 | |
| 99 | /// The step a time falls in. |
| 100 | pub fn step_at(unix_seconds: u64) -> u64 { |
| 101 | unix_seconds / TOTP_STEP_SECONDS |
| 102 | } |
| 103 | |
| 104 | /// The step `code` is right for, within [`TOTP_SKEW_STEPS`] of now and |
| 105 | /// after `last_step` (so no code is accepted twice); `None` otherwise. |
| 106 | pub fn verify_totp(secret: &[u8], code: &str, unix_seconds: u64, last_step: u64) -> Option<u64> { |
| 107 | let code = tidy_code(code); |
| 108 | if !is_totp_shaped(&code) { |
| 109 | return None; |
| 110 | } |
| 111 | let now = step_at(unix_seconds); |
| 112 | let mut found = None; |
| 113 | for step in now.saturating_sub(TOTP_SKEW_STEPS)..=now + TOTP_SKEW_STEPS { |
| 114 | // Compared in full every time, so timing says nothing of which. |
| 115 | let right = hotp(secret, step).bytes().zip(code.bytes()).fold(0u8, |diff, (a, b)| diff | (a ^ b)) == 0; |
| 116 | if right && step > last_step && found.is_none() { |
| 117 | found = Some(step); |
| 118 | } |
| 119 | } |
| 120 | found |
| 121 | } |
| 122 | |
| 123 | /// A recovery code: ten characters in two groups, such as `k7m2q-9xw4d`. |
| 124 | fn new_recovery_code() -> String { |
| 125 | const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; |
| 126 | let mut bytes = [0u8; 10]; |
| 127 | getrandom::getrandom(&mut bytes).expect("no source of randomness"); |
| 128 | let chars: String = bytes.iter().map(|byte| ALPHABET[*byte as usize % ALPHABET.len()] as char).collect(); |
| 129 | format!("{}-{}", &chars[..5], &chars[5..]) |
| 130 | } |
| 131 | |
| 132 | /// What a recovery code is kept as. |
| 133 | pub fn recovery_hash(code: &str) -> String { |
| 134 | crypto::sha256_hex(&tidy_code(code)) |
| 135 | } |
| 136 | |
| 137 | #[derive(Deserialize)] |
| 138 | struct Row { |
| 139 | secret: String, |
| 140 | enabled_at: Option<String>, |
| 141 | last_step: f64, |
| 142 | } |
| 143 | |
| 144 | impl Identity { |
| 145 | fn two_factor_sealer(&self) -> Option<Sealer> { |
| 146 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) |
| 147 | } |
| 148 | |
| 149 | async fn two_factor_row(&self, user_id: &str) -> Result<Option<Row>> { |
| 150 | self.db |
| 151 | .prepare("SELECT secret, enabled_at, last_step FROM two_factor WHERE user_id = ?") |
| 152 | .bind(&[user_id.into()])? |
| 153 | .first::<Row>(None) |
| 154 | .await |
| 155 | } |
| 156 | |
| 157 | /// Whether the account has two-factor authentication on. |
| 158 | pub async fn two_factor_enabled(&self, user_id: &str) -> Result<bool> { |
| 159 | Ok(self.two_factor_row(user_id).await?.is_some_and(|row| row.enabled_at.is_some())) |
| 160 | } |
| 161 | |
| 162 | /// The secret of a row, opened. |
| 163 | fn opened(&self, user_id: &str, row: &Row) -> Option<Vec<u8>> { |
| 164 | let text = self.two_factor_sealer()?.open(&row.secret, &bound(user_id))?; |
| 165 | base32_decode(&text) |
| 166 | } |
| 167 | |
| 168 | /// Whether `code` is a right code for the account, using it up: an app |
| 169 | /// code's step is recorded, a recovery code is spent. With `pending`, the |
| 170 | /// enrolment in progress is what is checked, and recovery codes are not. |
| 171 | async fn use_code(&self, user_id: &str, code: &str, pending: bool) -> Result<bool> { |
| 172 | let Some(row) = self.two_factor_row(user_id).await? else { |
| 173 | return Ok(false); |
| 174 | }; |
| 175 | if pending != row.enabled_at.is_none() { |
| 176 | return Ok(false); |
| 177 | } |
| 178 | let tidy = tidy_code(code); |
| 179 | if is_totp_shaped(&tidy) { |
| 180 | let Some(secret) = self.opened(user_id, &row) else { |
| 181 | return Ok(false); |
| 182 | }; |
| 183 | let Some(step) = verify_totp(&secret, &tidy, now_ms() / 1000, row.last_step as u64) else { |
| 184 | return Ok(false); |
| 185 | }; |
| 186 | // Only a later step moves it on, so two requests with one code |
| 187 | // cannot both pass. |
| 188 | let moved = self |
| 189 | .db |
| 190 | .prepare("UPDATE two_factor SET last_step = ?1 WHERE user_id = ?2 AND last_step < ?1 RETURNING user_id") |
| 191 | .bind(&[(step as f64).into(), user_id.into()])? |
| 192 | .first::<serde_json::Value>(None) |
| 193 | .await?; |
| 194 | return Ok(moved.is_some()); |
| 195 | } |
| 196 | if pending { |
| 197 | return Ok(false); |
| 198 | } |
| 199 | let spent = self |
| 200 | .db |
| 201 | .prepare( |
| 202 | "UPDATE two_factor_recovery SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL |
| 203 | RETURNING code_hash", |
| 204 | ) |
| 205 | .bind(&[rfc3339(now_ms()).into(), user_id.into(), recovery_hash(&tidy).into()])? |
| 206 | .first::<serde_json::Value>(None) |
| 207 | .await?; |
| 208 | if spent.is_some() { |
| 209 | self.log_security(user_id, "recovery_code_used", None, None).await; |
| 210 | } |
| 211 | Ok(spent.is_some()) |
| 212 | } |
| 213 | |
| 214 | /// Ten new recovery codes for the account, replacing any it had. |
| 215 | async fn new_recovery_codes(&self, user_id: &str) -> Result<RecoveryCodes> { |
| 216 | let codes: Vec<String> = (0..RECOVERY_CODES).map(|_| new_recovery_code()).collect(); |
| 217 | let mut statements = vec![ |
| 218 | self.db |
| 219 | .prepare("DELETE FROM two_factor_recovery WHERE user_id = ?") |
| 220 | .bind(&[user_id.into()])?, |
| 221 | ]; |
| 222 | for code in &codes { |
| 223 | statements.push( |
| 224 | self.db |
| 225 | .prepare("INSERT OR IGNORE INTO two_factor_recovery (user_id, code_hash) VALUES (?, ?)") |
| 226 | .bind(&[user_id.into(), recovery_hash(code).into()])?, |
| 227 | ); |
| 228 | } |
| 229 | self.db.batch(statements).await?; |
| 230 | Ok(RecoveryCodes { codes }) |
| 231 | } |
| 232 | |
| 233 | /// The workspaces the person belongs to that require two-factor |
| 234 | /// authentication, and of those, the ones they own. |
| 235 | async fn requiring_workspaces(&self, user_id: &str) -> Result<Vec<(String, bool)>> { |
| 236 | #[derive(Deserialize)] |
| 237 | struct Requiring { |
| 238 | slug: String, |
| 239 | role: Role, |
| 240 | } |
| 241 | Ok(self |
| 242 | .db |
| 243 | .prepare( |
| 244 | "SELECT w.slug, m.role FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id |
| 245 | WHERE m.user_id = ? AND w.require_two_factor = 1 AND w.deleted_at IS NULL ORDER BY w.slug", |
| 246 | ) |
| 247 | .bind(&[user_id.into()])? |
| 248 | .all() |
| 249 | .await? |
| 250 | .results::<Requiring>()? |
| 251 | .into_iter() |
| 252 | .map(|row| (row.slug, row.role == Role::Owner)) |
| 253 | .collect()) |
| 254 | } |
| 255 | |
| 256 | /// `two_factor_status`. |
| 257 | pub async fn two_factor_status(&self, a: UserArgs) -> Result<Outcome<TwoFactorStatus>> { |
| 258 | if !is_person(&a.user) { |
| 259 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); |
| 260 | } |
| 261 | #[derive(Deserialize)] |
| 262 | struct Left { |
| 263 | left: u32, |
| 264 | } |
| 265 | let row = self.two_factor_row(&a.user.id).await?; |
| 266 | let left = self |
| 267 | .db |
| 268 | .prepare("SELECT count(*) AS left FROM two_factor_recovery WHERE user_id = ? AND used_at IS NULL") |
| 269 | .bind(&[a.user.id.as_str().into()])? |
| 270 | .first::<Left>(None) |
| 271 | .await? |
| 272 | .map_or(0, |row| row.left); |
| 273 | let enabled_at = row.and_then(|row| row.enabled_at); |
| 274 | Ok(Outcome::Ok(TwoFactorStatus { |
| 275 | enabled: enabled_at.is_some(), |
| 276 | recovery_codes_left: if enabled_at.is_some() { left } else { 0 }, |
| 277 | enabled_at, |
| 278 | required_by: self.requiring_workspaces(&a.user.id).await?.into_iter().map(|(slug, _)| slug).collect(), |
| 279 | })) |
| 280 | } |
| 281 | |
| 282 | /// `two_factor_start`. |
| 283 | pub async fn two_factor_start(&self, a: TwoFactorArgs) -> Result<Outcome<TwoFactorSetup>> { |
| 284 | if !is_person(&a.user) { |
| 285 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); |
| 286 | } |
| 287 | if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() { |
| 288 | return Ok(refused); |
| 289 | } |
| 290 | if self.two_factor_enabled(&a.user.id).await? { |
| 291 | return Ok(Outcome::fail(FailureCode::Conflict, "Two-factor authentication is already on.")); |
| 292 | } |
| 293 | let Some(sealer) = self.two_factor_sealer() else { |
| 294 | return Ok(Outcome::fail(FailureCode::Conflict, NO_KEY)); |
| 295 | }; |
| 296 | let mut bytes = [0u8; 20]; |
| 297 | getrandom::getrandom(&mut bytes).expect("no source of randomness"); |
| 298 | let secret = base32_encode(&bytes); |
| 299 | self.db |
| 300 | .prepare( |
| 301 | "INSERT INTO two_factor (user_id, secret, enabled_at, created_at, last_step) VALUES (?1, ?2, NULL, ?3, 0) |
| 302 | ON CONFLICT (user_id) DO UPDATE SET secret = excluded.secret, enabled_at = NULL, |
| 303 | created_at = excluded.created_at, last_step = 0", |
| 304 | ) |
| 305 | .bind(&[a.user.id.as_str().into(), sealer.seal(&secret, &bound(&a.user.id)).into(), rfc3339(now_ms()).into()])? |
| 306 | .run() |
| 307 | .await?; |
| 308 | Ok(Outcome::Ok(TwoFactorSetup { uri: otpauth_uri(&secret, &a.user.username), secret })) |
| 309 | } |
| 310 | |
| 311 | /// `two_factor_enable`. |
| 312 | pub async fn two_factor_enable(&self, a: TwoFactorCodeArgs) -> Result<Outcome<RecoveryCodes>> { |
| 313 | if !is_person(&a.user) { |
| 314 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); |
| 315 | } |
| 316 | if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() { |
| 317 | return Ok(refused); |
| 318 | } |
| 319 | if self.two_factor_enabled(&a.user.id).await? { |
| 320 | return Ok(Outcome::fail(FailureCode::Conflict, "Two-factor authentication is already on.")); |
| 321 | } |
| 322 | if self.two_factor_row(&a.user.id).await?.is_none() { |
| 323 | return Ok(Outcome::fail(FailureCode::Invalid, "Start again: scan the QR code, then enter the code your app shows.")); |
| 324 | } |
| 325 | if !self.use_code(&a.user.id, &a.code, true).await? { |
| 326 | return Ok(Outcome::fail(FailureCode::Invalid, "That code is not right. Enter the code your app shows now.")); |
| 327 | } |
| 328 | self.db |
| 329 | .prepare(format!("UPDATE two_factor SET enabled_at = {SQL_NOW} WHERE user_id = ?")) |
| 330 | .bind(&[a.user.id.as_str().into()])? |
| 331 | .run() |
| 332 | .await?; |
| 333 | let codes = self.new_recovery_codes(&a.user.id).await?; |
| 334 | self.log_security(&a.user.id, "two_factor_enabled", Some("Authenticator app"), None).await; |
| 335 | self.tell_primary_and_backup(&a.user.id, &a.user.username, "Two-factor authentication was turned on").await; |
| 336 | self.audit_account(&a.user, "two_factor.enabled", "Turned on two-factor authentication").await; |
| 337 | Ok(Outcome::Ok(codes)) |
| 338 | } |
| 339 | |
| 340 | /// `two_factor_disable`. |
| 341 | pub async fn two_factor_disable(&self, a: TwoFactorCodeArgs) -> Result<Outcome<bool>> { |
| 342 | if !is_person(&a.user) { |
| 343 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); |
| 344 | } |
| 345 | if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() { |
| 346 | return Ok(refused); |
| 347 | } |
| 348 | if !self.two_factor_enabled(&a.user.id).await? { |
| 349 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_ON)); |
| 350 | } |
| 351 | let owned: Vec<String> = self |
| 352 | .requiring_workspaces(&a.user.id) |
| 353 | .await? |
| 354 | .into_iter() |
| 355 | .filter_map(|(slug, owner)| owner.then_some(slug)) |
| 356 | .collect(); |
| 357 | if !owned.is_empty() { |
| 358 | return Ok(Outcome::fail( |
| 359 | FailureCode::Conflict, |
| 360 | format!( |
| 361 | "You own {}, which requires two-factor authentication. Stop requiring it there first, or hand the workspace to another owner.", |
| 362 | owned.join(", ") |
| 363 | ), |
| 364 | )); |
| 365 | } |
| 366 | if !self.use_code(&a.user.id, &a.code, false).await? { |
| 367 | return Ok(Outcome::fail(FailureCode::Invalid, WRONG_CODE)); |
| 368 | } |
| 369 | self.db |
| 370 | .batch(vec![ |
| 371 | self.db.prepare("DELETE FROM two_factor WHERE user_id = ?").bind(&[a.user.id.as_str().into()])?, |
| 372 | self.db.prepare("DELETE FROM two_factor_recovery WHERE user_id = ?").bind(&[a.user.id.as_str().into()])?, |
| 373 | self.db.prepare("DELETE FROM two_factor_challenges WHERE user_id = ?").bind(&[a.user.id.as_str().into()])?, |
| 374 | ]) |
| 375 | .await?; |
| 376 | self.log_security(&a.user.id, "two_factor_disabled", None, None).await; |
| 377 | self.tell_primary_and_backup(&a.user.id, &a.user.username, "Two-factor authentication was turned off").await; |
| 378 | self.audit_account(&a.user, "two_factor.disabled", "Turned off two-factor authentication").await; |
| 379 | Ok(Outcome::Ok(true)) |
| 380 | } |
| 381 | |
| 382 | /// `two_factor_recovery_codes`. |
| 383 | pub async fn two_factor_recovery_codes(&self, a: TwoFactorArgs) -> Result<Outcome<RecoveryCodes>> { |
| 384 | if !is_person(&a.user) { |
| 385 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); |
| 386 | } |
| 387 | if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() { |
| 388 | return Ok(refused); |
| 389 | } |
| 390 | if !self.two_factor_enabled(&a.user.id).await? { |
| 391 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_ON)); |
| 392 | } |
| 393 | let codes = self.new_recovery_codes(&a.user.id).await?; |
| 394 | self.log_security(&a.user.id, "recovery_codes_regenerated", None, None).await; |
| 395 | self.tell_primary_and_backup(&a.user.id, &a.user.username, "New two-factor recovery codes were made; the old ones no longer work").await; |
| 396 | Ok(Outcome::Ok(codes)) |
| 397 | } |
| 398 | |
| 399 | /// A sign-in waiting for its code: the token the caller passes back. |
| 400 | pub(crate) async fn issue_challenge(&self, user_id: &str) -> Result<String> { |
| 401 | let token = crypto::random_hex(32); |
| 402 | self.db |
| 403 | .batch(vec![ |
| 404 | // Old ones for the account go; a sign-in starts one afresh. |
| 405 | self.db |
| 406 | .prepare(format!("DELETE FROM two_factor_challenges WHERE user_id = ? OR expires_at <= {SQL_NOW}")) |
| 407 | .bind(&[user_id.into()])?, |
| 408 | self.db |
| 409 | .prepare(format!( |
| 410 | "INSERT INTO two_factor_challenges (id, user_id, expires_at, attempts) VALUES (?, ?, {}, 0)", |
| 411 | sql_after(TWO_FACTOR_CHALLENGE_SECONDS) |
| 412 | )) |
| 413 | .bind(&[crypto::sha256_hex(&token).into(), user_id.into()])?, |
| 414 | ]) |
| 415 | .await?; |
| 416 | Ok(token) |
| 417 | } |
| 418 | |
| 419 | /// `two_factor_sign_in`. |
| 420 | pub async fn two_factor_sign_in(&self, a: TwoFactorSignInArgs) -> Result<Outcome<SignedIn>> { |
| 421 | #[derive(Deserialize)] |
| 422 | struct Challenge { |
| 423 | user_id: String, |
| 424 | attempts: u32, |
| 425 | } |
| 426 | let id = crypto::sha256_hex(&a.challenge); |
| 427 | let challenge = self |
| 428 | .db |
| 429 | .prepare(format!("SELECT user_id, attempts FROM two_factor_challenges WHERE id = ? AND expires_at > {SQL_NOW}")) |
| 430 | .bind(&[id.as_str().into()])? |
| 431 | .first::<Challenge>(None) |
| 432 | .await?; |
| 433 | let expired = || Ok(Outcome::fail(FailureCode::Unauthenticated, "This sign-in has expired. Sign in again.")); |
| 434 | let Some(challenge) = challenge else { |
| 435 | return expired(); |
| 436 | }; |
| 437 | if challenge.attempts >= TWO_FACTOR_ATTEMPTS { |
| 438 | self.db.prepare("DELETE FROM two_factor_challenges WHERE id = ?").bind(&[id.as_str().into()])?.run().await?; |
| 439 | return expired(); |
| 440 | } |
| 441 | if !self.use_code(&challenge.user_id, &a.code, false).await? { |
| 442 | self.db |
| 443 | .prepare("UPDATE two_factor_challenges SET attempts = attempts + 1 WHERE id = ?") |
| 444 | .bind(&[id.as_str().into()])? |
| 445 | .run() |
| 446 | .await?; |
| 447 | return Ok(Outcome::fail(FailureCode::Unauthenticated, WRONG_CODE)); |
| 448 | } |
| 449 | self.db.prepare("DELETE FROM two_factor_challenges WHERE id = ?").bind(&[id.as_str().into()])?.run().await?; |
| 450 | let user = self |
| 451 | .find_user( |
| 452 | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?", |
| 453 | &challenge.user_id, |
| 454 | ) |
| 455 | .await?; |
| 456 | let Some(user) = user else { |
| 457 | return expired(); |
| 458 | }; |
| 459 | self.session_for(user).await |
| 460 | } |
| 461 | |
| 462 | /// Records a change to the person's own account in the audit log of |
| 463 | /// every workspace they belong to, where its owners look. |
| 464 | pub(crate) async fn audit_account(&self, user: &User, action: &str, message: &str) { |
| 465 | let Ok(memberships) = self.memberships(&user.id).await else { |
| 466 | return; |
| 467 | }; |
| 468 | for membership in memberships { |
| 469 | self.audit_workspace(user, action, &membership.slug, g1t_contracts::audit::Surface::Web, message.to_owned()).await; |
| 470 | } |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | /// What a sealed secret is bound to. |
| 475 | fn bound(user_id: &str) -> String { |
| 476 | format!("two_factor:{user_id}") |
| 477 | } |
| 478 | |
| 479 | #[cfg(test)] |
| 480 | mod tests { |
| 481 | use super::*; |
| 482 | |
| 483 | /// RFC 6238, appendix B: the SHA-1 key and the 8-digit values, cut to |
| 484 | /// six digits (the last six of each). |
| 485 | #[test] |
| 486 | fn codes_match_rfc_6238() { |
| 487 | let secret = b"12345678901234567890"; |
| 488 | for (time, expected) in [ |
| 489 | (59u64, "94287082"), |
| 490 | (1_111_111_109, "07081804"), |
| 491 | (1_111_111_111, "14050471"), |
| 492 | (1_234_567_890, "89005924"), |
| 493 | (2_000_000_000, "69279037"), |
| 494 | (20_000_000_000, "65353130"), |
| 495 | ] { |
| 496 | assert_eq!(hotp(secret, step_at(time)), expected[2..], "at {time}"); |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | #[test] |
| 501 | fn base32_round_trips_and_reads_what_apps_show() { |
| 502 | let bytes = b"12345678901234567890"; |
| 503 | let text = base32_encode(bytes); |
| 504 | assert_eq!(text, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"); |
| 505 | assert_eq!(base32_decode(&text).unwrap(), bytes); |
| 506 | assert_eq!(base32_decode("gezd gnbv gy3t qojq gezd gnbv gy3t qojq").unwrap(), bytes); |
| 507 | assert!(base32_decode("not base32!").is_none()); |
| 508 | } |
| 509 | |
| 510 | #[test] |
| 511 | fn a_code_works_a_step_either_side_and_no_further() { |
| 512 | let secret = b"12345678901234567890"; |
| 513 | let now = 1_111_111_111; |
| 514 | let step = step_at(now); |
| 515 | let current = hotp(secret, step); |
| 516 | assert_eq!(verify_totp(secret, ¤t, now, 0), Some(step)); |
| 517 | // A clock 30 seconds behind or ahead still gets in. |
| 518 | assert_eq!(verify_totp(secret, &hotp(secret, step - 1), now, 0), Some(step - 1)); |
| 519 | assert_eq!(verify_totp(secret, &hotp(secret, step + 1), now, 0), Some(step + 1)); |
| 520 | // Two steps off does not. |
| 521 | assert_eq!(verify_totp(secret, &hotp(secret, step - 2), now, 0), None); |
| 522 | assert_eq!(verify_totp(secret, &hotp(secret, step + 2), now, 0), None); |
| 523 | // Typed with a space, it is the same code. |
| 524 | assert_eq!(verify_totp(secret, &format!("{} {}", ¤t[..3], ¤t[3..]), now, 0), Some(step)); |
| 525 | assert_eq!(verify_totp(secret, "abcdef", now, 0), None); |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn a_code_is_never_accepted_twice() { |
| 530 | let secret = b"12345678901234567890"; |
| 531 | let now = 1_111_111_111; |
| 532 | let step = step_at(now); |
| 533 | let current = hotp(secret, step); |
| 534 | let used = verify_totp(secret, ¤t, now, 0).unwrap(); |
| 535 | // Replayed: the step it was used for is the last, so it is refused. |
| 536 | assert_eq!(verify_totp(secret, ¤t, now, used), None); |
| 537 | // And an older code, still in the window, is refused after it. |
| 538 | assert_eq!(verify_totp(secret, &hotp(secret, step - 1), now, used), None); |
| 539 | // The next step's code is fine. |
| 540 | assert_eq!(verify_totp(secret, &hotp(secret, step + 1), now + 30, used), Some(step + 1)); |
| 541 | } |
| 542 | |
| 543 | #[test] |
| 544 | fn recovery_codes_are_two_groups_and_kept_as_hashes() { |
| 545 | let code = new_recovery_code(); |
| 546 | assert_eq!(code.len(), 11); |
| 547 | assert_eq!(&code[5..6], "-"); |
| 548 | assert_eq!(recovery_hash(&code), recovery_hash(&code.to_uppercase().replace('-', " "))); |
| 549 | assert_ne!(recovery_hash(&code), code); |
| 550 | } |
| 551 | } |