feat(auth): server-side sessions and current password for account changes (FLAW-30, FLAW-31)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

- Every login is a session (device, IP, last use); the token's jti is its ID.
  Sessions end on logout, per device from the profile, or after 30 idle days.
- Tokens older than a day are renewed via the X-Session-Token header.
- Changing password or login e-mail needs the current password; a new
  password logs out all other devices and mails the old address.
- Demo reset keeps the demo visitors' sessions.
- revoked_tokens is replaced by sessions (migration 4); everyone is logged
  out once after the upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:25:22 +01:00
1 parent b2ecda9896
commit 1af3a6e90a
25 files changed
+705 -75

No files matched your search

+73 -18
View File
@@ -145,7 +145,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := s.startSession(r, user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
@@ -229,7 +229,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := s.startSession(r, user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
@@ -297,7 +297,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := s.startSession(r, user)
if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
@@ -393,21 +393,11 @@ func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
// The session must really end; the client is told if it did not
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
return
}
// The session must really end; the client is told if it did not
if sid := sessionIDFrom(r); sid != "" {
if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) {
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
return
}
}
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
@@ -437,11 +427,23 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
DiscordWebhookURL *string `json:"discordWebhookUrl"`
CustomWebhookURL *string `json:"customWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
// Needed to change password or e-mail address (FLAW-30)
CurrentPassword string `json:"currentPassword"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
before, err := s.store.GetUserByID(userID)
if err != nil || before == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
return
}
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
allowPrivate := s.allowPrivateWebhooks()
if req.DiscordWebhookURL != nil {
@@ -470,7 +472,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
// Every other device is logged out; this one keeps its session with a fresh token
sid := sessionIDFrom(r)
if err := s.store.RevokeOtherSessions(userID, sid); err != nil {
writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden")
return
}
s.wsHub.DisconnectUser(userID)
if fresh, err := s.store.GetUserByID(userID); err == nil {
current := &auth.Claims{}
current.ID = sid
s.renewSessionToken(w, fresh, current, true)
}
}
u, err := s.store.UpdateUserFull(
userID,
@@ -489,9 +502,51 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, err.Error())
return
}
if changesPassword || changesEmail {
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
}
writeJSON(w, http.StatusOK, u)
}
// checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count
// towards the login protection, so this cannot be used to guess the password.
func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool {
ip := clientIPFrom(r)
if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 {
writeLoginLocked(w, wait)
return false
}
// A forgotten field is no guess and does not count as a failure
if password == "" || !auth.CheckPassword(password, user.PasswordHash) {
if password != "" {
s.loginGuard.recordFailure(user.ID, ip)
}
writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein")
return false
}
return true
}
// notifyAccountChange tells the previous address about a new password or e-mail address, so a
// takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown.
func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) {
if _, err := s.smtpSettings(); err != nil || before.Email == "" {
return
}
var what []string
if password {
what = append(what, "dein Passwort wurde geändert")
}
if email {
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
}
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
before.DisplayName, strings.Join(what, " und "))
go func() {
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
}()
}
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)