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
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:
1 parent
b2ecda9896
commit
1af3a6e90a
25 files changed
+705
-75
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user