Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
verify_it (0.4.2)
verify_it (0.5.0)
activesupport (>= 6.0)

GEM
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:) { }` |
Expand Down Expand Up @@ -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.

---

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/verify_it/verifications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions lib/verify_it.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions lib/verify_it/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions lib/verify_it/rate_limiter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 25 additions & 31 deletions lib/verify_it/storage/database_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:)
Expand Down Expand Up @@ -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:)
Expand Down
2 changes: 1 addition & 1 deletion lib/verify_it/storage/models/attempt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions lib/verify_it/verifiable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 24 additions & 1 deletion lib/verify_it/verifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -27,17 +32,25 @@ 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

stored_code = storage.fetch_code(identifier: to, record: record)
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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/verify_it/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module VerifyIt
VERSION = "0.4.2"
VERSION = "0.5.0"
end
Loading
Loading