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:
co-authored by
Claude Opus 5.5
parent
b6cf8c7fa0
commit
81a74375b7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user