docs: reorder & testing
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user