verify_it 0.4.1.beta → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -3
- data/README.md +66 -42
- data/app/controllers/verify_it/verifications_controller.rb +1 -1
- data/config/locales/en.yml +1 -0
- data/config/routes.rb +1 -1
- data/lib/generators/verify_it/install/install_generator.rb +6 -2
- data/lib/generators/verify_it/install/templates/create_verify_it_tables.rb +2 -2
- data/lib/generators/verify_it/install/templates/initializer.rb.erb +7 -4
- data/lib/verify_it/configuration.rb +4 -0
- data/lib/verify_it/engine.rb +0 -6
- data/lib/verify_it/rate_limiter.rb +24 -0
- data/lib/verify_it/storage/database_storage.rb +25 -31
- data/lib/verify_it/storage/models/attempt.rb +1 -1
- data/lib/verify_it/verifiable.rb +6 -4
- data/lib/verify_it/verifier.rb +25 -2
- data/lib/verify_it/version.rb +1 -1
- data/lib/verify_it.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be733b2c9a751f9163ffcc41c67a22c88b19a92baf8e8e5d478f0d2cfb3c1c3b
|
|
4
|
+
data.tar.gz: fb2580dd55fda49704fa8c327721eda31d470fff6f8f4177adb3741d24d89553
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d87fdcf7e40044a25259f945151644337b21d6e404e6fc793a3b9d856f745da23fe1014e34a7240d1386ca24a7e096b2233c19548e0ba8a86470faf98eb459db
|
|
7
|
+
data.tar.gz: bf5ef12903fe759e87de6ff9cd6bda4cb53b4abe3fe5c71aaae00c63a0efd3f2290abdff32706e0b7868e276a68580d78af847a15d82beab19531a522703e5dc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,75 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.5.0] - 2026-03-22
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- **IP-based rate limiting** — new opt-in configuration options `max_ip_send_attempts`
|
|
5
|
+
and `max_ip_verification_attempts` protect against SMS pumping, distributed brute
|
|
6
|
+
force, and IP-based abuse. Both default to `nil` (disabled). When configured, IP
|
|
7
|
+
attempts are tracked using the existing storage backends with no schema changes required.
|
|
8
|
+
- `Verifier#send_code` now accepts an optional `request:` keyword, matching
|
|
9
|
+
`verify_code`. The engine controller and `Verifiable` concern forward it automatically.
|
|
10
|
+
- `RateLimiter` gains `ip_send_rate_limited?`, `ip_verification_rate_limited?`,
|
|
11
|
+
`record_ip_send_attempt`, and `record_ip_verification_attempt` methods.
|
|
12
|
+
- `Verifiable#send_{channel}_code` now accepts an optional `request:` keyword.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **DatabaseStorage race conditions** — `store_code` and `increment_attempt_count`
|
|
16
|
+
now use atomic patterns with `rescue ActiveRecord::RecordNotUnique` + retry,
|
|
17
|
+
preventing duplicate records and rate limit bypass under concurrency.
|
|
18
|
+
- Migration template indexes on `verify_it_codes` and `verify_it_attempts` are now
|
|
19
|
+
`unique: true`, enforcing data integrity at the database level.
|
|
20
|
+
- `Attempt` model validation now accepts `ip_send` and `ip_verification` attempt types.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- README: generator commands now show the bare command first; `--storage` and
|
|
24
|
+
`--engine` are documented as opt-in flags with defaults explained
|
|
25
|
+
- README: moved Rails Engine section right after Rails Setup, before Plain Ruby,
|
|
26
|
+
so all Rails content is grouped together
|
|
27
|
+
- README: Plain Ruby examples use `record: user` instead of `record: current_user`
|
|
28
|
+
to avoid implying a Rails dependency
|
|
29
|
+
- README: Engine section notes that `--engine` already generates mount and resolver
|
|
30
|
+
config, so users don't duplicate setup
|
|
31
|
+
- README: Engine resolver example no longer hardcodes Redis storage; replaced with
|
|
32
|
+
a storage-agnostic comment pointing to the Configuration Reference
|
|
33
|
+
|
|
34
|
+
## [0.4.2] - 2026-03-21
|
|
2
35
|
|
|
3
36
|
### Added
|
|
4
37
|
- `on_verify_success` callback now receives `request:` as an optional keyword argument,
|
|
5
38
|
enabling apps to access HTTP context (e.g. set session) at verification time.
|
|
6
|
-
Thread: `VerifyIt.verify_code(request:)` → `Verifier#verify_code` → `handle_verification_success`.
|
|
7
39
|
Existing proc-based callbacks are unaffected (procs ignore unknown keyword args);
|
|
8
40
|
lambda-based callbacks should add `request: nil` to their signature.
|
|
41
|
+
- `Verifiable#verify_{channel}_code` now accepts an optional `request:` keyword,
|
|
42
|
+
forwarding it to `on_verify_success` so the model-level API has parity with the
|
|
43
|
+
engine and the direct `VerifyIt.verify_code` call.
|
|
44
|
+
- `current_record_resolver` and `identifier_resolver` are now listed in the
|
|
45
|
+
Configuration Reference table in the README.
|
|
46
|
+
- Install generator accepts `--engine` flag to mount engine routes and generate
|
|
47
|
+
resolver config. Without the flag, the installer produces a minimal initializer
|
|
48
|
+
with no engine routes or resolver boilerplate.
|
|
9
49
|
|
|
10
50
|
### Changed
|
|
11
|
-
-
|
|
51
|
+
- **Breaking (engine only):** `POST /verify/send` renamed to `POST /verify/request`
|
|
52
|
+
to avoid collision with Ruby's `Object#send`. Update client code that hits this
|
|
53
|
+
endpoint. The `/verify/confirm` endpoint is unchanged.
|
|
54
|
+
- Dropped Ruby 3.1 support; minimum required Ruby is now 3.2 (due to `connection_pool >= 3.0` transitive dependency).
|
|
55
|
+
- RuboCop `TargetRubyVersion` updated from 3.0 to 3.2 to match `required_ruby_version`.
|
|
56
|
+
|
|
57
|
+
### Removed
|
|
58
|
+
- Engine no longer auto-includes `VerifyIt::Verifiable` into all ActiveRecord models.
|
|
59
|
+
Add `include VerifyIt::Verifiable` explicitly in models that need it (as shown in
|
|
60
|
+
the README). This avoids polluting unrelated models with verification methods.
|
|
61
|
+
- Install generator no longer unconditionally mounts the engine. Pass `--engine`
|
|
62
|
+
to opt in.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
- Initializer template `delivery_channel` default corrected from `:email` to `:sms`.
|
|
66
|
+
- Unused rescue variable in `Verifier#attempt_delivery` (RuboCop `Lint/UselessAssignment`).
|
|
67
|
+
- README testing example now uses correct engine params (`channel`, `auth_headers`)
|
|
68
|
+
instead of the outdated standalone format.
|
|
69
|
+
- README `on_verify_success` callback signature now includes `request: nil`.
|
|
70
|
+
- Install generator mount path changed from `/verify_it` to `/verify` to match
|
|
71
|
+
README examples.
|
|
72
|
+
- Initializer template `on_verify_success` callback signature now includes `request: nil`.
|
|
12
73
|
|
|
13
74
|
## [0.4.0] - 2026-03-07
|
|
14
75
|
|
data/README.md
CHANGED
|
@@ -35,15 +35,21 @@ bundle install
|
|
|
35
35
|
### 1. Generate configuration files
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
|
|
38
|
+
rails generate verify_it:install
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
Available options:
|
|
42
|
+
- `--storage=memory|redis|database` (default: `memory`)
|
|
43
|
+
- `--engine` — mount the VerifyIt engine and generate resolver config
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
- A database migration (if you chose Database storage)
|
|
45
|
+
Examples:
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
```bash
|
|
48
|
+
rails generate verify_it:install --storage=redis
|
|
49
|
+
rails generate verify_it:install --storage=database --engine
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If you chose `--storage=database`, run the migration:
|
|
47
53
|
|
|
48
54
|
```bash
|
|
49
55
|
rails db:migrate
|
|
@@ -159,36 +165,14 @@ current_user.send_email_code(context: {
|
|
|
159
165
|
|
|
160
166
|
---
|
|
161
167
|
|
|
162
|
-
## Plain Ruby
|
|
163
|
-
|
|
164
|
-
```ruby
|
|
165
|
-
require "verify_it"
|
|
166
|
-
|
|
167
|
-
VerifyIt.configure do |config|
|
|
168
|
-
config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
|
|
169
|
-
config.storage = :memory # :memory, :redis, or :database
|
|
170
|
-
|
|
171
|
-
config.sms_sender = ->(to:, code:, context:) {
|
|
172
|
-
MySmsSender.deliver(to: to, body: "Your code: #{code}")
|
|
173
|
-
}
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# Send a code
|
|
177
|
-
result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: current_user)
|
|
178
|
-
|
|
179
|
-
# Verify a code
|
|
180
|
-
result = VerifyIt.verify_code(to: "+15551234567", code: "123456", record: current_user)
|
|
181
|
-
|
|
182
|
-
# Clean up
|
|
183
|
-
VerifyIt.cleanup(to: "+15551234567", record: current_user)
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
168
|
## Rails Engine (HTTP Endpoints)
|
|
189
169
|
|
|
190
|
-
VerifyIt ships a mountable Rails Engine that exposes two JSON endpoints.
|
|
191
|
-
|
|
170
|
+
VerifyIt ships a mountable Rails Engine that exposes two JSON endpoints. Use the
|
|
171
|
+
engine when you want drop-in endpoints without writing controller code. This is
|
|
172
|
+
optional - skip this section if you prefer to handle verification in your own
|
|
173
|
+
controllers using the `Verifiable` concern or the plain Ruby API.
|
|
174
|
+
|
|
175
|
+
> If you ran the installer with `--engine`, the mount and resolver config are already generated for you. The details below are for reference or manual setup.
|
|
192
176
|
|
|
193
177
|
### Mount the engine
|
|
194
178
|
|
|
@@ -201,7 +185,7 @@ This adds two routes:
|
|
|
201
185
|
|
|
202
186
|
| Method | Path | Action |
|
|
203
187
|
|---|---|---|
|
|
204
|
-
| POST | `/verify/
|
|
188
|
+
| POST | `/verify/request` | Send a verification code |
|
|
205
189
|
| POST | `/verify/confirm` | Confirm a verification code |
|
|
206
190
|
|
|
207
191
|
### Configure the resolvers
|
|
@@ -212,8 +196,7 @@ Two additional config options are **required** when using the engine endpoints:
|
|
|
212
196
|
# config/initializers/verify_it.rb
|
|
213
197
|
VerifyIt.configure do |config|
|
|
214
198
|
config.secret_key_base = Rails.application.secret_key_base
|
|
215
|
-
|
|
216
|
-
config.redis_client = Redis.new
|
|
199
|
+
# ... your storage config (see Configuration Reference) ...
|
|
217
200
|
|
|
218
201
|
# Resolve the authenticated record from the request (e.g. from a session or JWT).
|
|
219
202
|
config.current_record_resolver = ->(request) {
|
|
@@ -240,7 +223,7 @@ end
|
|
|
240
223
|
**Send a code:**
|
|
241
224
|
|
|
242
225
|
```bash
|
|
243
|
-
POST /verify/
|
|
226
|
+
POST /verify/request
|
|
244
227
|
Content-Type: application/json
|
|
245
228
|
|
|
246
229
|
{ "channel": "sms" }
|
|
@@ -283,6 +266,32 @@ en:
|
|
|
283
266
|
|
|
284
267
|
---
|
|
285
268
|
|
|
269
|
+
## Plain Ruby
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
require "verify_it"
|
|
273
|
+
|
|
274
|
+
VerifyIt.configure do |config|
|
|
275
|
+
config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
|
|
276
|
+
config.storage = :memory # :memory, :redis, or :database
|
|
277
|
+
|
|
278
|
+
config.sms_sender = ->(to:, code:, context:) {
|
|
279
|
+
MySmsSender.deliver(to: to, body: "Your code: #{code}")
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Send a code
|
|
284
|
+
result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: user)
|
|
285
|
+
|
|
286
|
+
# Verify a code
|
|
287
|
+
result = VerifyIt.verify_code(to: "+15551234567", code: "123456", record: user)
|
|
288
|
+
|
|
289
|
+
# Clean up
|
|
290
|
+
VerifyIt.cleanup(to: "+15551234567", record: user)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
286
295
|
## Configuration Reference
|
|
287
296
|
|
|
288
297
|
| Option | Default | Accepted values |
|
|
@@ -296,12 +305,17 @@ en:
|
|
|
296
305
|
| `max_send_attempts` | `3` | Integer |
|
|
297
306
|
| `max_verification_attempts` | `5` | Integer |
|
|
298
307
|
| `max_identifier_changes` | `5` | Integer |
|
|
308
|
+
| `max_ip_send_attempts` | `nil` | Integer or `nil` (disabled) — max sends per IP per window |
|
|
309
|
+
| `max_ip_verification_attempts` | `nil` | Integer or `nil` (disabled) — max verifications per IP per window |
|
|
299
310
|
| `rate_limit_window` | `3600` | Seconds (integer) |
|
|
311
|
+
| `delivery_channel` | `:sms` | `:sms`, `:email` |
|
|
300
312
|
| `sms_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
|
|
301
313
|
| `email_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
|
|
302
314
|
| `on_send` | `nil` | Lambda `(record:, identifier:, channel:) { }` |
|
|
303
|
-
| `on_verify_success` | `nil` | Lambda `(record:, identifier:) { }` |
|
|
315
|
+
| `on_verify_success` | `nil` | Lambda `(record:, identifier:, request: nil) { }` |
|
|
304
316
|
| `on_verify_failure` | `nil` | Lambda `(record:, identifier:, attempts:) { }` |
|
|
317
|
+
| `current_record_resolver` | `nil` | Lambda `(request) { }` — required when mounting the engine |
|
|
318
|
+
| `identifier_resolver` | `nil` | Lambda `(record, channel) { }` — required when mounting the engine |
|
|
305
319
|
| `namespace` | `nil` | Lambda `(record) { }` returning a string or nil |
|
|
306
320
|
| `test_mode` | `false` | Boolean — exposes `result.code` |
|
|
307
321
|
| `bypass_delivery` | `false` | Boolean — skips actual delivery |
|
|
@@ -313,7 +327,7 @@ config.on_send = ->(record:, identifier:, channel:) {
|
|
|
313
327
|
Analytics.track("verification_sent", user_id: record.id, channel: channel)
|
|
314
328
|
}
|
|
315
329
|
|
|
316
|
-
config.on_verify_success = ->(record:, identifier:) {
|
|
330
|
+
config.on_verify_success = ->(record:, identifier:, request: nil) {
|
|
317
331
|
Analytics.track("verification_success", user_id: record.id)
|
|
318
332
|
}
|
|
319
333
|
|
|
@@ -368,14 +382,14 @@ RSpec.configure do |config|
|
|
|
368
382
|
end
|
|
369
383
|
```
|
|
370
384
|
|
|
371
|
-
In a request spec:
|
|
385
|
+
In a request spec (with the engine mounted):
|
|
372
386
|
|
|
373
387
|
```ruby
|
|
374
388
|
it "verifies phone number" do
|
|
375
|
-
post "/verify/
|
|
389
|
+
post "/verify/request", params: { channel: "sms" }, headers: auth_headers
|
|
376
390
|
code = JSON.parse(response.body)["code"] # available in test_mode
|
|
377
391
|
|
|
378
|
-
post "/verify/confirm", params: {
|
|
392
|
+
post "/verify/confirm", params: { channel: "sms", code: code }, headers: auth_headers
|
|
379
393
|
expect(response).to have_http_status(:ok)
|
|
380
394
|
end
|
|
381
395
|
```
|
|
@@ -389,6 +403,16 @@ end
|
|
|
389
403
|
- Use `:redis` or `:database` storage in production
|
|
390
404
|
- Keep `code_ttl` short.
|
|
391
405
|
- Use `on_verify_failure` to monitor and alert on repeated failures
|
|
406
|
+
- **IP-based rate limiting** — enable `max_ip_send_attempts` and `max_ip_verification_attempts` to protect against SMS pumping and distributed brute force attacks:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
VerifyIt.configure do |config|
|
|
410
|
+
config.max_ip_send_attempts = 10 # max 10 sends per IP per window
|
|
411
|
+
config.max_ip_verification_attempts = 20 # max 20 verifications per IP per window
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
IP rate limiting requires passing `request:` to `send_code`/`verify_code`. The engine controller does this automatically. For custom controllers, pass `request:` explicitly.
|
|
392
416
|
|
|
393
417
|
---
|
|
394
418
|
|
|
@@ -13,7 +13,7 @@ module VerifyIt
|
|
|
13
13
|
|
|
14
14
|
channel = resolve_channel
|
|
15
15
|
identifier = resolve_identifier(record, channel)
|
|
16
|
-
result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {})
|
|
16
|
+
result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {}, request: request)
|
|
17
17
|
|
|
18
18
|
if result.success?
|
|
19
19
|
payload = { message: I18n.t("verify_it.responses.sent") }
|
data/config/locales/en.yml
CHANGED
|
@@ -9,6 +9,7 @@ en:
|
|
|
9
9
|
code_not_found: "No active verification code found. Please request a new one."
|
|
10
10
|
locked: "Your account is temporarily locked due to too many failed attempts."
|
|
11
11
|
delivery_failed: "Failed to deliver verification code. Please try again."
|
|
12
|
+
ip_rate_limited: "Too many requests from this IP address. Please try again later."
|
|
12
13
|
sms:
|
|
13
14
|
default_message: "%{code} is your verification code."
|
|
14
15
|
email:
|
data/config/routes.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
VerifyIt::Engine.routes.draw do
|
|
4
|
-
post "
|
|
4
|
+
post "request", to: "verifications#create", as: :request_verification
|
|
5
5
|
post "confirm", to: "verifications#verify", as: :confirm_verification
|
|
6
6
|
end
|
|
@@ -10,11 +10,13 @@ module VerifyIt
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Creates a VerifyIt initializer
|
|
13
|
+
desc "Creates a VerifyIt initializer and optionally mounts the engine routes " \
|
|
14
14
|
"and generates a migration when using database storage."
|
|
15
15
|
|
|
16
16
|
class_option :storage, type: :string, default: "memory",
|
|
17
17
|
desc: "Storage backend to use: memory, redis, or database"
|
|
18
|
+
class_option :engine, type: :boolean, default: false,
|
|
19
|
+
desc: "Mount the VerifyIt engine and generate resolver config"
|
|
18
20
|
|
|
19
21
|
def self.next_migration_number(dirname)
|
|
20
22
|
next_num = current_migration_number(dirname) + 1
|
|
@@ -26,7 +28,9 @@ module VerifyIt
|
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def mount_engine
|
|
29
|
-
|
|
31
|
+
return unless options[:engine]
|
|
32
|
+
|
|
33
|
+
route 'mount VerifyIt::Engine, at: "/verify"'
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def generate_migration
|
|
@@ -8,7 +8,7 @@ class CreateVerifyItTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
|
|
|
8
8
|
t.datetime :expires_at, null: false
|
|
9
9
|
t.timestamps
|
|
10
10
|
|
|
11
|
-
t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record"
|
|
11
|
+
t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record", unique: true
|
|
12
12
|
t.index :expires_at
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -22,7 +22,7 @@ class CreateVerifyItTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
|
|
|
22
22
|
t.timestamps
|
|
23
23
|
|
|
24
24
|
t.index [:identifier, :record_type, :record_id, :attempt_type],
|
|
25
|
-
name: "index_verify_it_attempts_on_record_and_type"
|
|
25
|
+
name: "index_verify_it_attempts_on_record_and_type", unique: true
|
|
26
26
|
t.index :expires_at
|
|
27
27
|
end
|
|
28
28
|
|
|
@@ -23,24 +23,27 @@ VerifyIt.configure do |config|
|
|
|
23
23
|
# e.g. Twilio, Vonage, etc.
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
<% if options[:engine] -%>
|
|
27
|
+
# Required when mounting the engine (VerifyIt::Engine):
|
|
28
|
+
# Resolve the current authenticated record from a request.
|
|
27
29
|
config.current_record_resolver = ->(request) {
|
|
28
30
|
# User.find_by(id: request.session[:user_id])
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
#
|
|
33
|
+
# Resolve the delivery identifier (phone/email) from the record and channel.
|
|
32
34
|
config.identifier_resolver = ->(record, channel) {
|
|
33
35
|
# channel == :sms ? record.phone_number : record.email
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
<% end -%>
|
|
36
39
|
# Optional overrides (shown with defaults):
|
|
37
40
|
# config.code_length = 6
|
|
38
41
|
# config.code_ttl = 300
|
|
39
42
|
# config.code_format = :numeric
|
|
40
43
|
# config.max_send_attempts = 3
|
|
41
44
|
# config.max_verification_attempts = 5
|
|
42
|
-
# config.delivery_channel = :
|
|
45
|
+
# config.delivery_channel = :sms
|
|
43
46
|
# config.on_send = ->(record:, identifier:, channel:) {}
|
|
44
|
-
# config.on_verify_success = ->(record:, identifier:) {}
|
|
47
|
+
# config.on_verify_success = ->(record:, identifier:, request: nil) {}
|
|
45
48
|
# config.on_verify_failure = ->(record:, identifier:, attempts:) {}
|
|
46
49
|
end
|
|
@@ -21,6 +21,8 @@ module VerifyIt
|
|
|
21
21
|
:test_mode,
|
|
22
22
|
:bypass_delivery,
|
|
23
23
|
:secret_key_base,
|
|
24
|
+
:max_ip_send_attempts,
|
|
25
|
+
:max_ip_verification_attempts,
|
|
24
26
|
:current_record_resolver,
|
|
25
27
|
:identifier_resolver
|
|
26
28
|
|
|
@@ -55,6 +57,8 @@ module VerifyIt
|
|
|
55
57
|
@test_mode = false
|
|
56
58
|
@bypass_delivery = false
|
|
57
59
|
@secret_key_base = nil
|
|
60
|
+
@max_ip_send_attempts = nil
|
|
61
|
+
@max_ip_verification_attempts = nil
|
|
58
62
|
@current_record_resolver = nil
|
|
59
63
|
@identifier_resolver = nil
|
|
60
64
|
end
|
data/lib/verify_it/engine.rb
CHANGED
|
@@ -9,11 +9,5 @@ module VerifyIt
|
|
|
9
9
|
initializer "verify_it.i18n" do
|
|
10
10
|
config.i18n.load_path += Dir[Engine.root.join("config", "locales", "**", "*.yml").to_s]
|
|
11
11
|
end
|
|
12
|
-
|
|
13
|
-
initializer "verify_it.active_record" do
|
|
14
|
-
ActiveSupport.on_load(:active_record) do
|
|
15
|
-
include VerifyIt::Verifiable
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
12
|
end
|
|
19
13
|
end
|
|
@@ -64,5 +64,29 @@ module VerifyIt
|
|
|
64
64
|
def reset_verification_attempts(identifier:, record:)
|
|
65
65
|
@storage.reset_attempts(identifier: identifier, record: record)
|
|
66
66
|
end
|
|
67
|
+
|
|
68
|
+
def ip_send_rate_limited?(ip:)
|
|
69
|
+
max = VerifyIt.configuration.max_ip_send_attempts
|
|
70
|
+
return false unless max
|
|
71
|
+
|
|
72
|
+
count = @storage.send_count(identifier: ip, record: nil)
|
|
73
|
+
count >= max
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ip_verification_rate_limited?(ip:)
|
|
77
|
+
max = VerifyIt.configuration.max_ip_verification_attempts
|
|
78
|
+
return false unless max
|
|
79
|
+
|
|
80
|
+
count = @storage.attempts(identifier: ip, record: nil)
|
|
81
|
+
count >= max
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def record_ip_send_attempt(ip:)
|
|
85
|
+
@storage.increment_send_count(identifier: ip, record: nil)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def record_ip_verification_attempt(ip:)
|
|
89
|
+
@storage.increment_attempts(identifier: ip, record: nil)
|
|
90
|
+
end
|
|
67
91
|
end
|
|
68
92
|
end
|
|
@@ -25,13 +25,14 @@ module VerifyIt
|
|
|
25
25
|
attrs[:record_id] = record.id
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# Find existing or create new
|
|
29
28
|
existing = find_code(identifier: identifier, record: record)
|
|
30
29
|
if existing
|
|
31
|
-
existing.update!(
|
|
30
|
+
existing.update!(code: code, expires_at: expires_at)
|
|
32
31
|
else
|
|
33
32
|
Models::Code.create!(attrs)
|
|
34
33
|
end
|
|
34
|
+
rescue ActiveRecord::RecordNotUnique
|
|
35
|
+
retry
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def fetch_code(identifier:, record:)
|
|
@@ -146,48 +147,41 @@ module VerifyIt
|
|
|
146
147
|
end
|
|
147
148
|
|
|
148
149
|
def find_attempt(identifier:, record:, attempt_type:)
|
|
150
|
+
build_attempt_scope(identifier:, record:, attempt_type:).first
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_attempt_scope(identifier:, record:, attempt_type:)
|
|
149
154
|
scope = Models::Attempt
|
|
150
155
|
.for_identifier(identifier)
|
|
151
156
|
.where(attempt_type: attempt_type)
|
|
152
157
|
|
|
153
158
|
scope = scope.for_record(record) if record
|
|
154
|
-
scope
|
|
159
|
+
scope
|
|
155
160
|
end
|
|
156
161
|
|
|
157
162
|
def increment_attempt_count(identifier:, record:, attempt_type:)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
record: record,
|
|
161
|
-
attempt_type: attempt_type
|
|
162
|
-
)
|
|
163
|
+
# Clean expired attempts first
|
|
164
|
+
build_attempt_scope(identifier:, record:, attempt_type:).expired.delete_all
|
|
163
165
|
|
|
164
166
|
window = VerifyIt.configuration.rate_limit_window
|
|
165
167
|
expires_at = Time.now + window
|
|
168
|
+
attrs = {
|
|
169
|
+
identifier: identifier.to_s,
|
|
170
|
+
attempt_type: attempt_type,
|
|
171
|
+
count: 0,
|
|
172
|
+
expires_at: expires_at
|
|
173
|
+
}
|
|
166
174
|
|
|
167
|
-
if
|
|
168
|
-
|
|
169
|
-
attrs =
|
|
170
|
-
identifier: identifier.to_s,
|
|
171
|
-
attempt_type: attempt_type,
|
|
172
|
-
count: 1,
|
|
173
|
-
expires_at: expires_at
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if record
|
|
177
|
-
attrs[:record_type] = record.class.name
|
|
178
|
-
attrs[:record_id] = record.id
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# Delete old expired attempt if exists
|
|
182
|
-
attempt&.destroy
|
|
183
|
-
|
|
184
|
-
Models::Attempt.create!(attrs)
|
|
185
|
-
1
|
|
186
|
-
else
|
|
187
|
-
# Increment existing
|
|
188
|
-
attempt.increment!(:count)
|
|
189
|
-
attempt.count
|
|
175
|
+
if record
|
|
176
|
+
attrs[:record_type] = record.class.name
|
|
177
|
+
attrs[:record_id] = record.id
|
|
190
178
|
end
|
|
179
|
+
|
|
180
|
+
attempt = build_attempt_scope(identifier:, record:, attempt_type:).first_or_create!(attrs)
|
|
181
|
+
attempt.increment!(:count)
|
|
182
|
+
attempt.count
|
|
183
|
+
rescue ActiveRecord::RecordNotUnique
|
|
184
|
+
retry
|
|
191
185
|
end
|
|
192
186
|
|
|
193
187
|
def get_attempt_count(identifier:, record:, attempt_type:)
|
|
@@ -7,7 +7,7 @@ module VerifyIt
|
|
|
7
7
|
self.table_name = "verify_it_attempts"
|
|
8
8
|
|
|
9
9
|
validates :identifier, presence: true
|
|
10
|
-
validates :attempt_type, presence: true, inclusion: { in: %w[verification send] }
|
|
10
|
+
validates :attempt_type, presence: true, inclusion: { in: %w[verification send ip_send ip_verification] }
|
|
11
11
|
validates :count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
12
12
|
validates :expires_at, presence: true
|
|
13
13
|
|
data/lib/verify_it/verifiable.rb
CHANGED
|
@@ -20,22 +20,24 @@ module VerifyIt
|
|
|
20
20
|
|
|
21
21
|
raise ArgumentError, "Method #{send_method} is already defined on #{name}" if method_defined?(send_method)
|
|
22
22
|
|
|
23
|
-
define_method(send_method) do |context: {}|
|
|
23
|
+
define_method(send_method) do |context: {}, request: nil|
|
|
24
24
|
identifier = send(attribute)
|
|
25
25
|
VerifyIt.send_code(
|
|
26
26
|
to: identifier,
|
|
27
27
|
record: self,
|
|
28
28
|
channel: channel,
|
|
29
|
-
context: context
|
|
29
|
+
context: context,
|
|
30
|
+
request: request
|
|
30
31
|
)
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
define_method(verify_method) do |code|
|
|
34
|
+
define_method(verify_method) do |code, request: nil|
|
|
34
35
|
identifier = send(attribute)
|
|
35
36
|
VerifyIt.verify_code(
|
|
36
37
|
to: identifier,
|
|
37
38
|
code: code,
|
|
38
|
-
record: self
|
|
39
|
+
record: self,
|
|
40
|
+
request: request
|
|
39
41
|
)
|
|
40
42
|
end
|
|
41
43
|
|
data/lib/verify_it/verifier.rb
CHANGED
|
@@ -11,9 +11,14 @@ module VerifyIt
|
|
|
11
11
|
@rate_limiter = RateLimiter.new(storage)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def send_code(to:, record:, channel: nil, context: {})
|
|
14
|
+
def send_code(to:, record:, channel: nil, context: {}, request: nil)
|
|
15
15
|
channel ||= config.delivery_channel
|
|
16
16
|
|
|
17
|
+
ip = extract_ip(request)
|
|
18
|
+
if ip && rate_limiter.ip_send_rate_limited?(ip: ip)
|
|
19
|
+
return rate_limited_result("IP address rate limited for sending")
|
|
20
|
+
end
|
|
21
|
+
|
|
17
22
|
rate_limit_result = check_send_rate_limits(to: to, record: record)
|
|
18
23
|
return rate_limit_result if rate_limit_result
|
|
19
24
|
|
|
@@ -27,10 +32,17 @@ module VerifyIt
|
|
|
27
32
|
)
|
|
28
33
|
return delivery_result if delivery_result
|
|
29
34
|
|
|
35
|
+
rate_limiter.record_ip_send_attempt(ip: ip) if ip
|
|
36
|
+
|
|
30
37
|
finalize_send(to: to, record: record, channel: channel, code_data: code_data)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def verify_code(to:, code:, record:, request: nil)
|
|
41
|
+
ip = extract_ip(request)
|
|
42
|
+
if ip && rate_limiter.ip_verification_rate_limited?(ip: ip)
|
|
43
|
+
return rate_limited_result("IP address rate limited for verification")
|
|
44
|
+
end
|
|
45
|
+
|
|
34
46
|
rate_limit_result = check_verification_rate_limit(to: to, record: record)
|
|
35
47
|
return rate_limit_result if rate_limit_result
|
|
36
48
|
|
|
@@ -38,6 +50,7 @@ module VerifyIt
|
|
|
38
50
|
return code_not_found_result unless stored_code
|
|
39
51
|
|
|
40
52
|
current_attempts = rate_limiter.record_verification_attempt(identifier: to, record: record)
|
|
53
|
+
rate_limiter.record_ip_verification_attempt(ip: ip) if ip
|
|
41
54
|
|
|
42
55
|
if code_matches?(code, stored_code)
|
|
43
56
|
handle_verification_success(to: to, record: record, attempts: current_attempts, request: request)
|
|
@@ -100,7 +113,7 @@ module VerifyIt
|
|
|
100
113
|
delivery = get_delivery_adapter(channel)
|
|
101
114
|
delivery.deliver(to: to, code: code, context: context)
|
|
102
115
|
nil
|
|
103
|
-
rescue StandardError
|
|
116
|
+
rescue StandardError
|
|
104
117
|
delivery_failed_result
|
|
105
118
|
end
|
|
106
119
|
|
|
@@ -159,6 +172,16 @@ module VerifyIt
|
|
|
159
172
|
end
|
|
160
173
|
end
|
|
161
174
|
|
|
175
|
+
def extract_ip(request)
|
|
176
|
+
return nil unless request
|
|
177
|
+
|
|
178
|
+
if request.respond_to?(:remote_ip)
|
|
179
|
+
request.remote_ip
|
|
180
|
+
elsif request.respond_to?(:ip)
|
|
181
|
+
request.ip
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
162
185
|
def invoke_callback(callback, **args)
|
|
163
186
|
callback&.call(**args)
|
|
164
187
|
end
|
data/lib/verify_it/version.rb
CHANGED
data/lib/verify_it.rb
CHANGED
|
@@ -33,8 +33,8 @@ module VerifyIt
|
|
|
33
33
|
yield(configuration)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def send_code(to:, record:, channel: nil, context: {})
|
|
37
|
-
verifier.send_code(to: to, record: record, channel: channel, context: context)
|
|
36
|
+
def send_code(to:, record:, channel: nil, context: {}, request: nil)
|
|
37
|
+
verifier.send_code(to: to, record: record, channel: channel, context: context, request: request)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def verify_code(to:, code:, record:, request: nil)
|