- 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>
173 lines
5.5 KiB
Go
173 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Passwort-Reset ohne E-Mail: der Admin erzeugt per CLI (kver reset-link) einen
|
|
// Einmal-Link und gibt ihn dem Nutzer persönlich. Der Link zeigt auf
|
|
// /reset#<token> -- im Fragment, damit das Token weder im Access-Log noch in
|
|
// der Impression-Statistik noch im Referer landet. Die Seite schickt es per
|
|
// POST an die API.
|
|
|
|
// resetTTL ist die Gültigkeit eines Reset-Links. Großzügig, weil er von Hand
|
|
// übergeben wird; er gilt ohnehin nur einmal.
|
|
const resetTTL = 72 * time.Hour
|
|
|
|
// errResetInvalid deckt unbekannt, abgelaufen und schon benutzt ab -- für den
|
|
// Nutzer ist das dasselbe: einen neuen Link holen.
|
|
var errResetInvalid = NotFound("reset.invalid", "Der Link ist ungültig oder abgelaufen.")
|
|
|
|
// hashResetToken: in der DB steht nur der Hash. SHA-256 statt bcrypt genügt,
|
|
// das Token ist 256 Bit Zufall und nicht zu erraten.
|
|
func hashResetToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// createResetToken legt für username einen neuen Reset-Link an und liefert das
|
|
// Token im Klartext (nur hier sichtbar). Ältere Links desselben Nutzers werden
|
|
// ungültig, abgelaufene aller Nutzer weggeräumt.
|
|
func createResetToken(username string) (string, time.Time, error) {
|
|
var uid int64
|
|
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()
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
now := time.Now()
|
|
expires := now.Add(resetTTL)
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(`DELETE FROM password_reset WHERE uid = $1 OR expires < $2`, uid, now.Unix()); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO password_reset (uid, token_hash, created_at, expires) VALUES ($1, $2, $3, $4)`,
|
|
uid, hashResetToken(token), now.Unix(), expires.Unix(),
|
|
); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return token, expires, tx.Commit()
|
|
}
|
|
|
|
// handleResetCheck prüft einen Link vorab, damit die Seite den Namen zeigen
|
|
// (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(token), time.Now().Unix(),
|
|
).Scan(&username)
|
|
if err == sql.ErrNoRows {
|
|
return "", errResetInvalid
|
|
} else if err != nil {
|
|
return "", Internal(err)
|
|
}
|
|
return username, nil
|
|
}
|
|
|
|
// handleReset setzt das neue Passwort (pass1/pass2 wie bei der Registrierung),
|
|
// verbraucht den Link, meldet alle bestehenden Sessions ab und loggt den
|
|
// Nutzer direkt neu ein.
|
|
func handleReset(w http.ResponseWriter, r *http.Request) error {
|
|
// Erst die Eingabe prüfen: ein Tippfehler soll den Link nicht verbrauchen.
|
|
pass1 := r.FormValue("pass1")
|
|
fields := map[string]string{}
|
|
checkNewPassword(pass1, r.FormValue("pass2"), fields)
|
|
if len(fields) > 0 {
|
|
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
|
e.Fields = fields
|
|
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)
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return Internal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// DELETE ... RETURNING verbraucht den Link atomar: zwei gleichzeitige
|
|
// Requests mit demselben Token können nicht beide durchkommen.
|
|
var uid int64
|
|
err = tx.QueryRow(
|
|
`DELETE FROM password_reset WHERE token_hash = $1 AND expires >= $2 RETURNING uid`,
|
|
hashResetToken(token), time.Now().Unix(),
|
|
).Scan(&uid)
|
|
if err == sql.ErrNoRows {
|
|
return errResetInvalid
|
|
} else if err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
var username string
|
|
if err := tx.QueryRow(
|
|
`UPDATE account SET password = $1 WHERE uid = $2 RETURNING username`, hash, uid,
|
|
).Scan(&username); err != nil {
|
|
// Konto inzwischen gelöscht: der Link führt ins Leere.
|
|
if err == sql.ErrNoRows {
|
|
return errResetInvalid
|
|
}
|
|
return Internal(err)
|
|
}
|
|
// Wer das alte Passwort kannte, soll mit dessen Sessions nicht drin bleiben.
|
|
if _, err := tx.Exec(`DELETE FROM session WHERE uid = $1`, uid); err != nil {
|
|
return Internal(err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"username": username})
|
|
return nil
|
|
}
|