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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:06:56 +01:00
1 parent 677fe0fc0a
commit 5e20710aa7
16 files changed
+489 -8

No files matched your search

+91
View File
@@ -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})
}
+3
View File
@@ -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,
+36
View File
@@ -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)
}
}