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:
1 parent
677fe0fc0a
commit
5e20710aa7
16 files changed
+489
-8
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user