diff --git a/.rubocop.yml b/.rubocop.yml index fdf4c92..d5d52eb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -30,7 +30,7 @@ Metrics/AbcSize: Max: 30 Metrics/ClassLength: - Max: 175 + Max: 200 # Result is a value object initialized with many named parameters. Metrics/ParameterLists: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9073453..69cd115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,23 @@ -## [Unreleased] +## [0.5.0] - 2026-03-22 + +### Added +- **IP-based rate limiting** — new opt-in configuration options `max_ip_send_attempts` + and `max_ip_verification_attempts` protect against SMS pumping, distributed brute + force, and IP-based abuse. Both default to `nil` (disabled). When configured, IP + attempts are tracked using the existing storage backends with no schema changes required. +- `Verifier#send_code` now accepts an optional `request:` keyword, matching + `verify_code`. The engine controller and `Verifiable` concern forward it automatically. +- `RateLimiter` gains `ip_send_rate_limited?`, `ip_verification_rate_limited?`, + `record_ip_send_attempt`, and `record_ip_verification_attempt` methods. +- `Verifiable#send_{channel}_code` now accepts an optional `request:` keyword. + +### Fixed +- **DatabaseStorage race conditions** — `store_code` and `increment_attempt_count` + now use atomic patterns with `rescue ActiveRecord::RecordNotUnique` + retry, + preventing duplicate records and rate limit bypass under concurrency. +- Migration template indexes on `verify_it_codes` and `verify_it_attempts` are now + `unique: true`, enforcing data integrity at the database level. +- `Attempt` model validation now accepts `ip_send` and `ip_verification` attempt types. ### Changed - README: generator commands now show the bare command first; `--storage` and diff --git a/Gemfile.lock b/Gemfile.lock index 5c2b966..993c3bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - verify_it (0.4.2) + verify_it (0.5.0) activesupport (>= 6.0) GEM @@ -379,7 +379,7 @@ CHECKSUMS unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 - verify_it (0.4.2) + verify_it (0.5.0) webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 diff --git a/README.md b/README.md index 6a5236e..c71ddef 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,8 @@ VerifyIt.cleanup(to: "+15551234567", record: user) | `max_send_attempts` | `3` | Integer | | `max_verification_attempts` | `5` | Integer | | `max_identifier_changes` | `5` | Integer | +| `max_ip_send_attempts` | `nil` | Integer or `nil` (disabled) — max sends per IP per window | +| `max_ip_verification_attempts` | `nil` | Integer or `nil` (disabled) — max verifications per IP per window | | `rate_limit_window` | `3600` | Seconds (integer) | | `delivery_channel` | `:sms` | `:sms`, `:email` | | `sms_sender` | `nil` | Lambda `(to:, code:, context:) { }` | @@ -401,6 +403,16 @@ end - Use `:redis` or `:database` storage in production - Keep `code_ttl` short. - Use `on_verify_failure` to monitor and alert on repeated failures +- **IP-based rate limiting** — enable `max_ip_send_attempts` and `max_ip_verification_attempts` to protect against SMS pumping and distributed brute force attacks: + +```ruby +VerifyIt.configure do |config| + config.max_ip_send_attempts = 10 # max 10 sends per IP per window + config.max_ip_verification_attempts = 20 # max 20 verifications per IP per window +end +``` + +IP rate limiting requires passing `request:` to `send_code`/`verify_code`. The engine controller does this automatically. For custom controllers, pass `request:` explicitly. --- diff --git a/app/controllers/verify_it/verifications_controller.rb b/app/controllers/verify_it/verifications_controller.rb index 4469b2f..4fba26f 100644 --- a/app/controllers/verify_it/verifications_controller.rb +++ b/app/controllers/verify_it/verifications_controller.rb @@ -13,7 +13,7 @@ def create channel = resolve_channel identifier = resolve_identifier(record, channel) - result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {}) + result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {}, request: request) if result.success? payload = { message: I18n.t("verify_it.responses.sent") } diff --git a/config/locales/en.yml b/config/locales/en.yml index 24c032b..5790aaa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -9,6 +9,7 @@ en: code_not_found: "No active verification code found. Please request a new one." locked: "Your account is temporarily locked due to too many failed attempts." delivery_failed: "Failed to deliver verification code. Please try again." + ip_rate_limited: "Too many requests from this IP address. Please try again later." sms: default_message: "%{code} is your verification code." email: diff --git a/lib/generators/verify_it/install/templates/create_verify_it_tables.rb b/lib/generators/verify_it/install/templates/create_verify_it_tables.rb index 1e53df1..18eed41 100644 --- a/lib/generators/verify_it/install/templates/create_verify_it_tables.rb +++ b/lib/generators/verify_it/install/templates/create_verify_it_tables.rb @@ -8,7 +8,7 @@ def change t.datetime :expires_at, null: false t.timestamps - t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record" + t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record", unique: true t.index :expires_at end @@ -22,7 +22,7 @@ def change t.timestamps t.index [:identifier, :record_type, :record_id, :attempt_type], - name: "index_verify_it_attempts_on_record_and_type" + name: "index_verify_it_attempts_on_record_and_type", unique: true t.index :expires_at end diff --git a/lib/verify_it.rb b/lib/verify_it.rb index c026372..7c4f5b2 100644 --- a/lib/verify_it.rb +++ b/lib/verify_it.rb @@ -33,8 +33,8 @@ def configure yield(configuration) end - def send_code(to:, record:, channel: nil, context: {}) - verifier.send_code(to: to, record: record, channel: channel, context: context) + def send_code(to:, record:, channel: nil, context: {}, request: nil) + verifier.send_code(to: to, record: record, channel: channel, context: context, request: request) end def verify_code(to:, code:, record:, request: nil) diff --git a/lib/verify_it/configuration.rb b/lib/verify_it/configuration.rb index 7f52b82..4df1f52 100644 --- a/lib/verify_it/configuration.rb +++ b/lib/verify_it/configuration.rb @@ -21,6 +21,8 @@ class Configuration :test_mode, :bypass_delivery, :secret_key_base, + :max_ip_send_attempts, + :max_ip_verification_attempts, :current_record_resolver, :identifier_resolver @@ -55,6 +57,8 @@ def initialize @test_mode = false @bypass_delivery = false @secret_key_base = nil + @max_ip_send_attempts = nil + @max_ip_verification_attempts = nil @current_record_resolver = nil @identifier_resolver = nil end diff --git a/lib/verify_it/rate_limiter.rb b/lib/verify_it/rate_limiter.rb index b3f4c2c..9174c8d 100644 --- a/lib/verify_it/rate_limiter.rb +++ b/lib/verify_it/rate_limiter.rb @@ -64,5 +64,29 @@ def record_identifier_change(record:, identifier:) def reset_verification_attempts(identifier:, record:) @storage.reset_attempts(identifier: identifier, record: record) end + + def ip_send_rate_limited?(ip:) + max = VerifyIt.configuration.max_ip_send_attempts + return false unless max + + count = @storage.send_count(identifier: ip, record: nil) + count >= max + end + + def ip_verification_rate_limited?(ip:) + max = VerifyIt.configuration.max_ip_verification_attempts + return false unless max + + count = @storage.attempts(identifier: ip, record: nil) + count >= max + end + + def record_ip_send_attempt(ip:) + @storage.increment_send_count(identifier: ip, record: nil) + end + + def record_ip_verification_attempt(ip:) + @storage.increment_attempts(identifier: ip, record: nil) + end end end diff --git a/lib/verify_it/storage/database_storage.rb b/lib/verify_it/storage/database_storage.rb index 7e0e83e..2ea0ce4 100644 --- a/lib/verify_it/storage/database_storage.rb +++ b/lib/verify_it/storage/database_storage.rb @@ -25,13 +25,14 @@ def store_code(identifier:, record:, code:, expires_at:) attrs[:record_id] = record.id end - # Find existing or create new existing = find_code(identifier: identifier, record: record) if existing - existing.update!(attrs) + existing.update!(code: code, expires_at: expires_at) else Models::Code.create!(attrs) end + rescue ActiveRecord::RecordNotUnique + retry end def fetch_code(identifier:, record:) @@ -146,48 +147,41 @@ def find_code(identifier:, record:) end def find_attempt(identifier:, record:, attempt_type:) + build_attempt_scope(identifier:, record:, attempt_type:).first + end + + def build_attempt_scope(identifier:, record:, attempt_type:) scope = Models::Attempt .for_identifier(identifier) .where(attempt_type: attempt_type) scope = scope.for_record(record) if record - scope.first + scope end def increment_attempt_count(identifier:, record:, attempt_type:) - attempt = find_attempt( - identifier: identifier, - record: record, - attempt_type: attempt_type - ) + # Clean expired attempts first + build_attempt_scope(identifier:, record:, attempt_type:).expired.delete_all window = VerifyIt.configuration.rate_limit_window expires_at = Time.now + window + attrs = { + identifier: identifier.to_s, + attempt_type: attempt_type, + count: 0, + expires_at: expires_at + } - if attempt.nil? || attempt.expired? - # Create new attempt record - attrs = { - identifier: identifier.to_s, - attempt_type: attempt_type, - count: 1, - expires_at: expires_at - } - - if record - attrs[:record_type] = record.class.name - attrs[:record_id] = record.id - end - - # Delete old expired attempt if exists - attempt&.destroy - - Models::Attempt.create!(attrs) - 1 - else - # Increment existing - attempt.increment!(:count) - attempt.count + if record + attrs[:record_type] = record.class.name + attrs[:record_id] = record.id end + + attempt = build_attempt_scope(identifier:, record:, attempt_type:).first_or_create!(attrs) + attempt.increment!(:count) + attempt.count + rescue ActiveRecord::RecordNotUnique + retry end def get_attempt_count(identifier:, record:, attempt_type:) diff --git a/lib/verify_it/storage/models/attempt.rb b/lib/verify_it/storage/models/attempt.rb index 22e7210..0f22b55 100644 --- a/lib/verify_it/storage/models/attempt.rb +++ b/lib/verify_it/storage/models/attempt.rb @@ -7,7 +7,7 @@ class Attempt < ::ActiveRecord::Base self.table_name = "verify_it_attempts" validates :identifier, presence: true - validates :attempt_type, presence: true, inclusion: { in: %w[verification send] } + validates :attempt_type, presence: true, inclusion: { in: %w[verification send ip_send ip_verification] } validates :count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :expires_at, presence: true diff --git a/lib/verify_it/verifiable.rb b/lib/verify_it/verifiable.rb index 40967f9..8a6a353 100644 --- a/lib/verify_it/verifiable.rb +++ b/lib/verify_it/verifiable.rb @@ -20,13 +20,14 @@ def verifies(attribute, channel: :sms) raise ArgumentError, "Method #{send_method} is already defined on #{name}" if method_defined?(send_method) - define_method(send_method) do |context: {}| + define_method(send_method) do |context: {}, request: nil| identifier = send(attribute) VerifyIt.send_code( to: identifier, record: self, channel: channel, - context: context + context: context, + request: request ) end diff --git a/lib/verify_it/verifier.rb b/lib/verify_it/verifier.rb index bdc8b3a..ca195aa 100644 --- a/lib/verify_it/verifier.rb +++ b/lib/verify_it/verifier.rb @@ -11,9 +11,14 @@ def initialize(storage:) @rate_limiter = RateLimiter.new(storage) end - def send_code(to:, record:, channel: nil, context: {}) + def send_code(to:, record:, channel: nil, context: {}, request: nil) channel ||= config.delivery_channel + ip = extract_ip(request) + if ip && rate_limiter.ip_send_rate_limited?(ip: ip) + return rate_limited_result("IP address rate limited for sending") + end + rate_limit_result = check_send_rate_limits(to: to, record: record) return rate_limit_result if rate_limit_result @@ -27,10 +32,17 @@ def send_code(to:, record:, channel: nil, context: {}) ) return delivery_result if delivery_result + rate_limiter.record_ip_send_attempt(ip: ip) if ip + finalize_send(to: to, record: record, channel: channel, code_data: code_data) end def verify_code(to:, code:, record:, request: nil) + ip = extract_ip(request) + if ip && rate_limiter.ip_verification_rate_limited?(ip: ip) + return rate_limited_result("IP address rate limited for verification") + end + rate_limit_result = check_verification_rate_limit(to: to, record: record) return rate_limit_result if rate_limit_result @@ -38,6 +50,7 @@ def verify_code(to:, code:, record:, request: nil) return code_not_found_result unless stored_code current_attempts = rate_limiter.record_verification_attempt(identifier: to, record: record) + rate_limiter.record_ip_verification_attempt(ip: ip) if ip if code_matches?(code, stored_code) handle_verification_success(to: to, record: record, attempts: current_attempts, request: request) @@ -159,6 +172,16 @@ def get_delivery_adapter(channel) end end + def extract_ip(request) + return nil unless request + + if request.respond_to?(:remote_ip) + request.remote_ip + elsif request.respond_to?(:ip) + request.ip + end + end + def invoke_callback(callback, **args) callback&.call(**args) end diff --git a/lib/verify_it/version.rb b/lib/verify_it/version.rb index 0167f27..dcb5ac5 100644 --- a/lib/verify_it/version.rb +++ b/lib/verify_it/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module VerifyIt - VERSION = "0.4.2" + VERSION = "0.5.0" end diff --git a/spec/rate_limiter_spec.rb b/spec/rate_limiter_spec.rb index 9538139..d78bb61 100644 --- a/spec/rate_limiter_spec.rb +++ b/spec/rate_limiter_spec.rb @@ -79,6 +79,61 @@ end end + describe "#ip_send_rate_limited?" do + context "when max_ip_send_attempts is nil (disabled)" do + before { VerifyIt.configuration.max_ip_send_attempts = nil } + + it "returns false regardless of count" do + 10.times { rate_limiter.record_ip_send_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_send_rate_limited?(ip: "1.2.3.4")).to be false + end + end + + context "when max_ip_send_attempts is configured" do + before { VerifyIt.configuration.max_ip_send_attempts = 5 } + + it "returns false when under limit" do + 3.times { rate_limiter.record_ip_send_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_send_rate_limited?(ip: "1.2.3.4")).to be false + end + + it "returns true when limit reached" do + 5.times { rate_limiter.record_ip_send_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_send_rate_limited?(ip: "1.2.3.4")).to be true + end + + it "tracks IPs independently" do + 5.times { rate_limiter.record_ip_send_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_send_rate_limited?(ip: "5.6.7.8")).to be false + end + end + end + + describe "#ip_verification_rate_limited?" do + context "when max_ip_verification_attempts is nil (disabled)" do + before { VerifyIt.configuration.max_ip_verification_attempts = nil } + + it "returns false regardless of count" do + 10.times { rate_limiter.record_ip_verification_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_verification_rate_limited?(ip: "1.2.3.4")).to be false + end + end + + context "when max_ip_verification_attempts is configured" do + before { VerifyIt.configuration.max_ip_verification_attempts = 10 } + + it "returns false when under limit" do + 5.times { rate_limiter.record_ip_verification_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_verification_rate_limited?(ip: "1.2.3.4")).to be false + end + + it "returns true when limit reached" do + 10.times { rate_limiter.record_ip_verification_attempt(ip: "1.2.3.4") } + expect(rate_limiter.ip_verification_rate_limited?(ip: "1.2.3.4")).to be true + end + end + end + describe "#reset_verification_attempts" do it "resets attempts counter" do rate_limiter.record_verification_attempt(identifier: identifier, record: mock_record) diff --git a/spec/storage/database_storage_spec.rb b/spec/storage/database_storage_spec.rb index a8874c5..e2708eb 100644 --- a/spec/storage/database_storage_spec.rb +++ b/spec/storage/database_storage_spec.rb @@ -25,7 +25,7 @@ t.datetime :expires_at, null: false t.timestamps - t.index %i[identifier record_type record_id], name: "index_verify_it_codes_on_record" + t.index %i[identifier record_type record_id], name: "index_verify_it_codes_on_record", unique: true t.index :expires_at end @@ -39,7 +39,7 @@ t.timestamps t.index %i[identifier record_type record_id attempt_type], - name: "index_verify_it_attempts_on_record_and_type" + name: "index_verify_it_attempts_on_record_and_type", unique: true t.index :expires_at end @@ -366,6 +366,76 @@ end end + describe "concurrency safety" do + it "store_code retries and succeeds on RecordNotUnique" do + expires_at = Time.now + 300 + call_count = 0 + + allow(VerifyIt::Storage::Models::Code).to receive(:create!).and_wrap_original do |method, *args| + call_count += 1 + raise ActiveRecord::RecordNotUnique, "duplicate" if call_count == 1 + + method.call(*args) + end + + expect do + storage.store_code(identifier: "+15559999999", record: nil, code: "123456", expires_at: expires_at) + end.not_to raise_error + + expect(call_count).to be >= 2 + end + + it "store_code finds existing record on retry after RecordNotUnique" do + expires_at = Time.now + 300 + # First store succeeds + storage.store_code(identifier: identifier, record: mock_record, code: "111111", expires_at: expires_at) + + call_count = 0 + original_create = VerifyIt::Storage::Models::Code.method(:create!) + allow(VerifyIt::Storage::Models::Code).to receive(:create!).and_wrap_original do |_method, *args| + call_count += 1 + raise ActiveRecord::RecordNotUnique, "duplicate" if call_count <= 1 + + original_create.call(*args) + end + + storage.store_code(identifier: identifier, record: mock_record, code: "222222", expires_at: expires_at) + expect(storage.fetch_code(identifier: identifier, record: mock_record)).to eq("222222") + expect(VerifyIt::Storage::Models::Code.count).to eq(1) + end + + it "increment_attempt_count retries and succeeds on RecordNotUnique" do + call_count = 0 + + allow_any_instance_of(ActiveRecord::Relation).to receive(:first_or_create!).and_wrap_original do |method, *args| + call_count += 1 + raise ActiveRecord::RecordNotUnique, "duplicate" if call_count == 1 + + method.call(*args) + end + + expect do + storage.increment_attempts(identifier: identifier, record: mock_record) + end.not_to raise_error + + expect(call_count).to be >= 2 + end + + it "sequential increment_attempts calls produce correct total count" do + 5.times { storage.increment_attempts(identifier: identifier, record: mock_record) } + expect(storage.attempts(identifier: identifier, record: mock_record)).to eq(5) + end + + it "unique constraint prevents duplicate code records" do + expires_at = Time.now + 300 + storage.store_code(identifier: identifier, record: mock_record, code: "111111", expires_at: expires_at) + storage.store_code(identifier: identifier, record: mock_record, code: "222222", expires_at: expires_at) + + expect(VerifyIt::Storage::Models::Code.count).to eq(1) + expect(storage.fetch_code(identifier: identifier, record: mock_record)).to eq("222222") + end + end + describe "ActiveRecord model scopes" do describe "Code scopes" do it "filters active codes" do diff --git a/spec/verifiable_spec.rb b/spec/verifiable_spec.rb index b78ad75..a570197 100644 --- a/spec/verifiable_spec.rb +++ b/spec/verifiable_spec.rb @@ -68,7 +68,8 @@ class SingleChannelUser < TestUser to: "+15551234567", record: user, channel: :sms, - context: {} + context: {}, + request: nil ) user.send_sms_code end @@ -79,7 +80,8 @@ class SingleChannelUser < TestUser to: "+15551234567", record: user, channel: :sms, - context: context_data + context: context_data, + request: nil ) user.send_sms_code(context: context_data) end diff --git a/spec/verifier_spec.rb b/spec/verifier_spec.rb index 3d67559..821b3f9 100644 --- a/spec/verifier_spec.rb +++ b/spec/verifier_spec.rb @@ -331,6 +331,123 @@ end end + describe "IP-based rate limiting" do + let(:fake_request) { double("request", remote_ip: "1.2.3.4") } + + before do + allow_any_instance_of(VerifyIt::Delivery::SmsDelivery).to receive(:deliver) + end + + describe "#send_code with IP rate limiting" do + context "when max_ip_send_attempts is nil (disabled)" do + before { VerifyIt.configuration.max_ip_send_attempts = nil } + + it "does not IP rate limit" do + 10.times do + result = verifier.send_code(to: identifier, record: record, request: fake_request) + # Only blocked by per-identifier limit, not IP + break unless result.success? + end + end + end + + context "when max_ip_send_attempts is configured" do + before { VerifyIt.configuration.max_ip_send_attempts = 2 } + + it "blocks after IP send limit exceeded" do + verifier.send_code(to: identifier, record: record, request: fake_request) + verifier.send_code(to: "+15559999999", record: record, request: fake_request) + + result = verifier.send_code(to: "+15558888888", record: record, request: fake_request) + expect(result.success?).to be false + expect(result.rate_limited?).to be true + expect(result.message).to include("IP address") + end + + it "allows different IPs independently" do + other_request = double("request", remote_ip: "5.6.7.8") + 2.times { verifier.send_code(to: identifier, record: record, request: fake_request) } + + result = verifier.send_code(to: identifier, record: record, request: other_request) + # May be limited by per-identifier rate limit, but not by IP + expect(result.message).not_to include("IP address") if result.rate_limited? + end + + it "does not record IP attempt on delivery failure" do + allow_any_instance_of(VerifyIt::Delivery::SmsDelivery).to receive(:deliver).and_raise(StandardError) + verifier.send_code(to: identifier, record: record, request: fake_request) + + # IP counter should not have been incremented since delivery failed + expect(storage.send_count(identifier: "1.2.3.4", record: nil)).to eq(0) + end + end + + context "when request is nil" do + it "skips IP rate limiting entirely" do + VerifyIt.configuration.max_ip_send_attempts = 1 + result = verifier.send_code(to: identifier, record: record, request: nil) + expect(result.success?).to be true + end + end + + context "when request only responds to #ip" do + let(:rack_request) { double("rack_request", ip: "9.8.7.6") } + + it "extracts IP via #ip fallback" do + VerifyIt.configuration.max_ip_send_attempts = 1 + verifier.send_code(to: identifier, record: record, request: rack_request) + + result = verifier.send_code(to: "+15559999999", record: record, request: rack_request) + expect(result.rate_limited?).to be true + end + end + end + + describe "#verify_code with IP rate limiting" do + let(:code) { "123456" } + let(:hashed_code) { VerifyIt::CodeHasher.digest(code, secret: VerifyIt.configuration.secret_key_base) } + + before do + storage.store_code( + identifier: identifier, + record: record, + code: hashed_code, + expires_at: Time.now + 300 + ) + end + + context "when max_ip_verification_attempts is configured" do + before { VerifyIt.configuration.max_ip_verification_attempts = 3 } + + it "blocks after IP verification limit exceeded" do + 3.times { verifier.verify_code(to: identifier, code: "wrong", record: record, request: fake_request) } + + # Re-store code since attempts may have consumed it + storage.store_code( + identifier: identifier, + record: record, + code: hashed_code, + expires_at: Time.now + 300 + ) + + result = verifier.verify_code(to: identifier, code: code, record: record, request: fake_request) + expect(result.success?).to be false + expect(result.rate_limited?).to be true + expect(result.message).to include("IP address") + end + end + + context "when max_ip_verification_attempts is nil (disabled)" do + before { VerifyIt.configuration.max_ip_verification_attempts = nil } + + it "does not IP rate limit verification" do + result = verifier.verify_code(to: identifier, code: code, record: record, request: fake_request) + expect(result.success?).to be true + end + end + end + end + describe "#cleanup" do it "returns success result" do result = verifier.cleanup(to: identifier, record: record)