Fix 2FA sign-in: the second step sent password twice 1b3a00986a5a
Reported by Andy: "Failed to deserialize form body: duplicate field password" after entering the code. The second step's form kept a leftover hidden, empty password field beside the real one. The test now submits the rendered form's own fields and refuses any name given twice; it fails with the old form. closes #54
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 17:33 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1b3a00986a5af27d50714ac69a47297eb1eea88fa1017ee53bbc8d963d723859- Depends on
- 19bb31ff1f5c
- Depended on by
- bff9106edcba
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 4 atoms, 0 tree operations, 2 files
Browse the tree at this change
modified crates/hub/tests/accounts.rs +19 −1
| … | 275 unchanged lines |
||
| 276 | 276 | assert!(r.body.contains("Recovery codes"), "{}", r.body); |
|
| 277 | 277 | let r = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; |
|
| 278 | 278 | assert!(r.body.contains("authenticator"), "second step asked: {}", r.body); |
|
| 279 | + | // Submit the second step as a browser would: every field of the |
|
| 280 | + | // rendered form, each name once (a repeated name is refused). |
|
| 281 | + | let form = &r.body[r.body.find("<form class=\"card\" method=\"post\" action=\"/login\">").unwrap()..]; |
|
| 282 | + | let form = &form[..form.find("</form>").unwrap()]; |
|
| 283 | + | let names: Vec<&str> = form.split("name=\"").skip(1).map(|t| t.split('"').next().unwrap()).collect(); |
|
| 284 | + | let mut unique = names.clone(); |
|
| 285 | + | unique.sort(); |
|
| 286 | + | unique.dedup(); |
|
| 287 | + | assert_eq!(unique.len(), names.len(), "a field named twice: {names:?}"); |
|
| 279 | 288 | let code = oxygenhub::auth::totp::current(&secret, (db::now_ms() / 1000) as u64).unwrap(); |
|
| 280 | - | let r = post(app, "/login", None, &format!("username=andy&password=correct-horse-battery&code={code}")).await; |
|
| 289 | + | let body: Vec<String> = names |
|
| 290 | + | .iter() |
|
| 291 | + | .map(|n| match *n { |
|
| 292 | + | "username" => "username=andy".to_string(), |
|
| 293 | + | "password" => "password=correct-horse-battery".to_string(), |
|
| 294 | + | "code" => format!("code={code}"), |
|
| 295 | + | other => format!("{other}="), |
|
| 296 | + | }) |
|
| 297 | + | .collect(); |
|
| 298 | + | let r = post(app, "/login", None, &body.join("&")).await; |
|
| 281 | 299 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 282 | 300 | assert!(r.cookie.is_some()); |
|
| 283 | 301 | assert!(r.location.as_deref() == Some("/")); |
|
| … | 1 unchanged lines |
modified templates/login.html +0 −1
| … | 6 unchanged lines |
||
| 7 | 7 | <input type="hidden" name="next" value="{{ next }}"> |
|
| 8 | 8 | {% if let Some(u) = totp_for %} |
|
| 9 | 9 | <input type="hidden" name="username" value="{{ u }}"> |
|
| 10 | - | <input type="hidden" name="password" value="" id="pw"> |
|
| 11 | 10 | <p>Signed in as <strong>{{ u }}</strong>. Enter the six-digit code from your authenticator app, or a recovery code.</p> |
|
| 12 | 11 | <label for="code">Code</label><input type="text" id="code" name="code" autocomplete="one-time-code" inputmode="numeric" autofocus> |
|
| 13 | 12 | <label for="password2">Password (again, to keep this step honest)</label><input type="password" id="password2" name="password" autocomplete="current-password"> |
|
| … | 8 unchanged lines |