Files
MindSift/internal/api/sessions.go
T
sandrodachandClaude Opus 5.5 9d9698186c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker;
  test alarm, SMTP test, server settings, backups, account and session
  changes of the shared demo accounts answer 403; the session list only
  shows the own device. The UI shows the notices and disables the controls.
- SMTP password is never sent back (smtpPassSet), empty keeps it.
- ?token= on webhook ingest accepts only API keys.
- Request bodies capped at 2 MB (sync 8 MB, imports 101 MB).
- Deleting an account removes its server backups.
- Backup downloads, exports and import uploads get 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:43:23 +01:00

89 lines
2.9 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// Sessions (FLAW-31): a login creates a server-side session (device, address, last use) whose ID is
// the token's JWT ID. Requests renew the token once a day through the X-Session-Token response
// header, so active devices stay logged in while idle sessions end after 30 days.
const tokenRenewAfter = 24 * time.Hour
type sessionIDKey struct{}
func sessionIDFrom(r *http.Request) string {
id, _ := r.Context().Value(sessionIDKey{}).(string)
return id
}
// startSession creates the session for a fresh login and returns its token.
func (s *Server) startSession(r *http.Request, user *domain.User) (string, error) {
sid, err := s.store.CreateSession(user.ID, r.UserAgent(), clientIPFrom(r).ip)
if err != nil {
return "", err
}
return auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, sid)
}
// renewSessionToken sends a fresh token for the current session when the presented one is old (or
// right away when force is set, e.g. after the own password changed).
func (s *Server) renewSessionToken(w http.ResponseWriter, user *domain.User, claims *auth.Claims, force bool) {
if claims == nil || claims.ID == "" {
return
}
if !force && claims.IssuedAt != nil && time.Since(claims.IssuedAt.Time) < tokenRenewAfter {
return
}
if token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, claims.ID); err == nil {
w.Header().Set("X-Session-Token", token)
}
}
func withSession(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionIDKey{}, sessionID)
}
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSessions(auth.GetUserID(r.Context()), sessionIDFrom(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if s.cfg.DemoMode {
// Demo visitors share the accounts; the others' devices and addresses are none of their business
own := list[:0]
for _, sess := range list {
if sess.Current {
own = append(own, sess)
}
}
list = own
}
writeJSON(w, http.StatusOK, list)
}
// handleEndSession logs out one device.
func (s *Server) handleEndSession(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeSession(userID, chi.URLParam(r, "id")); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrSessionNotFound) {
status = http.StatusNotFound
}
writeError(w, status, err.Error())
return
}
// That device's streams end; the others reconnect with their still valid sessions
s.wsHub.DisconnectUser(userID)
writeJSON(w, http.StatusOK, map[string]bool{"ended": true})
}