From 8c89329ca48fea207216721c536311f8cc23b64b Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 14:47:55 +0300 Subject: [PATCH] Ask for the new password twice Changing your own password had one input for it. Nothing can be read back, and unlike the current password it has never been typed before, so a typo saves silently and locks you out of an account you could reach a moment earlier. The only way back is an admin reset. The admin's reset of someone else's password is untouched: that is the recovery path, not a self-service change. --- src/profile.go | 7 +++++ src/profile_test.go | 62 ++++++++++++++++++++++++++++++++++++++ src/templates/profile.html | 2 ++ 3 files changed, 71 insertions(+) create mode 100644 src/profile_test.go diff --git a/src/profile.go b/src/profile.go index 234800c..3bcc096 100644 --- a/src/profile.go +++ b/src/profile.go @@ -132,6 +132,13 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) { } if newPassword := r.FormValue("new_password"); newPassword != "" { + // A typo here would lock them out of an account they can still reach right now, and the + // only way back is an admin reset. + if newPassword != r.FormValue("new_password_repeat") { + a.flash(w, "Uudet salasanat eivät täsmää.") + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) { return } diff --git a/src/profile_test.go b/src/profile_test.go new file mode 100644 index 0000000..a297a8d --- /dev/null +++ b/src/profile_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "net/http" + "net/url" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +// The repeat field is the only guard against a typo in something nobody can read back: the account +// is reachable right now, and a mistyped new password makes it reachable only through an admin. +func TestPasswordChangeNeedsMatchingRepeat(t *testing.T) { + a := testApp(t) + ctx := context.Background() + mux := a.withMember(a.memberMux()) + + id := a.seedMember(t, "esa@example.com") + hash, err := bcrypt.GenerateFromPassword([]byte("vanha1"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, + `update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil { + t.Fatal(err) + } + tok, _, err := a.startSession(ctx, id, false) + if err != nil { + t.Fatal(err) + } + + form := func(repeat string) url.Values { + return url.Values{ + "name": {"Esa"}, "email": {"esa@example.com"}, + "current_password": {"vanha1"}, + "new_password": {"uusi1"}, "new_password_repeat": {repeat}, + } + } + current := func() string { + var h string + if err := a.db.QueryRowContext(ctx, + `select password_hash from users where id = $1`, id).Scan(&h); err != nil { + t.Fatal(err) + } + return h + } + + if w := postAs(t, mux, "/profile", form("uusi2"), tok); w.Code != http.StatusSeeOther { + t.Fatalf("mismatch: status = %d, want 303", w.Code) + } + if bcrypt.CompareHashAndPassword([]byte(current()), []byte("vanha1")) != nil { + t.Fatal("a mismatched repeat still changed the password") + } + + if w := postAs(t, mux, "/profile", form("uusi1"), tok); w.Code != http.StatusSeeOther { + t.Fatalf("match: status = %d, want 303", w.Code) + } + if bcrypt.CompareHashAndPassword([]byte(current()), []byte("uusi1")) != nil { + t.Fatal("a matching repeat did not change the password") + } +} diff --git a/src/templates/profile.html b/src/templates/profile.html index 89c9670..2afbc57 100644 --- a/src/templates/profile.html +++ b/src/templates/profile.html @@ -50,6 +50,8 @@ + +

Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.