Passwort-Reset per Einmal-Link (kver reset-link)

Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>),
gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es
steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet
alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt
gemeinsam in checkNewPassword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-10-01 21:56:05 +02:00
co-authored by Claude Opus 5.5
parent b6cf8c7fa0
commit 81a74375b7
12 changed files with 408 additions and 14 deletions
+83
View File
@@ -687,6 +687,89 @@ func TestDeleteUser(t *testing.T) {
}
}
func TestPasswordReset(t *testing.T) {
srv := newTestServer(t)
old := registerAndLogin(t, srv, "alice")
token, _, err := createResetToken("alice")
if err != nil {
t.Fatalf("createResetToken: %v", err)
}
if _, _, err := createResetToken("niemand"); err != errUserNotFound {
t.Fatalf("unbekannter Nutzer: erwartet errUserNotFound, bekam %v", err)
}
c := newClient(t)
status := func(path string, form url.Values) int {
t.Helper()
resp := postForm(t, c, srv.URL+path, form)
resp.Body.Close()
return resp.StatusCode
}
// Vorab-Prüfung: gültig bzw. unbekannt.
if got := status("/api/auth/reset/check", url.Values{"token": {token}}); got != http.StatusOK {
t.Fatalf("check: erwartet 200, bekam %d", got)
}
if got := status("/api/auth/reset/check", url.Values{"token": {"falsch"}}); got != http.StatusNotFound {
t.Fatalf("check mit falschem Token: erwartet 404, bekam %d", got)
}
// Zu kurzes Passwort -> 400, der Link bleibt gültig.
if got := status("/api/auth/reset", url.Values{
"token": {token}, "pass1": {"kurz"}, "pass2": {"kurz"},
}); got != http.StatusBadRequest {
t.Fatalf("kurzes Passwort: erwartet 400, bekam %d", got)
}
if got := status("/api/auth/reset", url.Values{
"token": {token}, "pass1": {"neuesgeheimnis"}, "pass2": {"neuesgeheimnis"},
}); got != http.StatusOK {
t.Fatalf("reset: erwartet 200, bekam %d", got)
}
// Direkt eingeloggt; die alte Session ist weg.
if got := getStatus(t, c, srv.URL+"/api/user/info"); got != http.StatusOK {
t.Fatalf("nach Reset eingeloggt: erwartet 200, bekam %d", got)
}
if got := getStatus(t, old, srv.URL+"/api/user/info"); got != http.StatusUnauthorized {
t.Fatalf("alte Session nach Reset: erwartet 401, bekam %d", got)
}
// Link gilt nur einmal.
if got := status("/api/auth/reset", url.Values{
"token": {token}, "pass1": {"nochandersxx"}, "pass2": {"nochandersxx"},
}); got != http.StatusNotFound {
t.Fatalf("Link zweimal benutzt: erwartet 404, bekam %d", got)
}
// Altes Passwort tot, neues geht.
login := func(pass string) int {
resp := postForm(t, newClient(t), srv.URL+"/api/auth/login", url.Values{
"user": {"alice"}, "pass": {pass}, "timeout": {"86400"},
})
resp.Body.Close()
return resp.StatusCode
}
if got := login("supersecret1"); got != http.StatusUnauthorized {
t.Fatalf("altes Passwort: erwartet 401, bekam %d", got)
}
if got := login("neuesgeheimnis"); got != http.StatusOK {
t.Fatalf("neues Passwort: erwartet 200, bekam %d", got)
}
// Ein neuer Link macht den vorigen ungültig; abgelaufene gelten nicht.
first, _, _ := createResetToken("alice")
second, _, _ := createResetToken("alice")
if got := status("/api/auth/reset/check", url.Values{"token": {first}}); got != http.StatusNotFound {
t.Fatalf("überholter Link: erwartet 404, bekam %d", got)
}
db.Exec(`UPDATE password_reset SET expires = 0`)
if got := status("/api/auth/reset/check", url.Values{"token": {second}}); got != http.StatusNotFound {
t.Fatalf("abgelaufener Link: erwartet 404, bekam %d", got)
}
}
// postFormFrom schickt wie postForm als API-Client (Accept: application/json),
// zusätzlich mit gesetzter Client-IP.
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP