Files
galaxy-game/backend/internal/server/handlers_user_sessions.go
T
2026-05-07 00:58:53 +03:00

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),
})
}
}