343 lines
10 KiB
Go
343 lines
10 KiB
Go
// Package challenge defines the source-of-truth domain model for one e-mail
|
|
// confirmation challenge.
|
|
package challenge
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"galaxy/authsession/internal/domain/common"
|
|
)
|
|
|
|
// Status identifies the coarse lifecycle state of one challenge.
|
|
type Status string
|
|
|
|
const (
|
|
// StatusPendingSend reports that the challenge has been created but its
|
|
// delivery outcome has not been recorded yet.
|
|
StatusPendingSend Status = "pending_send"
|
|
|
|
// StatusSent reports that the confirmation code was delivered successfully.
|
|
StatusSent Status = "sent"
|
|
|
|
// StatusDeliverySuppressed reports that outward send succeeded but actual
|
|
// delivery was intentionally suppressed by policy.
|
|
StatusDeliverySuppressed Status = "delivery_suppressed"
|
|
|
|
// StatusDeliveryThrottled reports that a fresh challenge was created but
|
|
// delivery was skipped because the auth-side resend cooldown is still
|
|
// active.
|
|
StatusDeliveryThrottled Status = "delivery_throttled"
|
|
|
|
// StatusConfirmedPendingExpire reports that the challenge was confirmed
|
|
// successfully and is temporarily retained for idempotent retry handling.
|
|
StatusConfirmedPendingExpire Status = "confirmed_pending_expire"
|
|
|
|
// StatusExpired reports that the challenge can no longer be confirmed.
|
|
StatusExpired Status = "expired"
|
|
|
|
// StatusFailed reports that the challenge reached a terminal failure state.
|
|
StatusFailed Status = "failed"
|
|
|
|
// StatusCancelled reports that the challenge was cancelled explicitly.
|
|
StatusCancelled Status = "cancelled"
|
|
)
|
|
|
|
// IsKnown reports whether Status is one of the challenge states supported by
|
|
// the current domain model.
|
|
func (s Status) IsKnown() bool {
|
|
switch s {
|
|
case StatusPendingSend,
|
|
StatusSent,
|
|
StatusDeliverySuppressed,
|
|
StatusDeliveryThrottled,
|
|
StatusConfirmedPendingExpire,
|
|
StatusExpired,
|
|
StatusFailed,
|
|
StatusCancelled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsTerminal reports whether Status can no longer accept any lifecycle
|
|
// transition in the v1 challenge state machine.
|
|
func (s Status) IsTerminal() bool {
|
|
switch s {
|
|
case StatusExpired, StatusFailed, StatusCancelled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// AcceptsFreshConfirm reports whether Status may still consume a first
|
|
// successful confirmation attempt.
|
|
func (s Status) AcceptsFreshConfirm() bool {
|
|
switch s {
|
|
case StatusSent, StatusDeliverySuppressed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsConfirmedRetryState reports whether Status should use the idempotent retry
|
|
// path for a previously successful confirmation.
|
|
func (s Status) IsConfirmedRetryState() bool {
|
|
return s == StatusConfirmedPendingExpire
|
|
}
|
|
|
|
// CanTransitionTo reports whether the current challenge Status may move to
|
|
// next under the coarse lifecycle rules fixed by Stage 2.
|
|
func (s Status) CanTransitionTo(next Status) bool {
|
|
switch s {
|
|
case StatusPendingSend:
|
|
switch next {
|
|
case StatusSent, StatusDeliverySuppressed, StatusDeliveryThrottled, StatusFailed, StatusCancelled, StatusExpired:
|
|
return true
|
|
}
|
|
case StatusSent, StatusDeliverySuppressed:
|
|
switch next {
|
|
case StatusConfirmedPendingExpire, StatusFailed, StatusCancelled, StatusExpired:
|
|
return true
|
|
}
|
|
case StatusConfirmedPendingExpire:
|
|
return next == StatusExpired
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// DeliveryState identifies the recorded delivery result of one challenge.
|
|
type DeliveryState string
|
|
|
|
const (
|
|
// DeliveryPending reports that no delivery outcome has been recorded yet.
|
|
DeliveryPending DeliveryState = "pending"
|
|
|
|
// DeliverySent reports that the challenge code was sent successfully.
|
|
DeliverySent DeliveryState = "sent"
|
|
|
|
// DeliverySuppressed reports that the outward flow stays success-shaped
|
|
// while actual delivery is intentionally skipped.
|
|
DeliverySuppressed DeliveryState = "suppressed"
|
|
|
|
// DeliveryThrottled reports that the outward flow stays success-shaped
|
|
// while actual delivery is skipped because the resend cooldown is active.
|
|
DeliveryThrottled DeliveryState = "throttled"
|
|
|
|
// DeliveryFailed reports that delivery was attempted and failed explicitly.
|
|
DeliveryFailed DeliveryState = "failed"
|
|
)
|
|
|
|
// IsKnown reports whether DeliveryState is one of the delivery states
|
|
// supported by the current domain model.
|
|
func (s DeliveryState) IsKnown() bool {
|
|
switch s {
|
|
case DeliveryPending, DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CanTransitionTo reports whether the current DeliveryState may move to next
|
|
// under the coarse delivery rules fixed by Stage 2.
|
|
func (s DeliveryState) CanTransitionTo(next DeliveryState) bool {
|
|
if s != DeliveryPending {
|
|
return false
|
|
}
|
|
|
|
switch next {
|
|
case DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// AttemptCounters groups the mutable send and confirm counters tracked by one
|
|
// challenge aggregate.
|
|
type AttemptCounters struct {
|
|
// Send counts delivery attempts initiated for the challenge.
|
|
Send int
|
|
|
|
// Confirm counts confirmation attempts evaluated against the challenge.
|
|
Confirm int
|
|
}
|
|
|
|
// Validate reports whether AttemptCounters contains only non-negative values.
|
|
func (c AttemptCounters) Validate() error {
|
|
if c.Send < 0 {
|
|
return errors.New("challenge send attempt count must not be negative")
|
|
}
|
|
if c.Confirm < 0 {
|
|
return errors.New("challenge confirm attempt count must not be negative")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AbuseMetadata stores minimal abuse-related timestamps without fixing later
|
|
// anti-abuse policy details too early.
|
|
type AbuseMetadata struct {
|
|
// LastAttemptAt optionally records the last send or confirm attempt time
|
|
// associated with the challenge.
|
|
LastAttemptAt *time.Time
|
|
}
|
|
|
|
// Validate reports whether AbuseMetadata contains structurally valid values.
|
|
func (m AbuseMetadata) Validate() error {
|
|
if m.LastAttemptAt != nil && m.LastAttemptAt.IsZero() {
|
|
return errors.New("challenge abuse metadata last attempt time must not be zero")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Confirmation stores the idempotency metadata recorded after a successful
|
|
// challenge confirmation.
|
|
type Confirmation struct {
|
|
// SessionID is the created device session returned by the successful
|
|
// confirmation.
|
|
SessionID common.DeviceSessionID
|
|
|
|
// ClientPublicKey is the validated client key bound to SessionID.
|
|
ClientPublicKey common.ClientPublicKey
|
|
|
|
// ConfirmedAt records when the successful confirmation happened.
|
|
ConfirmedAt time.Time
|
|
}
|
|
|
|
// Validate reports whether Confirmation contains all metadata required for a
|
|
// confirmed challenge.
|
|
func (c Confirmation) Validate() error {
|
|
if err := c.SessionID.Validate(); err != nil {
|
|
return fmt.Errorf("challenge confirmation session id: %w", err)
|
|
}
|
|
if err := c.ClientPublicKey.Validate(); err != nil {
|
|
return fmt.Errorf("challenge confirmation client public key: %w", err)
|
|
}
|
|
if c.ConfirmedAt.IsZero() {
|
|
return errors.New("challenge confirmation time must not be zero")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Challenge is the minimal source-of-truth aggregate shape fixed by Stage 2.
|
|
type Challenge struct {
|
|
// ID identifies the challenge.
|
|
ID common.ChallengeID
|
|
|
|
// Email stores the normalized target e-mail address.
|
|
Email common.Email
|
|
|
|
// CodeHash stores only the hashed confirmation code.
|
|
CodeHash []byte
|
|
|
|
// Status reports the coarse challenge lifecycle state.
|
|
Status Status
|
|
|
|
// DeliveryState reports the recorded delivery outcome.
|
|
DeliveryState DeliveryState
|
|
|
|
// CreatedAt reports when the challenge was created.
|
|
CreatedAt time.Time
|
|
|
|
// ExpiresAt reports when the challenge becomes unusable.
|
|
ExpiresAt time.Time
|
|
|
|
// Attempts groups the send and confirm counters.
|
|
Attempts AttemptCounters
|
|
|
|
// Abuse stores minimal abuse-related timestamps.
|
|
Abuse AbuseMetadata
|
|
|
|
// Confirmation is present only after a successful confirm transition.
|
|
Confirmation *Confirmation
|
|
}
|
|
|
|
// IsExpiredAt reports whether the challenge is unusable at now either because
|
|
// it is already marked expired or because its expiration timestamp has passed.
|
|
func (c Challenge) IsExpiredAt(now time.Time) bool {
|
|
return c.Status == StatusExpired || !c.ExpiresAt.After(now)
|
|
}
|
|
|
|
// Validate reports whether Challenge satisfies the Stage-2 structural and
|
|
// lifecycle invariants.
|
|
func (c Challenge) Validate() error {
|
|
if err := c.ID.Validate(); err != nil {
|
|
return fmt.Errorf("challenge id: %w", err)
|
|
}
|
|
if err := c.Email.Validate(); err != nil {
|
|
return fmt.Errorf("challenge email: %w", err)
|
|
}
|
|
if len(c.CodeHash) == 0 {
|
|
return errors.New("challenge code hash must not be empty")
|
|
}
|
|
if !c.Status.IsKnown() {
|
|
return fmt.Errorf("challenge status %q is unsupported", c.Status)
|
|
}
|
|
if !c.DeliveryState.IsKnown() {
|
|
return fmt.Errorf("challenge delivery state %q is unsupported", c.DeliveryState)
|
|
}
|
|
if c.CreatedAt.IsZero() {
|
|
return errors.New("challenge creation time must not be zero")
|
|
}
|
|
if c.ExpiresAt.IsZero() {
|
|
return errors.New("challenge expiration time must not be zero")
|
|
}
|
|
if c.ExpiresAt.Before(c.CreatedAt) {
|
|
return errors.New("challenge expiration time must not be before creation time")
|
|
}
|
|
if err := c.Attempts.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Abuse.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch c.Status {
|
|
case StatusPendingSend:
|
|
if c.DeliveryState != DeliveryPending {
|
|
return errors.New("pending_send challenge must keep pending delivery state")
|
|
}
|
|
case StatusSent:
|
|
if c.DeliveryState != DeliverySent {
|
|
return errors.New("sent challenge must keep sent delivery state")
|
|
}
|
|
case StatusDeliverySuppressed:
|
|
if c.DeliveryState != DeliverySuppressed {
|
|
return errors.New("delivery_suppressed challenge must keep suppressed delivery state")
|
|
}
|
|
case StatusDeliveryThrottled:
|
|
if c.DeliveryState != DeliveryThrottled {
|
|
return errors.New("delivery_throttled challenge must keep throttled delivery state")
|
|
}
|
|
case StatusConfirmedPendingExpire:
|
|
if c.DeliveryState != DeliverySent && c.DeliveryState != DeliverySuppressed {
|
|
return errors.New("confirmed_pending_expire challenge must come from sent or suppressed delivery state")
|
|
}
|
|
}
|
|
|
|
if c.Status == StatusConfirmedPendingExpire {
|
|
if c.Confirmation == nil {
|
|
return errors.New("confirmed_pending_expire challenge must contain confirmation metadata")
|
|
}
|
|
if err := c.Confirmation.Validate(); err != nil {
|
|
return fmt.Errorf("challenge confirmation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if c.Confirmation != nil {
|
|
return errors.New("only confirmed_pending_expire challenge may contain confirmation metadata")
|
|
}
|
|
|
|
return nil
|
|
}
|