Let people change their own password, and operators reset a lost one
CI / test (push) Failing after 1m11s
CI / test (push) Failing after 1m11s
The account system shipped without either, which made the first forgotten password an unanswerable support request. Changing it requires the current one. A session is what an attacker gets from a borrowed laptop or a stolen cookie; the password is what they do not have, and without the check the first thing they would do is take the account. Every session is closed, including the browser doing the changing — leaving the others open would mean a password change does nothing about the reason someone is changing it. A new session is then opened for the browser in front of us, so the person who just proved their password is not thrown back to the sign-in page. That response also carries a new CSRF cookie, so the forms it renders take the new token rather than the one the request arrived with; there is a test that posts a form from that exact page. Guessing the current password is bounded the same way sign-in is, because someone holding a stolen session should not get unlimited attempts at the password that would let them keep it. There is no self-service reset: this gateway sends no email, and a reset link with nowhere to send it is not a reset. `llmgw users reset-password` is the operator's answer, and it closes their sessions too — if someone lost their password, the reason might be that somebody else has it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
216fc9bb03
commit
79e459d9ff
10 files changed
+437
-24
No files matched your search
@@ -317,3 +317,81 @@ func signUpAndApprove(t *testing.T, b *browser, path, email string) string {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestChangingAPasswordKeepsThisBrowserAndDropsTheOthers(t *testing.T) {
|
||||
// Both halves matter. Signing every session out is the security property;
|
||||
// keeping this one is what stops the person who just proved their password
|
||||
// from being thrown back to the sign-in page.
|
||||
h, path := signupHarness(t)
|
||||
|
||||
laptop := newBrowser(t, h.router.URL)
|
||||
signUpAndApprove(t, laptop, path, "ayse@example.com")
|
||||
|
||||
// A second browser, signed in as the same person.
|
||||
phone := newBrowser(t, h.router.URL)
|
||||
if code, _, _ := phone.post("/login", url.Values{
|
||||
"email": {"ayse@example.com"}, "password": {password},
|
||||
}); code != http.StatusSeeOther {
|
||||
t.Fatalf("second sign-in returned %d", code)
|
||||
}
|
||||
if code, _ := phone.get("/profile"); code != http.StatusOK {
|
||||
t.Fatalf("the second browser was not signed in: %d", code)
|
||||
}
|
||||
|
||||
_, profile := laptop.get("/profile")
|
||||
const newPassword = "an entirely different passphrase"
|
||||
code, body, _ := laptop.post("/profile/password", url.Values{
|
||||
"csrf": {csrf(t, profile)}, "current": {password}, "new": {newPassword},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("changing the password returned %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "password was changed") {
|
||||
t.Errorf("the page does not confirm the change")
|
||||
}
|
||||
|
||||
// This browser is still signed in, and its forms still work — which they
|
||||
// only do if the new CSRF token reached the page that was just rendered.
|
||||
newToken := csrf(t, body)
|
||||
if code, _, _ := laptop.post("/profile/keys", url.Values{
|
||||
"csrf": {newToken}, "name": {"after-change"},
|
||||
}); code != http.StatusOK {
|
||||
t.Errorf("the form on the page after a password change was refused: %d", code)
|
||||
}
|
||||
|
||||
// The other browser is not.
|
||||
if code, _ := phone.get("/profile"); code != http.StatusSeeOther {
|
||||
t.Errorf("the other browser kept its session: %d", code)
|
||||
}
|
||||
|
||||
// And the old password is gone.
|
||||
fresh := newBrowser(t, h.router.URL)
|
||||
if code, _, _ := fresh.post("/login", url.Values{
|
||||
"email": {"ayse@example.com"}, "password": {password},
|
||||
}); code != http.StatusUnauthorized {
|
||||
t.Errorf("the old password still signs in: %d", code)
|
||||
}
|
||||
if code, _, _ := fresh.post("/login", url.Values{
|
||||
"email": {"ayse@example.com"}, "password": {newPassword},
|
||||
}); code != http.StatusSeeOther {
|
||||
t.Errorf("the new password does not sign in: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongCurrentPasswordChangesNothing(t *testing.T) {
|
||||
h, path := signupHarness(t)
|
||||
b := newBrowser(t, h.router.URL)
|
||||
signUpAndApprove(t, b, path, "ayse@example.com")
|
||||
|
||||
_, profile := b.get("/profile")
|
||||
_, body, _ := b.post("/profile/password", url.Values{
|
||||
"csrf": {csrf(t, profile)}, "current": {"wrong"}, "new": {"something else entirely"},
|
||||
})
|
||||
if !strings.Contains(body, "not your current password") {
|
||||
t.Error("the page does not say why it was refused")
|
||||
}
|
||||
// Still signed in, still on the old password.
|
||||
if code, _ := b.get("/profile"); code != http.StatusOK {
|
||||
t.Errorf("a failed change signed the person out: %d", code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user