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# -- 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 if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, username).Scan(&uid); err != nil { return "", time.Time{}, errUserNotFound } 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 { 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(), ).Scan(&username) if err == sql.ErrNoRows { return errResetInvalid } else if err != nil { return Internal(err) } writeJSON(w, http.StatusOK, map[string]string{"username": username}) return 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 } 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(r.FormValue("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) } if err := startSession(w, r, uid); err != nil { return err } writeJSON(w, http.StatusOK, map[string]string{"username": username}) return nil }