feat: authsession service
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user