332 lines
10 KiB
Go
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)
|
|
}
|