Fix auth throttle crash, session disconnect on password change, TOTP gating #24
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/auth-security"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Audit auth/security group.
Verified: 137 targeted tests pass; xref under threshold.
🤖 Skippy PR review
2 findings — 2 blocking before merge.
lib/microwaveprop/accounts.ex:467lib/microwaveprop_web/controllers/user_totp_controller.ex:26Reviewed
d341b46ea10e. Commentskippy reviewto re-run.@ -453,3 +465,3 @@user|> User.password_changeset(attrs)|> update_user_and_delete_all_tokens()|> Ecto.Changeset.change(totp_secret: nil, totp_confirmed_at: nil, totp_last_used_at: nil)🟠 High — Password reset silently disables the second factor
reset_user_password/2nils the TOTP secret andclear_totp: truedeletes the recovery codes, so the factor that exists to survive a mailbox compromise is removed by a mailbox compromise: an attacker who can read (or intercept, or forward) the reset mail completes the reset and logs in with only the new password. The docstring's "attacker-enrolled factor" case needs the account password inside the sudo window to reach enrollment at all (this PR's ownrequire_sudo_mode), so it is a much rarer path than mailbox access. Require the second factor on the reset form instead: whentotp_enabled?(user)is true, add a code field toedit/2and verify it (or a recovery code) inupdate/2before calling this function, then rotate the secret and mail the owner a notice. At minimum keep the secret and say in the post-reset flash that two-factor was turned off.@ -20,2 +23,4 @@alias Microwaveprop.Accountsplug :require_sudo_mode when action in [:create, :confirm]🟡 Warning — Teardown is an unthrottled password-verification endpoint
delete/2spends a full bcryptAccounts.disable_totp/2password check per request, and it is the one password-checking action here that is neither sudo-gated nor throttled: the:browserpipeline has noAuthThrottleand this plug covers only[:create, :confirm], while login, two-factor and the reset forms all wire one. The response is a clean oracle (302 with "That password is not correct." versus "Two-factor authentication is off..."), so the hijacked session this PR is hardening against can grind the account password at one bcrypt per request with no limit and no lockout, then re-login through the (per-IP/per-identifier) login form and use the sudo window to change the email. Either add:deleteto the sudo-gated set so the guess has to pass the throttled login form, or throttle it directly:plug AuthThrottle, [scope: :totp_teardown, id_path: nil, ip_limit: 10] when action in [:delete](the account lives in the session, so there is no identifier to bucket on — same shape as the:two_factorscope).d341b46ea1d35fb328aad35fb328aa77b4649dcbAddressed in
77b4649d:update/2now verifies a TOTP or recovery code viaAccounts.verify_totp/2before callingreset_user_password/2whentotp_enabled?(user). The form renders a code field only for factor-enabled accounts, andPUT /users/reset-password/:tokenis AuthThrottle'd per token. Clearing the factor after a verified reset is kept so the owner re-enrolls cleanly (and an attacker-enrolled factor can't survive).:deletenow sits behindrequire_sudo_modealongside:create/:confirm, so the password re-check runs inside the throttled re-authentication path.🤖 Skippy PR review
3 findings — 2 blocking before merge.
lib/microwaveprop_web/controllers/user_reset_password_controller.ex:77lib/microwaveprop_web/controllers/user_reset_password_html/edit.html.heex:51lib/microwaveprop_web/controllers/user_reset_password_controller.ex:80Resolved 2 earlier findings: the reset form now verifies a live TOTP or recovery code, and teardown is behind require_sudo_mode. Nothing from the earlier review is still open.
Reviewed
77b4649dcb4d. Commentskippy reviewto re-run.@ -72,0 +74,4 @@# too — otherwise mailbox access alone strips the factor that exists# to survive a mailbox compromise. `reset_user_password/2` still# clears TOTP afterwards so the owner re-enrolls cleanly.with :ok <- require_totp(user, user_params),🟡 Warning — The second factor is spent before the new password is validated
require_totp/2runs beforeAccounts.reset_user_password/2, and verification is what consumes the factor: a live TOTP code stampstotp_last_used_at, so the replay guard then refuses that same code for the rest of its 30 s step, and a recovery code stampsused_atpermanently. A password rejected byUser.password_changeset(12+ chars, mixed classes, denylist: a first attempt fails often) therefore burns the code and only then re-renders the form, where the user's still-valid code is now reported as "did not match", and a recovery-code user silently spends one of their 8 on a reset that never happened. Validate the password first, verify the factor second:where
validate_password/1returns{:error, changeset}whenchangeset.valid?is false.@ -72,0 +77,4 @@with :ok <- require_totp(user, user_params),{:ok, _} <- Accounts.reset_user_password(user, user_params) doconn|> put_flash(:info, "Password reset successfully.")🔵 Suggestion — The success flash does not say the second factor was turned off
reset_user_password/2clears the secret and deletes the recovery codes, but the flash only reports the password change, so an account that had two-factor on comes back with one factor and no notice: the owner expects a code prompt at the next login, does not get one, and has no reason to open settings. Since the new code-side check means the reset can only be completed by someone holding a factor, this is now the residual of the earlier finding rather than a bypass. Say it in the flash ("Your password was reset. Two-factor authentication was turned off, so set it up again in Settings.") or mail the account.@ -45,0 +48,4 @@type="text"label="Two-factor code (or a recovery code)"autocomplete="one-time-code"inputmode="numeric"🟡 Warning — inputmode="numeric" makes the recovery-code path unusable on a phone
Recovery codes are 16-character base32 (
UserTotpRecoveryCode.build_set/1usesBase.encode32), so they contain letters, but this field asks for a numeric keypad: iOS and Android both present a digits-only keyboard forinputmode="numeric", so a phone user who lost their authenticator and is resetting with a recovery code cannot type it into the field whose label tells them to. The login prompt solves the same problem withinputmode="text"(user_session_html/two_factor.html.heex:24) while its subtitle invites recovery codes. Useinputmode="text"here as well; nothing about the field needs a digit-only keyboard, since six-digit codes type fine on the default one.