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