// Package flow owns signup and email challenge workflows. package flow import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "net/mail" "net/url" "strings" "time" accountservice "git.sendico.io/tech/sxchnge/product/customers" customer "git.sendico.io/tech/sxchnge/product/customers/model" account "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/notifications" verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" verification "git.sendico.io/tech/sxchnge/product/verification/store" "git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/transaction" merrors "git.sendico.io/tech/sxchnge/shared/errors" ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store" "go.mongodb.org/mongo-driver/v2/bson" ) const Cooldown = time.Minute const LoginTTL = 10 * time.Minute type Service struct { Accounts account.DB Customers accountservice.AccountService Tokens verification.DB Transactions transaction.Factory Attempts ratelimits.Store Sender notifications.Sender Secret []byte ActivationURL string } func (s *Service) Validate() error { u, err := url.Parse(s.ActivationURL) if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" { return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)") } if len(s.Secret) < 32 { return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes") } return nil } func NormalizeEmail(email string) (string, error) { email = strings.ToLower(strings.TrimSpace(email)) a, err := mail.ParseAddress(email) if err != nil || a.Address != email || len(email) > 254 { return "", merrors.InvalidArgument("valid email required") } return email, nil } func (s *Service) key(parts ...string) string { h := hmac.New(sha256.New, s.Secret) for _, p := range parts { h.Write([]byte(p)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil)) } func (s *Service) Limit(ctx context.Context, ip, email string) error { now := time.Now().UTC() start := now.Truncate(time.Hour) for _, scope := range []struct { kind, value string limit int }{{"auth_email", email, 10}, {"auth_ip", ip, 30}} { count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour)) if err != nil { return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable") } if count > scope.limit { return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now)) } } return nil } func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) { email, err := NormalizeEmail(a.Login) if err != nil { return nil, err } a.Login = email a.Name = strings.TrimSpace(a.Name) if len(a.Name) > 200 || len(a.Password) > 72 { return nil, merrors.InvalidArgument("name or password too long") } if err = s.Limit(ctx, ip, email); err != nil { return nil, err } raw, err := s.Customers.CreateAccount(ctx, a) if errors.Is(err, merrors.ErrDataConflict) { existing, lookup := s.Accounts.GetByEmail(ctx, email) if lookup != nil { return nil, lookup } // A retry after a queue failure may resume only with the original password. if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) { return nil, merrors.DataConflict("email already registered") } a = existing } else if err != nil { return nil, err } if raw != "" { u, _ := url.Parse(s.ActivationURL) q := u.Query() q.Set("token", raw) u.RawQuery = q.Encode() locale := "en" if strings.HasPrefix(a.Locale, "ru") { locale = "ru" } err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)}) } else { _, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key) } return a, err } func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error { email, err := NormalizeEmail(email) if err != nil { return err } if err = s.Limit(ctx, ip, email); err != nil { return err } a, err := s.Accounts.GetByEmail(ctx, email) if errors.Is(err, merrors.ErrNoData) { return nil } if err != nil { return err } if a.Status != customer.AccountPendingVerification || a.IsArchived() { return nil } _, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key) return err } func (s *Service) Issue(ctx context.Context, a *customer.Account, purpose verificationmodel.VerificationPurpose, scope, key string, deadlines ...time.Time) (time.Time, error) { if strings.TrimSpace(key) == "" || len(key) > 128 { return time.Time{}, merrors.InvalidArgument("idempotency key required (maximum 128 characters)") } secretKey := s.key(a.ID.Hex(), string(purpose), scope, key) req := verification.NewOTPRequest(a.ID, purpose, "").WithTTL(LoginTTL).WithMaxRetries(5) if purpose == verificationmodel.PurposeAccountActivation { req = verification.NewLinkRequest(a.ID, purpose, "").WithTTL(24 * time.Hour) } if len(deadlines) > 0 { remaining := time.Until(deadlines[0]) if remaining <= 0 { return time.Time{}, verification.ErorrTokenExpired() } if remaining < req.Ttl { req.WithTTL(remaining) } } req.WithContextRef(scope).WithIdempotencyKey(secretKey).WithCooldown(Cooldown) now := time.Now().UTC() // Destination limits span every challenge/context, including password retries. req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, now)) result, err := s.Tokens.CreateWithResult(ctx, req) if err != nil { return time.Time{}, err } if !result.ExpiresAt.After(now) { return time.Time{}, verification.ErorrTokenExpired() } locale := "en" if strings.HasPrefix(strings.ToLower(a.Locale), "ru") { locale = "ru" } message := notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.login", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"code": result.VerificationToken}} if purpose == verificationmodel.PurposeAccountActivation { u, _ := url.Parse(s.ActivationURL) q := u.Query() q.Set("token", result.VerificationToken) u.RawQuery = q.Encode() message.Template = "auth.activation" message.Parameters = map[string]string{"url": u.String()} } // Replay recovers ambiguous RPC failures using the same rendered payload and expiry. if err = s.Sender.Send(ctx, message); err != nil { return time.Time{}, err } return result.ExpiresAt, nil } func (s *Service) Activate(ctx context.Context, raw string) error { if len(raw) != 43 { return verification.ErorrTokenNotFound() } _, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw) if err != nil { return nil, err } a := &customer.Account{} if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil { return nil, err } if a.Status != customer.AccountPendingVerification || a.IsArchived() { return nil, merrors.Unauthorized("account cannot be activated") } return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)) }) return err }