OXYGENHUB

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