Passwort-Reset: Review-Fixes

- Token vor bcrypt prüfen, damit beliebige Tokens keine CPU-Last erzeugen
- CLI meldet DB-Fehler nicht mehr als "Nutzer nicht gefunden"
- Reset in einem Browser mit fremder Session räumt diese mit ab
- Konto-Löschen entfernt auch offene Reset-Links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-10-02 00:44:32 +02:00
co-authored by Claude Opus 5.5
parent 81a74375b7
commit 764ad50b8e
3 changed files with 61 additions and 10 deletions
+32 -7
View File
@@ -36,8 +36,11 @@ func hashResetToken(token string) string {
// ungültig, abgelaufene aller Nutzer weggeräumt.
func createResetToken(username string) (string, time.Time, error) {
var uid int64
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, username).Scan(&uid); err != nil {
err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, username).Scan(&uid)
if err == sql.ErrNoRows {
return "", time.Time{}, errUserNotFound
} else if err != nil {
return "", time.Time{}, err
}
token, err := newToken()
@@ -68,19 +71,29 @@ func createResetToken(username string) (string, time.Time, error) {
// (oder gleich "abgelaufen" melden) kann, bevor jemand ein Passwort tippt.
// POST statt GET, damit das Token nicht in der URL und damit im Log steht.
func handleResetCheck(w http.ResponseWriter, r *http.Request) error {
username, err := resetUsername(r.FormValue("token"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
return nil
}
// resetUsername liefert den Nutzer zu einem gültigen Link, ohne ihn zu
// verbrauchen.
func resetUsername(token string) (string, error) {
var username string
err := db.QueryRow(
`SELECT a.username FROM password_reset p JOIN account a ON a.uid = p.uid
WHERE p.token_hash = $1 AND p.expires >= $2`,
hashResetToken(r.FormValue("token")), time.Now().Unix(),
hashResetToken(token), time.Now().Unix(),
).Scan(&username)
if err == sql.ErrNoRows {
return errResetInvalid
return "", errResetInvalid
} else if err != nil {
return Internal(err)
return "", Internal(err)
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
return nil
return username, nil
}
// handleReset setzt das neue Passwort (pass1/pass2 wie bei der Registrierung),
@@ -97,6 +110,13 @@ func handleReset(w http.ResponseWriter, r *http.Request) error {
return e
}
// Vorab ohne Verbrauch prüfen: bcrypt kostet spürbar CPU und soll nicht
// für jedes beliebige Token anlaufen. Verbindlich ist erst das DELETE unten.
token := r.FormValue("token")
if _, err := resetUsername(token); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass1), bcrypt.DefaultCost)
if err != nil {
return Internal(err)
@@ -113,7 +133,7 @@ func handleReset(w http.ResponseWriter, r *http.Request) error {
var uid int64
err = tx.QueryRow(
`DELETE FROM password_reset WHERE token_hash = $1 AND expires >= $2 RETURNING uid`,
hashResetToken(r.FormValue("token")), time.Now().Unix(),
hashResetToken(token), time.Now().Unix(),
).Scan(&uid)
if err == sql.ErrNoRows {
return errResetInvalid
@@ -139,6 +159,11 @@ func handleReset(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// War im Browser noch jemand anderes angemeldet, wird dessen Cookie gleich
// überschrieben -- die Session dazu soll dann nicht verwaist gültig bleiben.
if s, ok := getSession(r); ok {
db.Exec(`DELETE FROM session WHERE value = $1`, s.Value)
}
if err := startSession(w, r, uid); err != nil {
return err
}