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
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user