Let people change their own password, and operators reset a lost one
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:
Ilker ManapandClaude Opus 5 committed 2026-09-16 09:39:11 +02:00
1 parent 216fc9bb03
commit 79e459d9ff
10 files changed
+437 -24

No files matched your search

+78
View File
@@ -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)
}
}