package twofactor import ( "context" "errors" model "git.sendico.io/tech/sxchnge/product/twofactor/model" vmodel "git.sendico.io/tech/sxchnge/product/verification/model" verification "git.sendico.io/tech/sxchnge/product/verification/store" merrors "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" "strings" "time" ) func (s *Service) Begin(ctx context.Context, req model.Request) (model.Challenge, error) { if !validScope(req.Scope) { return model.Challenge{}, merrors.InvalidArgument("unsupported second-factor operation scope") } return s.begin(ctx, req, nil) } func (s *Service) begin(ctx context.Context, req model.Request, enrollment *model.Factor) (model.Challenge, error) { if req.Subject.IsZero() || strings.TrimSpace(req.IdempotencyKey) == "" || len(req.IdempotencyKey) > 128 || strings.ContainsRune(req.IdempotencyKey, 0) { return model.Challenge{}, merrors.InvalidArgument("subject and idempotency key required") } a, p, version, err := s.profile(ctx, req.Subject) if err != nil { return model.Challenge{}, err } factor, err := s.preferred(p) if err != nil { return model.Challenge{}, err } if enrollment != nil { factor = *enrollment } // Repeating an operation key resolves to the same immutable challenge snapshot. id, _ := bson.ObjectIDFromHex(s.key("challenge", req.Subject.Hex(), scopeKey(req.Scope), req.IdempotencyKey)[:24]) expires := time.Now().UTC().Add(TTL).Truncate(time.Millisecond) if !req.Deadline.IsZero() && req.Deadline.Before(expires) { expires = req.Deadline.UTC().Truncate(time.Millisecond) } if !expires.After(time.Now()) { return model.Challenge{}, verification.ErorrTokenExpired() } locale := "en" if strings.HasPrefix(strings.ToLower(a.Locale), "ru") { locale = "ru" } candidate := &model.Record{Subject: req.Subject, Scope: req.Scope, Factor: factor, ProfileVersion: version, Locale: locale, ExpiresAt: expires} candidate.ID = id result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { r, err := s.store.Challenge(tx, id) if errors.Is(err, merrors.ErrNoData) { r = candidate if err = s.store.CreateChallenge(tx, r); err != nil { return nil, err } } else if err != nil { return nil, err } if r.Subject != req.Subject || r.Scope != req.Scope || r.Factor != factor || r.ProfileVersion != version { return nil, merrors.DataConflict("second-factor request changed") } return s.issue(tx, r, req.IdempotencyKey, req.IP) }) if err != nil { return model.Challenge{}, err } return s.deliver(ctx, result) } func (s *Service) Resend(ctx context.Context, req model.Request, id string) (model.Challenge, error) { result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { r, err := s.record(tx, id, req.Subject, req.Scope) if err != nil { return nil, err } return s.issue(tx, r, req.IdempotencyKey, req.IP) }) if err != nil { return model.Challenge{}, err } return s.deliver(ctx, result) } type issued struct { record *model.Record delivery model.Delivery resendAt time.Time } func (s *Service) issue(ctx context.Context, r *model.Record, key, ip string) (issued, error) { if r.VerifiedAt != nil { return issued{}, verification.ErorrTokenAlreadyUsed() } if !r.ExpiresAt.After(time.Now()) { return issued{}, verification.ErorrTokenExpired() } if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsRune(key, 0) { return issued{}, merrors.InvalidArgument("idempotency key required") } if ip == "" { ip = "unresolved" } secretKey := s.key("code", r.ID.Hex(), key) now := time.Now().UTC() req := verification.NewOTPRequest(r.Subject, r.Scope.Purpose, r.Factor.ID). WithContextRef(r.ID.Hex()).WithTTL(time.Until(r.ExpiresAt)).WithCooldown(Cooldown).WithMaxRetries(5).WithIdempotencyKey(secretKey). WithIssuanceLimits(verification.NewIssuanceLimit("twofactor_subject", r.Subject.Hex(), 5, time.Hour, now), verification.NewIssuanceLimit("twofactor_ip", ip, 20, time.Hour, now)) token, err := s.tokens.CreateWithResult(ctx, req) if err != nil { return issued{}, err } if !token.ExpiresAt.After(now) { return issued{}, verification.ErorrTokenExpired() } return issued{record: r, delivery: model.Delivery{Key: secretKey, Subject: r.Subject, Scope: r.Scope, Factor: r.Factor, Locale: r.Locale, Code: token.VerificationToken, ExpiresAt: token.ExpiresAt}, resendAt: now.Add(Cooldown)}, nil } func (s *Service) deliver(ctx context.Context, result any) (model.Challenge, error) { issued, ok := result.(issued) if !ok { return model.Challenge{}, merrors.Internal("invalid second-factor issuance result") } driver := s.drivers[issued.record.Factor.Channel] if driver == nil { return model.Challenge{}, merrors.NotImplemented("second-factor channel unavailable") } if err := driver.Send(ctx, issued.delivery); err != nil { s.logger.Warn("Second-factor delivery unavailable", zap.String("challenge", issued.record.ID.Hex()), zap.String("channel", string(driver.Channel()))) return model.Challenge{}, merrors.ServiceUnavailableWrap(err, "second-factor delivery unavailable") } s.logger.Info("Second-factor challenge queued", zap.String("challenge", issued.record.ID.Hex()), zap.String("purpose", string(issued.record.Scope.Purpose)), zap.String("channel", string(driver.Channel()))) return model.Challenge{ID: issued.record.ID.Hex(), Channel: driver.Channel(), Target: driver.Mask(issued.record.Factor.Address), ExpiresAt: issued.delivery.ExpiresAt, ResendAt: issued.resendAt}, nil } func preferenceScope(factorID string) model.Scope { return model.Scope{Purpose: vmodel.PurposeFactorPreference, ContextRef: "preferred:" + factorID} }