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

332 lines
10 KiB
Go

// Package sendemailcode implements the public send-email-code use case.
package sendemailcode
import (
"context"
"fmt"
"reflect"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/userresolution"
"galaxy/authsession/internal/ports"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/telemetry"
"go.uber.org/zap"
)
// Input describes one public send-email-code request.
type Input struct {
// Email is the user-supplied e-mail address that should receive the login
// code.
Email string
}
// Result describes one public send-email-code response.
type Result struct {
// ChallengeID is the stable challenge identifier returned to the caller.
ChallengeID string
}
// Service executes the public send-email-code use case.
type Service struct {
challengeStore ports.ChallengeStore
userDirectory ports.UserDirectory
idGenerator ports.IDGenerator
codeGenerator ports.CodeGenerator
codeHasher ports.CodeHasher
mailSender ports.MailSender
abuseProtector ports.SendEmailCodeAbuseProtector
clock ports.Clock
logger *zap.Logger
telemetry *telemetry.Runtime
}
// New returns a send-email-code service wired to the required ports.
func New(
challengeStore ports.ChallengeStore,
userDirectory ports.UserDirectory,
idGenerator ports.IDGenerator,
codeGenerator ports.CodeGenerator,
codeHasher ports.CodeHasher,
mailSender ports.MailSender,
clock ports.Clock,
) (*Service, error) {
return NewWithRuntime(
challengeStore,
userDirectory,
idGenerator,
codeGenerator,
codeHasher,
mailSender,
nil,
clock,
nil,
)
}
// NewWithRuntime returns a send-email-code service wired to the required
// ports plus the optional Stage-17 runtime collaborators.
func NewWithRuntime(
challengeStore ports.ChallengeStore,
userDirectory ports.UserDirectory,
idGenerator ports.IDGenerator,
codeGenerator ports.CodeGenerator,
codeHasher ports.CodeHasher,
mailSender ports.MailSender,
abuseProtector ports.SendEmailCodeAbuseProtector,
clock ports.Clock,
telemetryRuntime *telemetry.Runtime,
) (*Service, error) {
return NewWithObservability(
challengeStore,
userDirectory,
idGenerator,
codeGenerator,
codeHasher,
mailSender,
abuseProtector,
clock,
nil,
telemetryRuntime,
)
}
// NewWithObservability returns a send-email-code service wired to the required
// ports plus optional structured logging and telemetry dependencies.
func NewWithObservability(
challengeStore ports.ChallengeStore,
userDirectory ports.UserDirectory,
idGenerator ports.IDGenerator,
codeGenerator ports.CodeGenerator,
codeHasher ports.CodeHasher,
mailSender ports.MailSender,
abuseProtector ports.SendEmailCodeAbuseProtector,
clock ports.Clock,
logger *zap.Logger,
telemetryRuntime *telemetry.Runtime,
) (*Service, error) {
switch {
case challengeStore == nil:
return nil, fmt.Errorf("sendemailcode: challenge store must not be nil")
case userDirectory == nil:
return nil, fmt.Errorf("sendemailcode: user directory must not be nil")
case idGenerator == nil:
return nil, fmt.Errorf("sendemailcode: id generator must not be nil")
case codeGenerator == nil:
return nil, fmt.Errorf("sendemailcode: code generator must not be nil")
case codeHasher == nil:
return nil, fmt.Errorf("sendemailcode: code hasher must not be nil")
case mailSender == nil:
return nil, fmt.Errorf("sendemailcode: mail sender must not be nil")
case clock == nil:
return nil, fmt.Errorf("sendemailcode: clock must not be nil")
default:
return &Service{
challengeStore: challengeStore,
userDirectory: userDirectory,
idGenerator: idGenerator,
codeGenerator: codeGenerator,
codeHasher: codeHasher,
mailSender: mailSender,
abuseProtector: normalizeAbuseProtector(abuseProtector),
clock: clock,
logger: namedLogger(logger, "send_email_code"),
telemetry: telemetryRuntime,
}, nil
}
}
// Execute creates a fresh challenge for every request, stores only the hashed
// confirmation code, and records whether delivery was sent or intentionally
// suppressed.
func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
logFields := []zap.Field{
zap.String("component", "service"),
zap.String("use_case", "send_email_code"),
}
outcome := ""
defer func() {
if outcome != "" {
logFields = append(logFields, zap.String("outcome", outcome))
}
if result.ChallengeID != "" {
logFields = append(logFields, zap.String("challenge_id", result.ChallengeID))
}
shared.LogServiceOutcome(s.logger, ctx, "send email code completed", err, logFields...)
}()
email, err := shared.ParseEmail(input.Email)
if err != nil {
return Result{}, err
}
now := s.clock.Now().UTC()
abuseResult, err := s.abuseProtector.CheckAndReserve(ctx, ports.SendEmailCodeAbuseInput{
Email: email,
Now: now,
})
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
if err := abuseResult.Validate(); err != nil {
return Result{}, shared.InternalError(err)
}
challengeID, err := s.idGenerator.NewChallengeID()
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
code, err := s.codeGenerator.Generate()
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
codeHash, err := s.codeHasher.Hash(code)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
pendingStatus, pendingDeliveryState, err := ports.SendEmailCodeThrottleStatusToChallengeStatus(abuseResult.Outcome)
if err != nil {
return Result{}, shared.InternalError(err)
}
pending := challenge.Challenge{
ID: challengeID,
Email: email,
CodeHash: codeHash,
Status: pendingStatus,
DeliveryState: pendingDeliveryState,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
}
if err := pending.Validate(); err != nil {
return Result{}, shared.InternalError(err)
}
if err := s.challengeStore.Create(ctx, pending); err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
s.telemetry.RecordChallengeCreated(ctx)
final := pending
final.Attempts.Send = 1
final.Abuse.LastAttemptAt = &now
if abuseResult.Outcome == ports.SendEmailCodeAbuseOutcomeThrottled {
result, err = s.finishChallenge(ctx, pending, final)
if err == nil {
outcome = string(telemetry.SendEmailCodeOutcomeThrottled)
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeThrottled, telemetry.SendEmailCodeReasonThrottled)
}
return result, err
}
resolution, err := s.userDirectory.ResolveByEmail(ctx, email)
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
if err := resolution.Validate(); err != nil {
return Result{}, shared.InternalError(err)
}
s.telemetry.RecordUserDirectoryOutcome(ctx, "resolve_by_email", string(resolution.Kind))
switch resolution.Kind {
case userresolution.KindBlocked:
final.Status = challenge.StatusDeliverySuppressed
final.DeliveryState = challenge.DeliverySuppressed
result, err = s.finishChallenge(ctx, pending, final)
if err == nil {
outcome = string(telemetry.SendEmailCodeOutcomeSuppressed)
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSuppressed, telemetry.SendEmailCodeReasonBlocked)
}
return result, err
default:
deliveryResult, err := s.mailSender.SendLoginCode(ctx, ports.SendLoginCodeInput{
Email: email,
Code: code,
})
if err != nil {
final.Status = challenge.StatusFailed
final.DeliveryState = challenge.DeliveryFailed
if _, persistErr := s.finishChallenge(ctx, pending, final); persistErr != nil {
return Result{}, persistErr
}
outcome = string(telemetry.SendEmailCodeOutcomeFailed)
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeFailed, telemetry.SendEmailCodeReasonMailSender)
return Result{}, shared.ServiceUnavailable(err)
}
if err := deliveryResult.Validate(); err != nil {
return Result{}, shared.InternalError(err)
}
switch deliveryResult.Outcome {
case ports.SendLoginCodeOutcomeSent:
final.Status = challenge.StatusSent
final.DeliveryState = challenge.DeliverySent
result, err = s.finishChallenge(ctx, pending, final)
if err == nil {
outcome = string(telemetry.SendEmailCodeOutcomeSent)
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSent, "")
}
return result, err
case ports.SendLoginCodeOutcomeSuppressed:
final.Status = challenge.StatusDeliverySuppressed
final.DeliveryState = challenge.DeliverySuppressed
result, err = s.finishChallenge(ctx, pending, final)
if err == nil {
outcome = string(telemetry.SendEmailCodeOutcomeSuppressed)
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSuppressed, telemetry.SendEmailCodeReasonMailSender)
}
return result, err
default:
return Result{}, shared.InternalError(fmt.Errorf("sendemailcode: unsupported delivery outcome %q", deliveryResult.Outcome))
}
}
}
func (s *Service) finishChallenge(ctx context.Context, pending challenge.Challenge, final challenge.Challenge) (Result, error) {
if err := final.Validate(); err != nil {
return Result{}, shared.InternalError(err)
}
if err := s.challengeStore.CompareAndSwap(ctx, pending, final); err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
return Result{ChallengeID: final.ID.String()}, nil
}
func normalizeAbuseProtector(protector ports.SendEmailCodeAbuseProtector) ports.SendEmailCodeAbuseProtector {
if protector == nil {
return allowAllSendEmailCodeAbuseProtector{}
}
value := reflect.ValueOf(protector)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
if value.IsNil() {
return allowAllSendEmailCodeAbuseProtector{}
}
}
return protector
}
type allowAllSendEmailCodeAbuseProtector struct{}
func (allowAllSendEmailCodeAbuseProtector) CheckAndReserve(_ context.Context, input ports.SendEmailCodeAbuseInput) (ports.SendEmailCodeAbuseResult, error) {
if err := input.Validate(); err != nil {
return ports.SendEmailCodeAbuseResult{}, err
}
return ports.SendEmailCodeAbuseResult{
Outcome: ports.SendEmailCodeAbuseOutcomeAllowed,
}, nil
}
func namedLogger(logger *zap.Logger, name string) *zap.Logger {
if logger == nil {
logger = zap.NewNop()
}
return logger.Named(name)
}