Skip to main content

max / makenotwork

12.4 KB · 380 lines History Blame Raw
1 //! TOTP 2FA management API: setup, confirm, disable, backup codes, status.
2
3 use axum::{
4 extract::State,
5 response::{Html, IntoResponse, Response},
6 Form,
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 auth::{verify_password, AuthUser},
12 constants::{BACKUP_CODE_COUNT, BACKUP_CODE_LENGTH, TOTP_DIGITS, TOTP_SKEW, TOTP_STEP},
13 db,
14 error::{AppError, Result, ResultExt},
15 helpers::hx_toast,
16 templates::{TotpSetupTemplate, TotpStatusTemplate},
17 AppState,
18 };
19
20 /// Generate a TOTP secret, QR code, and backup codes (does not enable 2FA yet).
21 #[tracing::instrument(skip_all, name = "totp::setup")]
22 pub(super) async fn setup(
23 State(state): State<AppState>,
24 AuthUser(user): AuthUser,
25 ) -> Result<Response> {
26 user.check_not_sandbox()?;
27 // Generate a 20-byte (160-bit) random secret
28 use rand::Rng;
29 let secret_bytes: Vec<u8> = (0..20).map(|_| rand::rng().random()).collect();
30
31 let totp = totp_rs::TOTP::new(
32 totp_rs::Algorithm::SHA1,
33 TOTP_DIGITS,
34 TOTP_SKEW,
35 TOTP_STEP,
36 secret_bytes,
37 Some("Makenotwork".to_string()),
38 user.email.clone(),
39 )
40 .context("totp generation")?;
41
42 let secret_base32 = totp.get_secret_base32();
43
44 // Store the secret (not yet enabled)
45 db::totp::set_totp_secret(&state.db, user.id, &secret_base32).await?;
46
47 // Generate QR code as base64 PNG
48 let qr_base64 = totp
49 .get_qr_base64()
50 .map_err(|e| AppError::Internal(anyhow::anyhow!("qr code generation: {e}")))?;
51
52 // Generate backup codes
53 let backup_codes = generate_backup_codes();
54 let code_hashes: Vec<String> = backup_codes
55 .iter()
56 .map(|code| hash_backup_code(code, &state.config.signing_secret))
57 .collect();
58
59 db::totp::create_backup_codes(&state.db, user.id, &code_hashes).await?;
60
61 Ok(TotpSetupTemplate {
62 qr_base64,
63 secret_base32,
64 backup_codes,
65 }
66 .into_response())
67 }
68
69 /// Verify the user's first TOTP code and enable 2FA.
70 #[derive(Deserialize)]
71 pub struct ConfirmForm {
72 code: String,
73 }
74
75 #[tracing::instrument(skip_all, name = "totp::confirm")]
76 pub(super) async fn confirm(
77 State(state): State<AppState>,
78 AuthUser(user): AuthUser,
79 Form(form): Form<ConfirmForm>,
80 ) -> Result<Response> {
81 let secret = db::totp::get_totp_secret(&state.db, user.id)
82 .await?
83 .ok_or_else(|| AppError::BadRequest("2FA setup not started".to_string()))?;
84
85 let totp = build_totp(&secret, &user.email)?;
86
87 if !totp.check_current(&form.code).map_err(|e| {
88 AppError::Internal(anyhow::anyhow!("TOTP check failed: {}", e))
89 })? {
90 return Ok((
91 [("HX-Retarget", "#totp-confirm-status"), ("HX-Reswap", "innerHTML")],
92 Html("<span class=\"save-error\">Invalid code. Please try again.</span>"),
93 )
94 .into_response());
95 }
96
97 // Record the matched step to prevent replay on the very first login
98 let now = chrono::Utc::now().timestamp() as u64;
99 if let Some(step) = find_matching_step(&totp, &form.code, now) {
100 db::totp::set_totp_last_used_step(&state.db, user.id, step).await?;
101 }
102
103 db::totp::enable_totp(&state.db, user.id).await?;
104
105 Ok((
106 [("HX-Trigger", hx_toast("Two-factor authentication enabled", "success"))],
107 TotpStatusTemplate { enabled: true },
108 )
109 .into_response())
110 }
111
112 /// Disable 2FA (requires password confirmation).
113 #[derive(Deserialize)]
114 pub struct DisableForm {
115 password: String,
116 }
117
118 #[tracing::instrument(skip_all, name = "totp::disable")]
119 pub(super) async fn disable(
120 State(state): State<AppState>,
121 AuthUser(user): AuthUser,
122 Form(form): Form<DisableForm>,
123 ) -> Result<Response> {
124 // Verify password
125 let db_user = db::users::get_user_by_id(&state.db, user.id)
126 .await?
127 .ok_or(AppError::Unauthorized)?;
128
129 if !verify_password(&form.password, &db_user.password_hash)? {
130 return Ok((
131 [("HX-Retarget", "#totp-disable-status"), ("HX-Reswap", "innerHTML")],
132 Html("<span class=\"save-error\">Incorrect password.</span>"),
133 )
134 .into_response());
135 }
136
137 db::totp::disable_totp(&state.db, user.id).await?;
138
139 Ok((
140 [("HX-Trigger", hx_toast("Two-factor authentication disabled", "success"))],
141 TotpStatusTemplate { enabled: false },
142 )
143 .into_response())
144 }
145
146 /// Regenerate backup codes (requires password confirmation).
147 #[derive(Deserialize)]
148 pub struct RegenerateForm {
149 password: String,
150 }
151
152 #[tracing::instrument(skip_all, name = "totp::regenerate_backup_codes")]
153 pub(super) async fn regenerate_backup_codes(
154 State(state): State<AppState>,
155 AuthUser(user): AuthUser,
156 Form(form): Form<RegenerateForm>,
157 ) -> Result<Response> {
158 // Verify password
159 let db_user = db::users::get_user_by_id(&state.db, user.id)
160 .await?
161 .ok_or(AppError::Unauthorized)?;
162
163 if !verify_password(&form.password, &db_user.password_hash)? {
164 return Ok((
165 [("HX-Retarget", "#backup-regen-status"), ("HX-Reswap", "innerHTML")],
166 Html("<span class=\"save-error\">Incorrect password.</span>"),
167 )
168 .into_response());
169 }
170
171 let backup_codes = generate_backup_codes();
172 let code_hashes: Vec<String> = backup_codes
173 .iter()
174 .map(|code| hash_backup_code(code, &state.config.signing_secret))
175 .collect();
176
177 db::totp::create_backup_codes(&state.db, user.id, &code_hashes).await?;
178
179 // Return the new codes as an HTML partial
180 let codes_html: String = backup_codes
181 .iter()
182 .map(|c| format!("<code>{}</code>", c))
183 .collect::<Vec<_>>()
184 .join("\n");
185
186 Ok((
187 [("HX-Trigger", hx_toast("Backup codes regenerated", "success"))],
188 Html(format!(
189 "<div class=\"backup-codes-grid\">\n{}\n</div>\n<p style=\"opacity: 0.7; font-size: 0.85rem; margin-top: 0.75rem;\">Save these codes somewhere safe. Each code can only be used once.</p>",
190 codes_html
191 )),
192 )
193 .into_response())
194 }
195
196 /// Return the current 2FA status as an HTMX partial for the dashboard.
197 #[tracing::instrument(skip_all, name = "totp::status")]
198 pub(super) async fn status(
199 State(state): State<AppState>,
200 AuthUser(user): AuthUser,
201 ) -> Result<Response> {
202 let enabled = db::totp::is_totp_enabled(&state.db, user.id).await?;
203
204 Ok(TotpStatusTemplate { enabled }.into_response())
205 }
206
207 // ── Helpers ─────────────────────────────────────────────────────────────────
208
209 /// Build a TOTP instance from a stored base32 secret.
210 pub(crate) fn build_totp(secret_base32: &str, account_name: &str) -> Result<totp_rs::TOTP> {
211 let secret_bytes = totp_rs::Secret::Encoded(secret_base32.to_string())
212 .to_bytes()
213 .context("parse totp secret")?;
214
215 totp_rs::TOTP::new(
216 totp_rs::Algorithm::SHA1,
217 TOTP_DIGITS,
218 TOTP_SKEW,
219 TOTP_STEP,
220 secret_bytes,
221 Some("Makenotwork".to_string()),
222 account_name.to_string(),
223 )
224 .context("totp creation")
225 }
226
227 /// Generate random alphanumeric backup codes.
228 fn generate_backup_codes() -> Vec<String> {
229 use rand::Rng;
230 let mut rng = rand::rng();
231
232 (0..BACKUP_CODE_COUNT)
233 .map(|_| {
234 (0..BACKUP_CODE_LENGTH)
235 .map(|_| {
236 let idx: u8 = rng.random_range(0..36);
237 if idx < 10 {
238 (b'0' + idx) as char
239 } else {
240 (b'a' + idx - 10) as char
241 }
242 })
243 .collect()
244 })
245 .collect()
246 }
247
248 /// Find which TOTP time step a code matches, returning the step number.
249 ///
250 /// This is used instead of `totp.check_current()` so we can store the
251 /// *matched* step for replay prevention, not just the wall-clock step.
252 /// Without this, a code valid for step N can be replayed at step N+1
253 /// within the skew window.
254 pub(crate) fn find_matching_step(totp: &totp_rs::TOTP, code: &str, time_secs: u64) -> Option<i64> {
255 let base_step = time_secs / TOTP_STEP;
256 let skew = TOTP_SKEW as u64;
257 let start = base_step.saturating_sub(skew);
258 for i in 0..=(skew * 2) {
259 let step = start + i;
260 let step_time = step * TOTP_STEP;
261 let expected = totp.generate(step_time);
262 if crate::crypto::constant_time_compare(&expected, code) {
263 return Some(step as i64);
264 }
265 }
266 None
267 }
268
269 /// Argon2id hash of a backup code.
270 ///
271 /// Backup codes have only ~41 bits of entropy (8 alphanumeric chars), so the
272 /// previous HMAC-SHA256 scheme was brute-forceable in minutes if the DB and
273 /// the server's signing secret both leaked. Argon2id with even modest
274 /// parameters multiplies that work by ~10^5, putting offline attack in the
275 /// "needs a real GPU farm and time" range.
276 ///
277 /// Uses lower-than-password params (8 MiB, 1 iteration) — backup codes are
278 /// random tokens, not user-chosen passwords, so the security floor is set by
279 /// the wordlist, not the hash function. Per-code unique salt.
280 pub(crate) fn hash_backup_code(code: &str, _secret: &str) -> String {
281 use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
282 use argon2::{Algorithm, Argon2, Params, Version};
283
284 let salt = SaltString::generate(&mut OsRng);
285 let params = Params::new(8 * 1024, 1, 1, None)
286 .expect("argon2 backup-code params are valid");
287 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
288 let hash = argon2
289 .hash_password(code.as_bytes(), &salt)
290 .expect("argon2 backup-code hashing");
291 hash.to_string()
292 }
293
294 /// Legacy HMAC-SHA256 hash kept for verifying pre-migration backup codes.
295 ///
296 /// Used only as a fallback inside `verify_and_consume_backup_code` when the
297 /// stored hash isn't an Argon2 PHC string. Do not call from new code paths —
298 /// any newly issued backup code goes through `hash_backup_code` (Argon2).
299 pub(crate) fn legacy_hmac_backup_code(code: &str, secret: &str) -> String {
300 use hmac::{Hmac, Mac};
301 use sha2::Sha256;
302
303 let mut mac =
304 Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
305 mac.update(code.as_bytes());
306 hex::encode(mac.finalize().into_bytes())
307 }
308
309 #[cfg(test)]
310 mod tests {
311 use super::*;
312
313 #[test]
314 fn backup_code_generation_produces_correct_count() {
315 let codes = generate_backup_codes();
316 assert_eq!(codes.len(), BACKUP_CODE_COUNT);
317 }
318
319 #[test]
320 fn backup_codes_are_correct_length() {
321 let codes = generate_backup_codes();
322 for code in &codes {
323 assert_eq!(code.len(), BACKUP_CODE_LENGTH);
324 }
325 }
326
327 #[test]
328 fn backup_codes_are_alphanumeric() {
329 let codes = generate_backup_codes();
330 for code in &codes {
331 assert!(
332 code.chars().all(|c| c.is_ascii_alphanumeric()),
333 "Code should be alphanumeric: {}",
334 code
335 );
336 }
337 }
338
339 #[test]
340 fn backup_codes_are_unique() {
341 let codes = generate_backup_codes();
342 let unique: std::collections::HashSet<&String> = codes.iter().collect();
343 assert_eq!(unique.len(), codes.len());
344 }
345
346 #[test]
347 fn hash_backup_code_is_argon2_phc() {
348 // Argon2 hashes are non-deterministic (unique salt per call) and use
349 // the PHC string format that starts with `$argon2`.
350 let h = hash_backup_code("abc12345", "secret");
351 assert!(h.starts_with("$argon2"), "got {h}");
352 }
353
354 #[test]
355 fn hash_backup_code_non_deterministic() {
356 // Two hashes of the same code must differ — distinct salts.
357 let h1 = hash_backup_code("abc12345", "secret");
358 let h2 = hash_backup_code("abc12345", "secret");
359 assert_ne!(h1, h2);
360 }
361
362 #[test]
363 fn hash_backup_code_verifies_against_itself() {
364 use argon2::{password_hash::PasswordVerifier, Argon2, PasswordHash};
365 let h = hash_backup_code("abc12345", "ignored");
366 let parsed = PasswordHash::new(&h).unwrap();
367 assert!(Argon2::default().verify_password(b"abc12345", &parsed).is_ok());
368 assert!(Argon2::default().verify_password(b"wrong", &parsed).is_err());
369 }
370
371 #[test]
372 fn legacy_hmac_is_deterministic_and_secret_keyed() {
373 let h1 = legacy_hmac_backup_code("abc12345", "secret");
374 let h2 = legacy_hmac_backup_code("abc12345", "secret");
375 assert_eq!(h1, h2);
376 let h3 = legacy_hmac_backup_code("abc12345", "different-secret");
377 assert_ne!(h1, h3);
378 }
379 }
380