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

589 lines
19 KiB
Go

// Package confirmemailcode implements the public confirm-email-code use case.
package confirmemailcode
import (
"context"
"errors"
"fmt"
"time"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/sessionlimit"
"galaxy/authsession/internal/ports"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/telemetry"
"go.uber.org/zap"
)
const (
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
)
// Input describes one public confirm-email-code request.
type Input struct {
// ChallengeID identifies the challenge that should be confirmed.
ChallengeID string
// Code is the cleartext confirmation code submitted by the caller.
Code string
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that
// should be registered for the created device session.
ClientPublicKey string
}
// Result describes one public confirm-email-code response.
type Result struct {
// DeviceSessionID is the stable identifier of the created or idempotently
// recovered device session.
DeviceSessionID string
}
// Service executes the public confirm-email-code use case.
type Service struct {
challengeStore ports.ChallengeStore
sessionStore ports.SessionStore
userDirectory ports.UserDirectory
configProvider ports.ConfigProvider
publisher ports.GatewaySessionProjectionPublisher
idGenerator ports.IDGenerator
codeHasher ports.CodeHasher
clock ports.Clock
logger *zap.Logger
telemetry *telemetry.Runtime
}
// New returns a confirm-email-code service wired to the required ports.
func New(
challengeStore ports.ChallengeStore,
sessionStore ports.SessionStore,
userDirectory ports.UserDirectory,
configProvider ports.ConfigProvider,
publisher ports.GatewaySessionProjectionPublisher,
idGenerator ports.IDGenerator,
codeHasher ports.CodeHasher,
clock ports.Clock,
) (*Service, error) {
return NewWithTelemetry(
challengeStore,
sessionStore,
userDirectory,
configProvider,
publisher,
idGenerator,
codeHasher,
clock,
nil,
)
}
// NewWithTelemetry returns a confirm-email-code service wired to the required
// ports plus the optional Stage-17 telemetry runtime.
func NewWithTelemetry(
challengeStore ports.ChallengeStore,
sessionStore ports.SessionStore,
userDirectory ports.UserDirectory,
configProvider ports.ConfigProvider,
publisher ports.GatewaySessionProjectionPublisher,
idGenerator ports.IDGenerator,
codeHasher ports.CodeHasher,
clock ports.Clock,
telemetryRuntime *telemetry.Runtime,
) (*Service, error) {
return NewWithObservability(
challengeStore,
sessionStore,
userDirectory,
configProvider,
publisher,
idGenerator,
codeHasher,
clock,
nil,
telemetryRuntime,
)
}
// NewWithObservability returns a confirm-email-code service wired to the
// required ports plus optional structured logging and telemetry dependencies.
func NewWithObservability(
challengeStore ports.ChallengeStore,
sessionStore ports.SessionStore,
userDirectory ports.UserDirectory,
configProvider ports.ConfigProvider,
publisher ports.GatewaySessionProjectionPublisher,
idGenerator ports.IDGenerator,
codeHasher ports.CodeHasher,
clock ports.Clock,
logger *zap.Logger,
telemetryRuntime *telemetry.Runtime,
) (*Service, error) {
switch {
case challengeStore == nil:
return nil, fmt.Errorf("confirmemailcode: challenge store must not be nil")
case sessionStore == nil:
return nil, fmt.Errorf("confirmemailcode: session store must not be nil")
case userDirectory == nil:
return nil, fmt.Errorf("confirmemailcode: user directory must not be nil")
case configProvider == nil:
return nil, fmt.Errorf("confirmemailcode: config provider must not be nil")
case publisher == nil:
return nil, fmt.Errorf("confirmemailcode: projection publisher must not be nil")
case idGenerator == nil:
return nil, fmt.Errorf("confirmemailcode: id generator must not be nil")
case codeHasher == nil:
return nil, fmt.Errorf("confirmemailcode: code hasher must not be nil")
case clock == nil:
return nil, fmt.Errorf("confirmemailcode: clock must not be nil")
default:
return &Service{
challengeStore: challengeStore,
sessionStore: sessionStore,
userDirectory: userDirectory,
configProvider: configProvider,
publisher: publisher,
idGenerator: idGenerator,
codeHasher: codeHasher,
clock: clock,
logger: namedLogger(logger, "confirm_email_code"),
telemetry: telemetryRuntime,
}, nil
}
}
// Execute validates one challenge confirmation attempt, creates a device
// session when policy allows it, and handles short-window idempotent retries.
func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
logFields := []zap.Field{
zap.String("component", "service"),
zap.String("use_case", "confirm_email_code"),
}
defer func() {
outcome := string(telemetry.ConfirmEmailCodeOutcomeSuccess)
if err != nil {
outcome = shared.CodeOf(err)
if outcome == "" {
outcome = shared.ErrorCodeServiceUnavailable
}
}
s.telemetry.RecordConfirmEmailCode(ctx, outcome)
logFields = append(logFields, zap.String("outcome", outcome))
if result.DeviceSessionID != "" {
logFields = append(logFields, zap.String("device_session_id", result.DeviceSessionID))
}
shared.LogServiceOutcome(s.logger, ctx, "confirm email code completed", err, logFields...)
}()
challengeID, err := shared.ParseChallengeID(input.ChallengeID)
if err != nil {
return Result{}, err
}
logFields = append(logFields, zap.String("challenge_id", challengeID.String()))
code, err := shared.ParseRequiredCode(input.Code)
if err != nil {
return Result{}, err
}
clientPublicKey, err := shared.ParseClientPublicKey(input.ClientPublicKey)
if err != nil {
return Result{}, err
}
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
current, err := s.challengeStore.Get(ctx, challengeID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return Result{}, shared.ChallengeNotFound()
default:
return Result{}, shared.ServiceUnavailable(err)
}
}
now := s.clock.Now().UTC()
if expired, err := s.ensureChallengeNotExpired(ctx, current, now); err != nil {
if errors.Is(err, ports.ErrConflict) {
continue
}
return Result{}, err
} else if expired {
return Result{}, shared.ChallengeExpired()
}
switch {
case current.Status.IsConfirmedRetryState():
return s.handleConfirmedRetry(ctx, current, code, clientPublicKey)
case !current.Status.AcceptsFreshConfirm():
return Result{}, shared.InvalidCode()
}
match, err := s.codeHasher.Compare(current.CodeHash, code)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
if !match {
if err := s.recordInvalidConfirmAttempt(ctx, current, now); err != nil {
if errors.Is(err, ports.ErrConflict) {
continue
}
return Result{}, err
}
return Result{}, shared.InvalidCode()
}
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, current.Email)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
if err := ensureUserResult.Validate(); err != nil {
return Result{}, shared.InternalError(err)
}
s.telemetry.RecordUserDirectoryOutcome(ctx, "ensure_user_by_email", string(ensureUserResult.Outcome))
if !ensureUserResult.UserID.IsZero() {
logFields = append(logFields, zap.String("user_id", ensureUserResult.UserID.String()))
}
if ensureUserResult.Outcome == ports.EnsureUserOutcomeBlocked {
if err := s.markChallengeFailed(ctx, current, now); err != nil {
if errors.Is(err, ports.ErrConflict) {
continue
}
return Result{}, err
}
return Result{}, shared.BlockedByPolicy()
}
limitConfig, err := s.configProvider.LoadSessionLimit(ctx)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
decision, err := s.evaluateSessionLimit(ctx, ensureUserResult.UserID, limitConfig)
if err != nil {
return Result{}, err
}
if decision.Kind == sessionlimit.KindExceeded {
s.telemetry.RecordSessionLimitRejection(ctx)
return Result{}, shared.SessionLimitExceeded()
}
sessionRecord, err := s.createSession(ctx, ensureUserResult.UserID, clientPublicKey, now)
if err != nil {
return Result{}, err
}
next := current
next.Status = challenge.StatusConfirmedPendingExpire
next.ExpiresAt = now.Add(challenge.ConfirmedRetention)
next.Abuse.LastAttemptAt = &now
next.Confirmation = &challenge.Confirmation{
SessionID: sessionRecord.ID,
ClientPublicKey: clientPublicKey,
ConfirmedAt: now,
}
if err := next.Validate(); err != nil {
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
return Result{}, shared.InternalError(err)
}
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
if errors.Is(err, ports.ErrConflict) {
return s.handleCreateSessionCASConflict(ctx, challengeID, code, clientPublicKey, sessionRecord)
}
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
return Result{}, shared.ServiceUnavailable(err)
}
// Publish the currently stored session view so a concurrent revoke/block
// cannot overwrite source of truth with a stale active projection.
currentSession, err := s.sessionStore.Get(ctx, sessionRecord.ID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: newly created session %q was not found", sessionRecord.ID))
default:
return Result{}, shared.ServiceUnavailable(err)
}
}
if err := s.publishSession(ctx, currentSession, "confirm_email_code"); err != nil {
return Result{}, err
}
return Result{DeviceSessionID: currentSession.ID.String()}, nil
}
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: compare-and-swap retry limit exceeded"))
}
func (s *Service) ensureChallengeNotExpired(ctx context.Context, current challenge.Challenge, now time.Time) (bool, error) {
if current.IsExpiredAt(now) {
if current.Status != challenge.StatusExpired && current.Status.CanTransitionTo(challenge.StatusExpired) {
next := current
next.Status = challenge.StatusExpired
next.Abuse.LastAttemptAt = &now
next.Confirmation = nil
if err := next.Validate(); err != nil {
return true, shared.InternalError(err)
}
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
if !errors.Is(err, ports.ErrConflict) {
return true, shared.ServiceUnavailable(err)
}
return false, err
}
}
return true, nil
}
return false, nil
}
func (s *Service) handleConfirmedRetry(ctx context.Context, current challenge.Challenge, code string, clientPublicKey common.ClientPublicKey) (Result, error) {
match, err := s.codeHasher.Compare(current.CodeHash, code)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
if !match {
return Result{}, shared.InvalidCode()
}
if current.Confirmation == nil {
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed challenge is missing confirmation metadata"))
}
if current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
return Result{}, shared.InvalidCode()
}
record, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed session %q was not found", current.Confirmation.SessionID))
default:
return Result{}, shared.ServiceUnavailable(err)
}
}
if err := s.publishSession(ctx, record, "confirm_email_code_retry"); err != nil {
return Result{}, err
}
return Result{DeviceSessionID: record.ID.String()}, nil
}
func (s *Service) recordInvalidConfirmAttempt(ctx context.Context, current challenge.Challenge, now time.Time) error {
next := current
next.Attempts.Confirm++
next.Abuse.LastAttemptAt = &now
if next.Attempts.Confirm >= challenge.MaxInvalidConfirmAttempts {
next.Status = challenge.StatusFailed
}
if err := next.Validate(); err != nil {
return shared.InternalError(err)
}
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
return err
default:
return shared.ServiceUnavailable(err)
}
}
return nil
}
func (s *Service) markChallengeFailed(ctx context.Context, current challenge.Challenge, now time.Time) error {
next := current
next.Status = challenge.StatusFailed
next.Abuse.LastAttemptAt = &now
if err := next.Validate(); err != nil {
return shared.InternalError(err)
}
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
return err
default:
return shared.ServiceUnavailable(err)
}
}
return nil
}
func (s *Service) evaluateSessionLimit(ctx context.Context, userID common.UserID, config ports.SessionLimitConfig) (sessionlimit.Decision, error) {
activeSessionCount, err := s.sessionStore.CountActiveByUserID(ctx, userID)
if err != nil {
return sessionlimit.Decision{}, shared.ServiceUnavailable(err)
}
decision, err := shared.EvaluateSessionLimit(config, activeSessionCount)
if err != nil {
return sessionlimit.Decision{}, err
}
return decision, nil
}
func (s *Service) createSession(ctx context.Context, userID common.UserID, clientPublicKey common.ClientPublicKey, now time.Time) (devicesession.Session, error) {
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
deviceSessionID, err := s.idGenerator.NewDeviceSessionID()
if err != nil {
return devicesession.Session{}, shared.ServiceUnavailable(err)
}
record := devicesession.Session{
ID: deviceSessionID,
UserID: userID,
ClientPublicKey: clientPublicKey,
Status: devicesession.StatusActive,
CreatedAt: now,
}
if err := record.Validate(); err != nil {
return devicesession.Session{}, shared.InternalError(err)
}
if err := s.sessionStore.Create(ctx, record); err != nil {
if errors.Is(err, ports.ErrConflict) {
continue
}
return devicesession.Session{}, shared.ServiceUnavailable(err)
}
s.telemetry.RecordSessionCreated(ctx)
return record, nil
}
return devicesession.Session{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: session id conflict retry limit exceeded"))
}
func (s *Service) handleCreateSessionCASConflict(
ctx context.Context,
challengeID common.ChallengeID,
code string,
clientPublicKey common.ClientPublicKey,
createdSession devicesession.Session,
) (Result, error) {
defer s.bestEffortRevokeSupersededSession(ctx, createdSession)
current, err := s.challengeStore.Get(ctx, challengeID)
if err != nil {
if errors.Is(err, ports.ErrNotFound) {
return Result{}, shared.ServiceUnavailable(err)
}
return Result{}, shared.ServiceUnavailable(err)
}
if current.Status != challenge.StatusConfirmedPendingExpire || current.Confirmation == nil {
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q changed to unexpected status %q after create", challengeID, current.Status))
}
match, err := s.codeHasher.Compare(current.CodeHash, code)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
if !match || current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q was confirmed by a different payload", challengeID))
}
winningSession, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: winning session %q was not found", current.Confirmation.SessionID))
default:
return Result{}, shared.ServiceUnavailable(err)
}
}
if err := s.publishSession(ctx, winningSession, "confirm_email_code_race_winner"); err != nil {
return Result{}, err
}
return Result{DeviceSessionID: winningSession.ID.String()}, nil
}
func (s *Service) bestEffortRevokeSupersededSession(ctx context.Context, record devicesession.Session) {
revocation := devicesession.Revocation{
At: s.clock.Now().UTC(),
ReasonCode: revokeReasonConfirmRace,
ActorType: revokeActorTypeService,
ActorID: revokeActorIDService,
}
if err := revocation.Validate(); err != nil {
return
}
revokeResult, err := s.sessionStore.Revoke(ctx, ports.RevokeSessionInput{
DeviceSessionID: record.ID,
Revocation: revocation,
})
if err != nil {
s.logger.Warn(
"best-effort superseded session revoke failed",
zap.String("component", "service"),
zap.String("use_case", "confirm_email_code"),
zap.String("operation", "confirm_email_code_race_cleanup"),
zap.String("device_session_id", record.ID.String()),
zap.String("reason_code", revocation.ReasonCode.String()),
zap.Error(err),
)
return
}
if err := revokeResult.Validate(); err != nil {
s.logger.Warn(
"best-effort superseded session revoke produced invalid result",
zap.String("component", "service"),
zap.String("use_case", "confirm_email_code"),
zap.String("operation", "confirm_email_code_race_cleanup"),
zap.String("device_session_id", record.ID.String()),
zap.Error(err),
)
return
}
if revokeResult.Outcome == ports.RevokeSessionOutcomeRevoked {
s.telemetry.RecordSessionRevocations(ctx, "confirm_email_code_race_cleanup", revocation.ReasonCode.String(), 1)
}
snapshot, err := shared.ToGatewayProjectionSnapshot(revokeResult.Session)
if err != nil {
s.logger.Warn(
"best-effort superseded session snapshot mapping failed",
zap.String("component", "service"),
zap.String("use_case", "confirm_email_code"),
zap.String("operation", "confirm_email_code_race_cleanup"),
zap.String("device_session_id", revokeResult.Session.ID.String()),
zap.Error(err),
)
return
}
if err := shared.PublishProjectionSnapshotWithTelemetry(ctx, s.publisher, snapshot, s.telemetry, "confirm_email_code_race_cleanup"); err != nil {
s.logger.Warn(
"best-effort superseded session publish failed",
zap.String("component", "service"),
zap.String("use_case", "confirm_email_code"),
zap.String("operation", "confirm_email_code_race_cleanup"),
zap.String("device_session_id", revokeResult.Session.ID.String()),
zap.Error(err),
)
}
}
func (s *Service) publishSession(ctx context.Context, record devicesession.Session, operation string) error {
return shared.PublishSessionProjectionWithTelemetry(ctx, s.publisher, record, s.telemetry, operation)
}
func namedLogger(logger *zap.Logger, name string) *zap.Logger {
if logger == nil {
logger = zap.NewNop()
}
return logger.Named(name)
}