514 lines
17 KiB
Go
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))
|
|
}
|