Files
galaxy-game/authsession/internal/api/internalhttp/handler.go
T
2026-04-08 16:23:07 +02:00

514 lines
17 KiB
Go

package internalhttp
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
const jsonContentType = "application/json; charset=utf-8"
const internalHTTPServiceName = "galaxy-authsession-internal"
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type actorRequest struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
type sessionResponseDTO struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt *string `json:"revoked_at,omitempty"`
RevokeReasonCode *string `json:"revoke_reason_code,omitempty"`
RevokeActorType *string `json:"revoke_actor_type,omitempty"`
RevokeActorID *string `json:"revoke_actor_id,omitempty"`
}
type getSessionResponse struct {
Session sessionResponseDTO `json:"session"`
}
type listUserSessionsResponse struct {
Sessions []sessionResponseDTO `json:"sessions"`
}
type revokeDeviceSessionRequest struct {
ReasonCode string `json:"reason_code"`
Actor actorRequest `json:"actor"`
}
type revokeDeviceSessionResponse struct {
Outcome string `json:"outcome"`
DeviceSessionID string `json:"device_session_id"`
AffectedSessionCount int64 `json:"affected_session_count"`
}
type revokeAllUserSessionsRequest struct {
ReasonCode string `json:"reason_code"`
Actor actorRequest `json:"actor"`
}
type revokeAllUserSessionsResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id"`
AffectedSessionCount int64 `json:"affected_session_count"`
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
}
type blockUserRequest struct {
UserID string `json:"user_id,omitempty"`
Email string `json:"email,omitempty"`
ReasonCode string `json:"reason_code"`
Actor actorRequest `json:"actor"`
}
type blockUserResponse struct {
Outcome string `json:"outcome"`
SubjectKind string `json:"subject_kind"`
SubjectValue string `json:"subject_value"`
AffectedSessionCount int64 `json:"affected_session_count"`
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
}
var configureGinModeOnce sync.Once
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
normalizedDeps, err := normalizeDependencies(deps)
if err != nil {
return nil, err
}
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
engine := gin.New()
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
engine.Use(withInternalObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.GET("/api/v1/internal/sessions/:device_session_id", handleGetSession(normalizedDeps.GetSession, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/sessions", handleListUserSessions(normalizedDeps.ListUserSessions, cfg.RequestTimeout))
engine.POST("/api/v1/internal/sessions/:device_session_id/revoke", handleRevokeDeviceSession(normalizedDeps.RevokeDeviceSession, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sessions/revoke-all", handleRevokeAllUserSessions(normalizedDeps.RevokeAllUserSessions, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-blocks", handleBlockUser(normalizedDeps.BlockUser, cfg.RequestTimeout))
return engine, nil
}
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
options := []otelgin.Option{}
if runtime != nil {
options = append(
options,
otelgin.WithTracerProvider(runtime.TracerProvider()),
otelgin.WithMeterProvider(runtime.MeterProvider()),
)
}
return otelgin.Middleware(internalHTTPServiceName, options...)
}
func handleGetSession(useCase GetSessionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, getsession.Input{
DeviceSessionID: c.Param("device_session_id"),
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateGetSessionResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("get session response: %w", err)))
return
}
c.JSON(http.StatusOK, getSessionResponse{Session: toSessionResponseDTO(result.Session)})
}
}
func handleListUserSessions(useCase ListUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, listusersessions.Input{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateListUserSessionsResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("list user sessions response: %w", err)))
return
}
c.JSON(http.StatusOK, listUserSessionsResponse{Sessions: toSessionResponseDTOs(result.Sessions)})
}
}
func handleRevokeDeviceSession(useCase RevokeDeviceSessionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeDeviceSessionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, revokedevicesession.Input{
DeviceSessionID: c.Param("device_session_id"),
ReasonCode: request.ReasonCode,
ActorType: request.Actor.Type,
ActorID: request.Actor.ID,
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateRevokeDeviceSessionResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke device session response: %w", err)))
return
}
c.JSON(http.StatusOK, revokeDeviceSessionResponse{
Outcome: result.Outcome,
DeviceSessionID: result.DeviceSessionID,
AffectedSessionCount: result.AffectedSessionCount,
})
}
}
func handleRevokeAllUserSessions(useCase RevokeAllUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeAllUserSessionsRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, revokeallusersessions.Input{
UserID: c.Param("user_id"),
ReasonCode: request.ReasonCode,
ActorType: request.Actor.Type,
ActorID: request.Actor.ID,
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateRevokeAllUserSessionsResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke all user sessions response: %w", err)))
return
}
c.JSON(http.StatusOK, revokeAllUserSessionsResponse{
Outcome: result.Outcome,
UserID: result.UserID,
AffectedSessionCount: result.AffectedSessionCount,
AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs),
})
}
}
func handleBlockUser(useCase BlockUserUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockUserRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
if err := validateBlockUserRequest(&request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, blockuser.Input{
UserID: request.UserID,
Email: request.Email,
ReasonCode: request.ReasonCode,
ActorType: request.Actor.Type,
ActorID: request.Actor.ID,
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateBlockUserResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("block user response: %w", err)))
return
}
c.JSON(http.StatusOK, blockUserResponse{
Outcome: result.Outcome,
SubjectKind: result.SubjectKind,
SubjectValue: result.SubjectValue,
AffectedSessionCount: result.AffectedSessionCount,
AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs),
})
}
}
func toSessionResponseDTO(session shared.Session) sessionResponseDTO {
return sessionResponseDTO{
DeviceSessionID: session.DeviceSessionID,
UserID: session.UserID,
ClientPublicKey: session.ClientPublicKey,
Status: session.Status,
CreatedAt: session.CreatedAt,
RevokedAt: cloneStringPointer(session.RevokedAt),
RevokeReasonCode: cloneStringPointer(session.RevokeReasonCode),
RevokeActorType: cloneStringPointer(session.RevokeActorType),
RevokeActorID: cloneStringPointer(session.RevokeActorID),
}
}
func toSessionResponseDTOs(sessions []shared.Session) []sessionResponseDTO {
result := make([]sessionResponseDTO, 0, len(sessions))
for _, session := range sessions {
result = append(result, toSessionResponseDTO(session))
}
return result
}
func cloneStrings(values []string) []string {
result := make([]string, 0, len(values))
return append(result, values...)
}
func cloneStringPointer(value *string) *string {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func validateAuditRequest(reasonCode string, actor actorRequest) error {
if strings.TrimSpace(reasonCode) == "" {
return errors.New("reason_code must not be empty")
}
if strings.TrimSpace(actor.Type) == "" {
return errors.New("actor.type must not be empty")
}
return nil
}
func validateBlockUserRequest(request *blockUserRequest) error {
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
return err
}
hasUserID := strings.TrimSpace(request.UserID) != ""
hasEmail := strings.TrimSpace(request.Email) != ""
switch {
case hasUserID && hasEmail:
return errors.New("exactly one of user_id or email must be provided")
case !hasUserID && !hasEmail:
return errors.New("exactly one of user_id or email must be provided")
default:
return nil
}
}
func validateSessionDTO(session *shared.Session) error {
switch {
case strings.TrimSpace(session.DeviceSessionID) == "":
return errors.New("session.device_session_id must not be empty")
case strings.TrimSpace(session.UserID) == "":
return errors.New("session.user_id must not be empty")
case strings.TrimSpace(session.ClientPublicKey) == "":
return errors.New("session.client_public_key must not be empty")
case strings.TrimSpace(session.CreatedAt) == "":
return errors.New("session.created_at must not be empty")
}
if _, err := time.Parse(time.RFC3339, session.CreatedAt); err != nil {
return fmt.Errorf("session.created_at: %w", err)
}
switch session.Status {
case "active":
if session.RevokedAt != nil || session.RevokeReasonCode != nil || session.RevokeActorType != nil || session.RevokeActorID != nil {
return errors.New("active session must not contain revoke metadata")
}
case "revoked":
switch {
case session.RevokedAt == nil || strings.TrimSpace(*session.RevokedAt) == "":
return errors.New("revoked session must contain revoked_at")
case session.RevokeReasonCode == nil || strings.TrimSpace(*session.RevokeReasonCode) == "":
return errors.New("revoked session must contain revoke_reason_code")
case session.RevokeActorType == nil || strings.TrimSpace(*session.RevokeActorType) == "":
return errors.New("revoked session must contain revoke_actor_type")
}
if _, err := time.Parse(time.RFC3339, *session.RevokedAt); err != nil {
return fmt.Errorf("session.revoked_at: %w", err)
}
default:
return fmt.Errorf("session.status %q is unsupported", session.Status)
}
return nil
}
func validateGetSessionResult(result *getsession.Result) error {
return validateSessionDTO(&result.Session)
}
func validateListUserSessionsResult(result *listusersessions.Result) error {
if result.Sessions == nil {
return errors.New("sessions must not be null")
}
for index := range result.Sessions {
if err := validateSessionDTO(&result.Sessions[index]); err != nil {
return fmt.Errorf("sessions[%d]: %w", index, err)
}
}
return nil
}
func validateRevokeDeviceSessionResult(result *revokedevicesession.Result) error {
switch result.Outcome {
case "revoked":
if result.AffectedSessionCount != 1 {
return errors.New("revoked outcome must affect exactly one session")
}
case "already_revoked":
if result.AffectedSessionCount != 0 {
return errors.New("already_revoked outcome must affect zero sessions")
}
default:
return fmt.Errorf("revoke device session outcome %q is unsupported", result.Outcome)
}
if strings.TrimSpace(result.DeviceSessionID) == "" {
return errors.New("device_session_id must not be empty")
}
return nil
}
func validateRevokeAllUserSessionsResult(result *revokeallusersessions.Result) error {
switch result.Outcome {
case "revoked", "no_active_sessions":
default:
return fmt.Errorf("revoke all user sessions outcome %q is unsupported", result.Outcome)
}
if strings.TrimSpace(result.UserID) == "" {
return errors.New("user_id must not be empty")
}
if result.AffectedSessionCount < 0 {
return errors.New("affected_session_count must not be negative")
}
if result.AffectedDeviceSessionIDs == nil {
return errors.New("affected_device_session_ids must not be null")
}
if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount {
return errors.New("affected_device_session_ids length must match affected_session_count")
}
for index, deviceSessionID := range result.AffectedDeviceSessionIDs {
if strings.TrimSpace(deviceSessionID) == "" {
return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index)
}
}
return nil
}
func validateBlockUserResult(result *blockuser.Result) error {
switch result.Outcome {
case "blocked", "already_blocked":
default:
return fmt.Errorf("block user outcome %q is unsupported", result.Outcome)
}
switch result.SubjectKind {
case blockuser.SubjectKindUserID, blockuser.SubjectKindEmail:
default:
return fmt.Errorf("subject_kind %q is unsupported", result.SubjectKind)
}
if strings.TrimSpace(result.SubjectValue) == "" {
return errors.New("subject_value must not be empty")
}
if result.AffectedSessionCount < 0 {
return errors.New("affected_session_count must not be negative")
}
if result.AffectedDeviceSessionIDs == nil {
return errors.New("affected_device_session_ids must not be null")
}
if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount {
return errors.New("affected_device_session_ids length must match affected_session_count")
}
for index, deviceSessionID := range result.AffectedDeviceSessionIDs {
if strings.TrimSpace(deviceSessionID) == "" {
return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index)
}
}
return nil
}
func projectInternalError(err error) shared.InternalErrorProjection {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return shared.ProjectInternalError(shared.ServiceUnavailable(err))
}
return shared.ProjectInternalError(err)
}
func internalErrorProjection(err error) shared.InternalErrorProjection {
return shared.ProjectInternalError(shared.InternalError(err))
}