144 lines
4.8 KiB
Go
144 lines
4.8 KiB
Go
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),
|
|
})
|
|
}
|
|
}
|