package server import ( "errors" "net/http" "galaxy/backend/internal/auth" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/middleware/userid" "galaxy/backend/internal/telemetry" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // UserSessionsHandlers groups the user-facing session handlers under // `/api/v1/user/sessions/*`. Authenticated callers can list their own // active device sessions, revoke a specific one (logout from one // device), or revoke all sessions at once (logout everywhere). Every // mutation lands an audit row in `session_revocations` through the // auth service. nil *auth.Service falls back to the standard 501 // placeholder. type UserSessionsHandlers struct { svc *auth.Service logger *zap.Logger } // NewUserSessionsHandlers constructs the handler set. svc may be nil // — in that case every handler returns 501 not_implemented. func NewUserSessionsHandlers(svc *auth.Service, logger *zap.Logger) *UserSessionsHandlers { if logger == nil { logger = zap.NewNop() } return &UserSessionsHandlers{svc: svc, logger: logger.Named("http.user.sessions")} } type userSessionsListResponse struct { Items []deviceSessionPayload `json:"items"` } type userSessionsRevocationSummary struct { UserID string `json:"user_id"` RevokedCount int `json:"revoked_count"` } // List handles GET /api/v1/user/sessions. func (h *UserSessionsHandlers) List() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userSessionsList") } return func(c *gin.Context) { callerID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") return } sessions := h.svc.ListActiveByUser(c.Request.Context(), callerID) items := make([]deviceSessionPayload, 0, len(sessions)) for _, s := range sessions { items = append(items, deviceSessionToWire(s)) } c.JSON(http.StatusOK, userSessionsListResponse{Items: items}) } } // Revoke handles POST /api/v1/user/sessions/{device_session_id}/revoke. // The target session must belong to the caller; otherwise the handler // returns 404 (using the same shape as a missing session) so callers // cannot probe foreign device_session_ids. func (h *UserSessionsHandlers) Revoke() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userSessionsRevoke") } return func(c *gin.Context) { callerID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") return } deviceSessionID, err := uuid.Parse(c.Param("device_session_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID") return } // Ownership check via the cache — if the target session is not // active and owned by the caller, surface a 404 in both // branches so foreign sessions are not probeable. cached, ok := h.svc.LookupSessionInCache(deviceSessionID) if !ok || cached.UserID != callerID { httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found") return } ctx := c.Request.Context() sess, err := h.svc.RevokeSession(ctx, deviceSessionID, auth.RevokeContext{ ActorKind: auth.ActorKindUserSelf, ActorID: callerID.String(), }) if err != nil { if errors.Is(err, auth.ErrSessionNotFound) { httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found") return } h.logger.Error("user sessions revoke failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") return } c.JSON(http.StatusOK, deviceSessionToWire(sess)) } } // RevokeAll handles POST /api/v1/user/sessions/revoke-all. func (h *UserSessionsHandlers) RevokeAll() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userSessionsRevokeAll") } return func(c *gin.Context) { callerID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") return } ctx := c.Request.Context() revoked, err := h.svc.RevokeAllForUser(ctx, callerID, auth.RevokeContext{ ActorKind: auth.ActorKindUserSelf, ActorID: callerID.String(), }) if err != nil { h.logger.Error("user sessions revoke-all failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") return } c.JSON(http.StatusOK, userSessionsRevocationSummary{ UserID: callerID.String(), RevokedCount: len(revoked), }) } }