// Package flow owns signup and account activation, independently of second-factor checks. package flow import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "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 type SignupFacts interface { Ensure(context.Context, *customer.Account) error Ready(context.Context, bson.ObjectID) error } type Service struct { Facts SignupFacts 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 (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 := customer.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.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key)) 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.IsArchived() || !existing.MatchPassword(a.Password) { return nil, merrors.DataConflict("email already registered") } a = existing } else if err != nil { return nil, err } if s.Facts != nil { if err := s.Facts.Ensure(ctx, a); 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.sendActivation(ctx, a, key) } return a, err } func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error { email, err := customer.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 } if s.Facts != nil { if err := s.Facts.Ensure(ctx, a); err != nil { return err } } _, err = s.sendActivation(ctx, a, key) return err } func (s *Service) activationKey(email, key string) string { return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key) } func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) { if strings.TrimSpace(key) == "" || len(key) > 128 { return time.Time{}, merrors.InvalidArgument("idempotency key required") } secretKey := s.activationKey(a.Login, key) req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey) req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC())) result, err := s.Tokens.CreateWithResult(ctx, req) if err != nil { return time.Time{}, err } if !result.ExpiresAt.After(time.Now()) { return time.Time{}, verification.ErorrTokenExpired() } locale := "en" if strings.HasPrefix(strings.ToLower(a.Locale), "ru") { locale = "ru" } u, _ := url.Parse(s.ActivationURL) q := u.Query() q.Set("token", result.VerificationToken) u.RawQuery = q.Encode() err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}}) return result.ExpiresAt, err } func (s *Service) Activate(ctx context.Context, raw string) (string, error) { if len(raw) != 43 { return "", verification.ErorrTokenNotFound() } var email string _, 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") } if s.Facts != nil { if err := s.Facts.Ready(tx, a.ID); err != nil { return nil, err } } if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil { return nil, err } email = a.Login return nil, nil }) if err != nil { return "", err } return email, nil }