feat(auth): reset a forgotten password by mail
"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL are configured. The request always gets the same answer and sends the mail in the background (at most every 2 minutes per account). The link holds a random token valid once for one hour; only its SHA-256 is stored. Setting the new password invalidates all open links and ends every session. The app removes the token from the address bar and offers a way back for expired links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
677fe0fc0a
commit
5e20710aa7
16 files changed
+489
-8
No files matched your search
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// passwordResetAvailable: mails can be sent and links point to a known address (BASE_URL).
|
||||
func (s *Server) passwordResetAvailable() bool {
|
||||
if !s.cfg.BaseURLSet || s.cfg.DemoMode {
|
||||
return false
|
||||
}
|
||||
_, err := s.smtpSettings()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// handleForgotPassword always answers the same, whether the address exists or not, and sends the mail
|
||||
// in the background so the response time does not tell either.
|
||||
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passwordResetAvailable() {
|
||||
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
go func() {
|
||||
user, err := s.store.GetUserByEmail(email)
|
||||
if err != nil || user == nil || !user.IsActive {
|
||||
return
|
||||
}
|
||||
token, err := s.store.CreatePasswordResetToken(user.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, storage.ErrResetTooSoon) {
|
||||
slog.Warn("Passwort-Reset: Token konnte nicht erzeugt werden", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?resetToken=" + url.QueryEscape(token)
|
||||
body := fmt.Sprintf("Hallo %s,\n\nfür dein MindSift-Konto wurde ein neues Passwort angefordert. "+
|
||||
"Über diesen Link kannst du es innerhalb der nächsten Stunde festlegen:\n\n%s\n\n"+
|
||||
"Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail; dein Passwort bleibt unverändert.\n",
|
||||
user.DisplayName, link)
|
||||
_ = s.sendEmailNotification(user.Email, "MindSift: Passwort zurücksetzen", body)
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "Das Passwort braucht mindestens 4 Zeichen")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
userID, err := s.store.ResetPasswordWithToken(req.Token, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrResetTokenInvalid) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -92,6 +92,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||
r.Post("/api/v1/auth/forgot-password", s.handleForgotPassword)
|
||||
r.Post("/api/v1/auth/reset-password", s.handleResetPassword)
|
||||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
@@ -487,6 +489,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
"passwordReset": s.passwordResetAvailable(),
|
||||
// Public so the login screen can link Impressum and Datenschutz
|
||||
"legal": map[string]interface{}{
|
||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||
|
||||
@@ -1449,3 +1449,39 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
||||
t.Fatalf("revoked token must be refused by the ingest, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetFlow(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
post := func(path, body string) (int, string) {
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
// Without BASE_URL and SMTP the feature is off
|
||||
if code, _ := post("/api/v1/auth/forgot-password", `{"email":"x@example.com"}`); code != http.StatusServiceUnavailable {
|
||||
t.Errorf("forgot-password without setup: want 503, got %d", code)
|
||||
}
|
||||
|
||||
// The link from the mail sets a new password once and ends the old sessions
|
||||
u, _ := store.CreateUser("reset@example.com", "hash", "Reset", "member")
|
||||
raw, err := store.CreatePasswordResetToken(u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"abc"}`); code != http.StatusBadRequest {
|
||||
t.Errorf("too short password must be refused, got %d", code)
|
||||
}
|
||||
if code, body := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"neues-passwort"}`); code != http.StatusOK {
|
||||
t.Fatalf("reset: %d %s", code, body)
|
||||
}
|
||||
if code, _ := post("/api/v1/auth/login", `{"login":"reset@example.com","password":"neues-passwort"}`); code != http.StatusOK {
|
||||
t.Errorf("login with the new password failed: %d", code)
|
||||
}
|
||||
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"nochmal1"}`); code != http.StatusBadRequest {
|
||||
t.Errorf("token must work only once, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ type Config struct {
|
||||
DatabasePath string
|
||||
JWTSecret string
|
||||
BaseURL string
|
||||
// BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built
|
||||
// from it, never from the request's Host header.
|
||||
BaseURLSet bool
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
Debug bool
|
||||
@@ -153,6 +156,7 @@ func Load() *Config {
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
BaseURL: baseURL,
|
||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Password reset: the user gets a random link token by mail. Only its SHA-256 hash is stored, so a
|
||||
// leaked database does not contain usable links. A token works once and for one hour; a new password
|
||||
// also ends all sessions (token_version).
|
||||
|
||||
const (
|
||||
resetTokenTTL = time.Hour
|
||||
resetTokenCooldown = 2 * time.Minute // no new mail for the same account within this time
|
||||
)
|
||||
|
||||
var (
|
||||
ErrResetTokenInvalid = errors.New("Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.")
|
||||
ErrResetTooSoon = errors.New("reset requested again too soon")
|
||||
)
|
||||
|
||||
func (s *Store) migratePasswordReset() error {
|
||||
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT ''
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func hashResetToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken returns a new raw token for the user, or ErrResetTooSoon while the last
|
||||
// one is younger than the cooldown.
|
||||
func (s *Store) CreatePasswordResetToken(userID string) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
var last sql.NullString
|
||||
_ = s.db.QueryRow(`SELECT MAX(created_at) FROM password_reset_tokens WHERE user_id = ?`, userID).Scan(&last)
|
||||
if last.Valid {
|
||||
if t, err := time.Parse(time.RFC3339, last.String); err == nil && now.Sub(t) < resetTokenCooldown {
|
||||
return "", ErrResetTooSoon
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := hex.EncodeToString(buf)
|
||||
// Expired and used tokens are of no use any more
|
||||
_, _ = s.db.Exec(`DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at != ''`, now.Format(time.RFC3339))
|
||||
_, err := s.db.Exec(`INSERT INTO password_reset_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
hashResetToken(raw), userID, now.Format(time.RFC3339), now.Add(resetTokenTTL).Format(time.RFC3339))
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// ResetPasswordWithToken sets a new password hash if the token is valid and returns the user ID.
|
||||
// All other open tokens of the user become invalid as well.
|
||||
func (s *Store) ResetPasswordWithToken(raw, passwordHash string) (string, error) {
|
||||
var userID, expires, used string
|
||||
var active bool
|
||||
err := s.db.QueryRow(`SELECT t.user_id, t.expires_at, t.used_at, u.is_active FROM password_reset_tokens t
|
||||
JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?`, hashResetToken(raw)).Scan(&userID, &expires, &used, &active)
|
||||
if err != nil {
|
||||
return "", ErrResetTokenInvalid
|
||||
}
|
||||
exp, perr := time.Parse(time.RFC3339, expires)
|
||||
if used != "" || !active || perr != nil || time.Now().UTC().After(exp) {
|
||||
return "", ErrResetTokenInvalid
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at = ''`, now, hashResetToken(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return "", ErrResetTokenInvalid // used in parallel
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at = ''`, now, userID)
|
||||
return userID, s.UpdateUserPassword(userID, passwordHash)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordResetToken(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "reset.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
u, _ := store.CreateUser("anna@example.com", "old", "Anna", "member")
|
||||
|
||||
raw, err := store.CreatePasswordResetToken(u.ID)
|
||||
if err != nil || len(raw) != 64 {
|
||||
t.Fatalf("token: %q %v", raw, err)
|
||||
}
|
||||
var stored string
|
||||
_ = store.db.QueryRow(`SELECT token_hash FROM password_reset_tokens`).Scan(&stored)
|
||||
if stored == raw {
|
||||
t.Fatalf("only the hash may be stored")
|
||||
}
|
||||
if _, err := store.CreatePasswordResetToken(u.ID); err != ErrResetTooSoon {
|
||||
t.Errorf("second request within the cooldown must be refused, got %v", err)
|
||||
}
|
||||
|
||||
before, _ := store.GetUserByID(u.ID)
|
||||
if _, err := store.ResetPasswordWithToken("falsch", "new"); err != ErrResetTokenInvalid {
|
||||
t.Errorf("unknown token must fail")
|
||||
}
|
||||
if id, err := store.ResetPasswordWithToken(raw, "new"); err != nil || id != u.ID {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
after, _ := store.GetUserByID(u.ID)
|
||||
if after.PasswordHash != "new" || after.TokenVersion != before.TokenVersion+1 {
|
||||
t.Errorf("password not changed or sessions not ended: %+v", after)
|
||||
}
|
||||
if _, err := store.ResetPasswordWithToken(raw, "again"); err != ErrResetTokenInvalid {
|
||||
t.Errorf("a token works only once")
|
||||
}
|
||||
|
||||
// Expired tokens fail
|
||||
_, _ = store.db.Exec(`DELETE FROM password_reset_tokens`)
|
||||
raw2, _ := store.CreatePasswordResetToken(u.ID)
|
||||
_, _ = store.db.Exec(`UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00Z'`)
|
||||
if _, err := store.ResetPasswordWithToken(raw2, "x"); err != ErrResetTokenInvalid {
|
||||
t.Errorf("expired token must fail")
|
||||
}
|
||||
}
|
||||
@@ -514,6 +514,9 @@ func (s *Store) migrate() error {
|
||||
}
|
||||
s.migrateAnchorTags()
|
||||
s.migrateSearchFTS()
|
||||
if err := s.migratePasswordReset(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
||||
return err
|
||||
|
||||
Reference in new issue
Block a user