package twofactor import ( "context" "crypto/rand" 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" ) type verificationOutcome struct { proof model.Proof rejected error } func (s *Service) Verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) { if !validScope(scope) { return model.Proof{}, merrors.InvalidArgument("unsupported second-factor operation scope") } return s.verify(ctx, subject, scope, id, code) } func (s *Service) verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) { code = strings.TrimSpace(code) if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { return model.Proof{}, verification.ErorrTokenNotFound() } result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { r, err := s.record(tx, id, subject, scope) if err != nil { return nil, err } if r.VerifiedAt != nil { return nil, verification.ErorrTokenAlreadyUsed() } // Commit rejected verification attempts while rolling back infrastructure // failures. Sendico's Consume records misses in the enclosing transaction. _, err = s.tokens.Consume(tx, subject, scope.Purpose, r.ID.Hex(), code) if err != nil { if isRejection(err) { return verificationOutcome{rejected: err}, nil } return nil, err } ttl := min(proofTTL, time.Until(r.ExpiresAt)) if ttl <= 0 { return nil, verification.ErorrTokenExpired() } token, err := s.tokens.CreateWithResult(tx, verification.NewLinkRequest(subject, vmodel.PurposeFactorProof, r.ID.Hex()).WithContextRef(scopeKey(scope)).WithTTL(ttl).WithIdempotencyKey(s.key("proof", r.ID.Hex()))) if err != nil { return nil, err } now := time.Now().UTC() r.VerifiedAt = &now if err = s.store.UpdateChallenge(tx, r); err != nil { return nil, err } return verificationOutcome{proof: model.Proof{Token: token.VerificationToken, ExpiresAt: token.ExpiresAt}}, nil }) if err != nil { return model.Proof{}, err } outcome, ok := result.(verificationOutcome) if !ok { return model.Proof{}, merrors.Internal("invalid second-factor verification result") } if outcome.rejected != nil { return model.Proof{}, outcome.rejected } s.logger.Debug("Second-factor proof prepared", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose))) return outcome.proof, nil } func (s *Service) Consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error { if !validScope(scope) { return merrors.InvalidArgument("unsupported second-factor operation scope") } return s.consume(ctx, subject, scope, proof, action) } func (s *Service) consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error { if action == nil || len(proof.Token) != 43 { return merrors.InvalidArgument("proof and transactional action required") } _, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { token, err := s.tokens.Consume(tx, subject, vmodel.PurposeFactorProof, scopeKey(scope), proof.Token) if err != nil { return nil, err } r, err := s.record(tx, token.Target, subject, scope) if err != nil { return nil, err } if r.VerifiedAt == nil { return nil, verification.ErorrTokenNotFound() } // Touch the profile in this transaction so a concurrent preference update // conflicts and cannot let an old-factor proof authorize a later action. _, p, _, err := s.profile(tx, subject) if err != nil { return nil, err } p.Fence = rand.Text() if err = s.saveProfile(tx, p); err != nil { return nil, err } return nil, action(tx) }) if err == nil { s.logger.Debug("Second-factor proof consumption prepared", zap.String("purpose", string(scope.Purpose))) } return err } // Complete atomically verifies the code, consumes its proof and commits the // owning operation. A failed operation leaves the code retryable; a rejected // code still commits its attempt counter. func (s *Service) Complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error { if !validScope(scope) { return merrors.InvalidArgument("unsupported second-factor operation scope") } return s.complete(ctx, subject, scope, id, code, action) } func (s *Service) complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error { result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { proof, err := s.verify(tx, subject, scope, id, code) if err != nil { if isRejection(err) { return verificationOutcome{rejected: err}, nil } return nil, err } if err = s.consume(tx, subject, scope, proof, action); err != nil { return nil, err } return verificationOutcome{}, nil }) if err != nil { return err } outcome, ok := result.(verificationOutcome) if !ok { return merrors.Internal("invalid second-factor completion result") } if outcome.rejected == nil { s.logger.Info("Second-factor operation completed", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose))) } else { s.logger.Info("Second-factor code rejected", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose))) } return outcome.rejected }