feat(auth): reset a forgotten password by mail

"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL
are configured. The request always gets the same answer and sends the mail
in the background (at most every 2 minutes per account). The link holds a
random token valid once for one hour; only its SHA-256 is stored. Setting
the new password invalidates all open links and ends every session. The
app removes the token from the address bar and offers a way back for
expired links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:06:56 +01:00
1 parent 677fe0fc0a
commit 5e20710aa7
16 files changed
+489 -8

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"path/filepath"
"testing"
)
func TestPasswordResetToken(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "reset.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "old", "Anna", "member")
raw, err := store.CreatePasswordResetToken(u.ID)
if err != nil || len(raw) != 64 {
t.Fatalf("token: %q %v", raw, err)
}
var stored string
_ = store.db.QueryRow(`SELECT token_hash FROM password_reset_tokens`).Scan(&stored)
if stored == raw {
t.Fatalf("only the hash may be stored")
}
if _, err := store.CreatePasswordResetToken(u.ID); err != ErrResetTooSoon {
t.Errorf("second request within the cooldown must be refused, got %v", err)
}
before, _ := store.GetUserByID(u.ID)
if _, err := store.ResetPasswordWithToken("falsch", "new"); err != ErrResetTokenInvalid {
t.Errorf("unknown token must fail")
}
if id, err := store.ResetPasswordWithToken(raw, "new"); err != nil || id != u.ID {
t.Fatalf("reset: %v", err)
}
after, _ := store.GetUserByID(u.ID)
if after.PasswordHash != "new" || after.TokenVersion != before.TokenVersion+1 {
t.Errorf("password not changed or sessions not ended: %+v", after)
}
if _, err := store.ResetPasswordWithToken(raw, "again"); err != ErrResetTokenInvalid {
t.Errorf("a token works only once")
}
// Expired tokens fail
_, _ = store.db.Exec(`DELETE FROM password_reset_tokens`)
raw2, _ := store.CreatePasswordResetToken(u.ID)
_, _ = store.db.Exec(`UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00Z'`)
if _, err := store.ResetPasswordWithToken(raw2, "x"); err != ErrResetTokenInvalid {
t.Errorf("expired token must fail")
}
}