docs: reorder & testing
This commit is contained in:
@@ -15,12 +15,15 @@ import (
|
||||
)
|
||||
|
||||
// InternalSessionsHandlers groups the gateway-only session handlers
|
||||
// under `/api/v1/internal/sessions/*`. The current implementation ships real
|
||||
// implementations; nil *auth.Service falls back to the Stage-3
|
||||
// placeholder so the contract test continues to validate the OpenAPI
|
||||
// envelope without booting a database.
|
||||
// under `/api/v1/internal/sessions/*`. The internal surface only
|
||||
// carries the per-request session lookup gateway needs to verify
|
||||
// signed envelopes; revocation is driven through the user surface
|
||||
// (self-driven) or through admin operations that call auth in-process,
|
||||
// not through this listener. nil *auth.Service falls back to the
|
||||
// Stage-3 placeholder so the contract test continues to validate the
|
||||
// OpenAPI envelope without booting a database.
|
||||
type InternalSessionsHandlers struct {
|
||||
svc *auth.Service
|
||||
svc *auth.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -62,58 +65,3 @@ func (h *InternalSessionsHandlers) Get() gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, deviceSessionToWire(sess))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke handles POST /api/v1/internal/sessions/{device_session_id}/revoke.
|
||||
func (h *InternalSessionsHandlers) Revoke() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsRevoke")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
sess, err := h.svc.RevokeSession(ctx, deviceSessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrSessionNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("internal 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))
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeAllForUser handles POST /api/v1/internal/sessions/users/{user_id}/revoke-all.
|
||||
func (h *InternalSessionsHandlers) RevokeAllForUser() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsRevokeAllForUser")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
revoked, err := h.svc.RevokeAllForUser(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("internal 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, gin.H{
|
||||
"user_id": userID.String(),
|
||||
"revoked_count": len(revoked),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ func (h *PublicAuthHandlers) ConfirmEmailCode() gin.HandlerFunc {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code is incorrect")
|
||||
case errors.Is(err, auth.ErrTooManyAttempts):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "too many attempts")
|
||||
case errors.Is(err, auth.ErrEmailPermanentlyBlocked):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed")
|
||||
default:
|
||||
h.logger.Error("confirm-email-code failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
|
||||
@@ -116,15 +116,20 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||
return
|
||||
}
|
||||
// Orders payload uses an updatedAt + commands shape; we don't
|
||||
// rewrite it here because the engine derives the actor from
|
||||
// the route, not the order body. We pass the body through
|
||||
// verbatim (per ARCHITECTURE.md §9: backend is the only
|
||||
// caller, so rewriting is unnecessary). Unused mapping is
|
||||
// kept in the lookup so 404 returns when no mapping exists.
|
||||
_ = mapping
|
||||
// Engine binds the order body into `gamerest.Command{Actor,
|
||||
// Commands}` and rejects an empty actor with `notblank`, so
|
||||
// backend rebinds the actor from the runtime player mapping
|
||||
// before forwarding — the same rule as for the command
|
||||
// handler. Per ARCHITECTURE.md §9 backend is the only caller
|
||||
// of the engine, so the body never carries a client-supplied
|
||||
// actor.
|
||||
_ = order.Order{}
|
||||
resp, err := h.engine.PutOrders(ctx, endpoint, body)
|
||||
payload, err := rebindActor(body, mapping.RaceName)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
|
||||
return
|
||||
}
|
||||
resp, err := h.engine.PutOrders(ctx, endpoint, payload)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games orders", ctx, resp, err)
|
||||
return
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ type RouterDependencies struct {
|
||||
UserLobbyMy *UserLobbyMyHandlers
|
||||
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
||||
UserGames *UserGamesHandlers
|
||||
UserSessions *UserSessionsHandlers
|
||||
AdminAdminAccounts *AdminAdminAccountsHandlers
|
||||
AdminUsers *AdminUsersHandlers
|
||||
AdminGames *AdminGamesHandlers
|
||||
@@ -162,6 +163,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.UserGames == nil {
|
||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.UserSessions == nil {
|
||||
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminAdminAccounts == nil {
|
||||
deps.AdminAdminAccounts = NewAdminAdminAccountsHandlers(nil, deps.Logger)
|
||||
}
|
||||
@@ -258,6 +262,11 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
|
||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
|
||||
userSessions := group.Group("/sessions")
|
||||
userSessions.GET("", deps.UserSessions.List())
|
||||
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
|
||||
userSessions.POST("/:device_session_id/revoke", deps.UserSessions.Revoke())
|
||||
}
|
||||
|
||||
func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
||||
@@ -323,9 +332,7 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
|
||||
group.Use(metrics.Middleware(instruments, metrics.GroupInternal))
|
||||
|
||||
sessions := group.Group("/sessions")
|
||||
sessions.POST("/users/:user_id/revoke-all", deps.InternalSessions.RevokeAllForUser())
|
||||
sessions.GET("/:device_session_id", deps.InternalSessions.Get())
|
||||
sessions.POST("/:device_session_id/revoke", deps.InternalSessions.Revoke())
|
||||
|
||||
users := group.Group("/users")
|
||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
||||
|
||||
Reference in New Issue
Block a user