verify_it 0.1.1 → 0.3.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 +10 -0
- data/README.md +176 -283
- data/lib/verify_it/cli.rb +6 -0
- data/lib/verify_it/code_hasher.rb +25 -0
- data/lib/verify_it/configuration.rb +3 -1
- data/lib/verify_it/templates/initializer.rb.erb +22 -65
- data/lib/verify_it/verifiable.rb +2 -5
- data/lib/verify_it/verifier.rb +4 -3
- data/lib/verify_it/version.rb +1 -1
- data/lib/verify_it.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c975ad8c3016c930d9b8071d4f6c21fef89eece05332a48c5b1ebfebebf7386
|
|
4
|
+
data.tar.gz: 40adb101cba36f8cb6b1c89f81977558e4d21ae0de87a93333a70e3bf92006d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4b862df0a1be371deb7fa9ea1d4eb2ed85da2f83852771a3efd52156187cf0c7a5057c162cd5e6e04ab5f6c88571c3fa6bc9c4d0427f901a7f4dbf5f23b6fe9
|
|
7
|
+
data.tar.gz: af94994c116729b2f0670b1664618ac1c01877755996ae00bd2b97069292fba1c0b2ef48d73f91a48d2c5653c2b49d5fc500a40c3a37e1cf304c77d7cf9d852c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-03-03
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- Verification codes are now hashed with HMAC-SHA256 before storage; a compromised storage backend no longer exposes usable codes
|
|
7
|
+
- New required configuration: `secret_key_base` — must be set to a random secret in your initializer
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `VerifyIt::CodeHasher` module for HMAC-SHA256 code digesting (stdlib `openssl` only — no new dependencies)
|
|
11
|
+
- `config.secret_key_base` configuration option
|
|
12
|
+
|
|
3
13
|
## [0.1.1] - 2026-03-02
|
|
4
14
|
|
|
5
15
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# VerifyIt
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A storage-agnostic verification system for Ruby applications.
|
|
4
|
+
VerifyIt provides a flexible framework for implementing SMS and email verifications with built-in rate limiting and multiple storage backends.
|
|
4
5
|
|
|
5
6
|
## Features
|
|
6
7
|
|
|
@@ -10,397 +11,289 @@ A production-grade, storage-agnostic verification system for Ruby applications.
|
|
|
10
11
|
- **Thread-Safe**: Designed for concurrent access
|
|
11
12
|
- **Test Mode**: Expose codes for testing without delivery
|
|
12
13
|
- **Rails Optional**: Works standalone or with Rails
|
|
13
|
-
- **
|
|
14
|
+
- **Little Runtime Dependencies**: Only ActiveSupport required
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
16
17
|
|
|
17
|
-
Add
|
|
18
|
+
Add to your Gemfile:
|
|
18
19
|
|
|
19
20
|
```ruby
|
|
20
21
|
gem 'verify_it'
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
And then execute:
|
|
24
|
-
|
|
25
24
|
```bash
|
|
26
25
|
bundle install
|
|
27
26
|
```
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
gem install verify_it
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
### Rails Installation
|
|
28
|
+
## Rails Setup
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
### 1. Generate configuration files
|
|
38
31
|
|
|
39
32
|
```bash
|
|
40
33
|
bundle exec verify_it install
|
|
41
34
|
```
|
|
42
35
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
The installer will ask you to choose a storage backend and generate:
|
|
37
|
+
|
|
38
|
+
- `config/initializers/verify_it.rb` — your main configuration
|
|
39
|
+
- A database migration (if you chose Database storage)
|
|
47
40
|
|
|
48
|
-
If you
|
|
41
|
+
If you chose Database storage, run:
|
|
49
42
|
|
|
50
43
|
```bash
|
|
51
44
|
rails db:migrate
|
|
52
45
|
```
|
|
53
46
|
|
|
54
|
-
|
|
47
|
+
### 2. Configure the initializer
|
|
55
48
|
|
|
56
|
-
|
|
49
|
+
Open `config/initializers/verify_it.rb` and fill in your delivery lambdas.
|
|
57
50
|
|
|
58
|
-
|
|
59
|
-
require 'verify_it'
|
|
51
|
+
**Redis + Twilio example:**
|
|
60
52
|
|
|
53
|
+
```ruby
|
|
61
54
|
VerifyIt.configure do |config|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# Code settings
|
|
69
|
-
config.code_length = 6
|
|
70
|
-
config.code_ttl = 300 # 5 minutes
|
|
71
|
-
config.code_format = :numeric # or :alphanumeric, :alpha
|
|
72
|
-
|
|
73
|
-
# Rate limiting
|
|
74
|
-
config.max_send_attempts = 3
|
|
75
|
-
config.max_verification_attempts = 5
|
|
76
|
-
config.max_identifier_changes = 5
|
|
77
|
-
config.rate_limit_window = 3600 # 1 hour
|
|
78
|
-
|
|
79
|
-
# Delivery (SMS example with Twilio)
|
|
55
|
+
config.secret_key_base = Rails.application.secret_key_base
|
|
56
|
+
|
|
57
|
+
config.storage = :redis
|
|
58
|
+
config.redis_client = Redis.new(url: ENV["REDIS_URL"])
|
|
59
|
+
|
|
80
60
|
config.sms_sender = ->(to:, code:, context:) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"Your verification code is: #{code}"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
twilio_client.messages.create(
|
|
89
|
-
to: to,
|
|
90
|
-
from: '+15551234567',
|
|
91
|
-
body: message
|
|
61
|
+
Twilio::REST::Client.new.messages.create(
|
|
62
|
+
from: ENV["TWILIO_NUMBER"],
|
|
63
|
+
to: to,
|
|
64
|
+
body: "Your verification code is: #{code}"
|
|
92
65
|
)
|
|
93
66
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
config.
|
|
97
|
-
# Context available for customization
|
|
98
|
-
UserMailer.verification_code(to, code, context).deliver_now
|
|
99
|
-
}
|
|
67
|
+
|
|
68
|
+
config.test_mode = Rails.env.test?
|
|
69
|
+
config.bypass_delivery = Rails.env.test?
|
|
100
70
|
end
|
|
101
71
|
```
|
|
102
72
|
|
|
103
|
-
###
|
|
73
|
+
### 3. Add `Verifiable` to your model
|
|
104
74
|
|
|
105
75
|
```ruby
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
channel: :sms
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
if result.success?
|
|
114
|
-
puts "Code sent successfully!"
|
|
115
|
-
puts "Expires at: #{result.expires_at}"
|
|
116
|
-
else
|
|
117
|
-
puts "Error: #{result.message}"
|
|
118
|
-
puts "Rate limited!" if result.rate_limited?
|
|
76
|
+
class User < ApplicationRecord
|
|
77
|
+
include VerifyIt::Verifiable
|
|
78
|
+
|
|
79
|
+
verifies :phone_number # defaults to channel: :sms
|
|
80
|
+
verifies :email, channel: :email
|
|
119
81
|
end
|
|
120
82
|
```
|
|
121
83
|
|
|
122
|
-
|
|
84
|
+
This generates the following methods on `User`:
|
|
85
|
+
|
|
86
|
+
| Method | Description |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `send_sms_code` | Send a code to `phone_number` via SMS |
|
|
89
|
+
| `send_email_code` | Send a code to `email` via email |
|
|
90
|
+
| `verify_sms_code(code)` | Verify an SMS code |
|
|
91
|
+
| `verify_email_code(code)` | Verify an email code |
|
|
92
|
+
| `cleanup_sms_verification` | Remove stored verification data |
|
|
93
|
+
| `cleanup_email_verification` | Remove stored verification data |
|
|
94
|
+
|
|
95
|
+
### 4. Use it in your controllers
|
|
96
|
+
|
|
97
|
+
**Sending a code:**
|
|
123
98
|
|
|
124
99
|
```ruby
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
flash[:error] = "Account locked due to too many attempts"
|
|
138
|
-
elsif result.success? == false
|
|
139
|
-
# Invalid code
|
|
140
|
-
flash[:error] = "Invalid verification code. #{5 - result.attempts} attempts remaining"
|
|
100
|
+
class VerificationsController < ApplicationController
|
|
101
|
+
def create
|
|
102
|
+
result = current_user.send_sms_code
|
|
103
|
+
|
|
104
|
+
if result.success?
|
|
105
|
+
render json: { expires_at: result.expires_at }
|
|
106
|
+
elsif result.rate_limited?
|
|
107
|
+
render json: { error: "Too many requests" }, status: :too_many_requests
|
|
108
|
+
else
|
|
109
|
+
render json: { error: result.message }, status: :unprocessable_entity
|
|
110
|
+
end
|
|
111
|
+
end
|
|
141
112
|
end
|
|
142
113
|
```
|
|
143
114
|
|
|
144
|
-
|
|
115
|
+
**Verifying a code:**
|
|
145
116
|
|
|
146
117
|
```ruby
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
118
|
+
class VerificationsController < ApplicationController
|
|
119
|
+
def update
|
|
120
|
+
result = current_user.verify_sms_code(params[:code])
|
|
121
|
+
|
|
122
|
+
if result.verified?
|
|
123
|
+
session[:phone_verified] = true
|
|
124
|
+
render json: { verified: true }
|
|
125
|
+
elsif result.locked?
|
|
126
|
+
render json: { error: "Too many failed attempts. Try again later." }, status: :locked
|
|
127
|
+
else
|
|
128
|
+
remaining = VerifyIt.configuration.max_verification_attempts - result.attempts
|
|
129
|
+
render json: { error: "Invalid code. #{remaining} attempts remaining." }, status: :unprocessable_entity
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
152
133
|
```
|
|
153
134
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
### Storage Backends
|
|
135
|
+
### Passing context
|
|
157
136
|
|
|
158
|
-
|
|
159
|
-
Perfect for testing and development:
|
|
137
|
+
Both `send_*` methods accept an optional `context:` hash that is forwarded to your sender lambda. Use it for custom message templates or request metadata:
|
|
160
138
|
|
|
161
139
|
```ruby
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
#### Redis Storage
|
|
166
|
-
Recommended for production:
|
|
140
|
+
current_user.send_sms_code(context: {
|
|
141
|
+
message_template: "Your Acme login code: %{code}"
|
|
142
|
+
})
|
|
167
143
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
144
|
+
current_user.send_email_code(context: {
|
|
145
|
+
ip: request.ip,
|
|
146
|
+
user_agent: request.user_agent
|
|
147
|
+
})
|
|
171
148
|
```
|
|
172
149
|
|
|
173
|
-
|
|
174
|
-
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Plain Ruby
|
|
175
153
|
|
|
176
154
|
```ruby
|
|
177
|
-
|
|
178
|
-
```
|
|
155
|
+
require "verify_it"
|
|
179
156
|
|
|
180
|
-
|
|
157
|
+
VerifyIt.configure do |config|
|
|
158
|
+
config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
|
|
159
|
+
config.storage = :memory # :memory, :redis, or :database
|
|
181
160
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
161
|
+
config.sms_sender = ->(to:, code:, context:) {
|
|
162
|
+
MySmsSender.deliver(to: to, body: "Your code: #{code}")
|
|
163
|
+
}
|
|
164
|
+
end
|
|
186
165
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
- `verify_it_attempts` - Tracks verification and send attempts
|
|
190
|
-
- `verify_it_identifier_changes` - Tracks identifier change history
|
|
166
|
+
# Send a code
|
|
167
|
+
result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: current_user)
|
|
191
168
|
|
|
192
|
-
|
|
169
|
+
# Verify a code
|
|
170
|
+
result = VerifyIt.verify_code(to: "+15551234567", code: "123456", record: current_user)
|
|
193
171
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
config.code_ttl = 300 # Time-to-live in seconds (5 minutes)
|
|
197
|
-
config.code_format = :numeric # :numeric, :alphanumeric, or :alpha
|
|
172
|
+
# Clean up
|
|
173
|
+
VerifyIt.cleanup(to: "+15551234567", record: current_user)
|
|
198
174
|
```
|
|
199
175
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Configuration Reference
|
|
179
|
+
|
|
180
|
+
| Option | Default | Accepted values |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `secret_key_base` | `nil` | Any string — **required** |
|
|
183
|
+
| `storage` | `:memory` | `:memory`, `:redis`, `:database` |
|
|
184
|
+
| `redis_client` | `nil` | A `Redis` instance (required when `storage: :redis`) |
|
|
185
|
+
| `code_length` | `6` | Integer |
|
|
186
|
+
| `code_ttl` | `300` | Seconds (integer) |
|
|
187
|
+
| `code_format` | `:numeric` | `:numeric`, `:alphanumeric`, `:alpha` |
|
|
188
|
+
| `max_send_attempts` | `3` | Integer |
|
|
189
|
+
| `max_verification_attempts` | `5` | Integer |
|
|
190
|
+
| `max_identifier_changes` | `5` | Integer |
|
|
191
|
+
| `rate_limit_window` | `3600` | Seconds (integer) |
|
|
192
|
+
| `sms_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
|
|
193
|
+
| `email_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
|
|
194
|
+
| `on_send` | `nil` | Lambda `(record:, identifier:, channel:) { }` |
|
|
195
|
+
| `on_verify_success` | `nil` | Lambda `(record:, identifier:) { }` |
|
|
196
|
+
| `on_verify_failure` | `nil` | Lambda `(record:, identifier:, attempts:) { }` |
|
|
197
|
+
| `namespace` | `nil` | Lambda `(record) { }` returning a string or nil |
|
|
198
|
+
| `test_mode` | `false` | Boolean — exposes `result.code` |
|
|
199
|
+
| `bypass_delivery` | `false` | Boolean — skips actual delivery |
|
|
208
200
|
|
|
209
201
|
### Callbacks
|
|
210
202
|
|
|
211
|
-
Hook into the verification lifecycle:
|
|
212
|
-
|
|
213
203
|
```ruby
|
|
214
204
|
config.on_send = ->(record:, identifier:, channel:) {
|
|
215
|
-
Analytics.track(
|
|
205
|
+
Analytics.track("verification_sent", user_id: record.id, channel: channel)
|
|
216
206
|
}
|
|
217
207
|
|
|
218
208
|
config.on_verify_success = ->(record:, identifier:) {
|
|
219
|
-
Analytics.track(
|
|
209
|
+
Analytics.track("verification_success", user_id: record.id)
|
|
220
210
|
}
|
|
221
211
|
|
|
222
212
|
config.on_verify_failure = ->(record:, identifier:, attempts:) {
|
|
223
|
-
Analytics.track(
|
|
213
|
+
Analytics.track("verification_failure", user_id: record.id, attempts: attempts)
|
|
224
214
|
}
|
|
225
215
|
```
|
|
226
216
|
|
|
227
217
|
### Namespacing
|
|
228
218
|
|
|
229
|
-
Isolate verification data
|
|
230
|
-
|
|
231
|
-
```ruby
|
|
232
|
-
config.namespace = ->(record) {
|
|
233
|
-
record.respond_to?(:organization_id) ? "org:#{record.organization_id}" : nil
|
|
234
|
-
}
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
### Test Mode
|
|
238
|
-
|
|
239
|
-
Expose codes in test environment:
|
|
219
|
+
Isolate verification data per tenant:
|
|
240
220
|
|
|
241
221
|
```ruby
|
|
242
|
-
config.
|
|
243
|
-
config.bypass_delivery = true # Skip actual delivery
|
|
222
|
+
config.namespace = ->(record) { "org:#{record.organization_id}" }
|
|
244
223
|
```
|
|
245
224
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
VerifyIt automatically integrates with Rails when detected.
|
|
249
|
-
|
|
250
|
-
### Model Integration
|
|
251
|
-
|
|
252
|
-
```ruby
|
|
253
|
-
class User < ApplicationRecord
|
|
254
|
-
include VerifyIt::Verifiable
|
|
255
|
-
|
|
256
|
-
verifies :phone_number, channel: :sms
|
|
257
|
-
verifies :email, channel: :email
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# Usage
|
|
261
|
-
user = User.find(params[:id])
|
|
262
|
-
|
|
263
|
-
# Send code (no context needed)
|
|
264
|
-
result = user.send_sms_code
|
|
225
|
+
---
|
|
265
226
|
|
|
266
|
-
|
|
267
|
-
result = user.send_sms_code(context: {
|
|
268
|
-
message_template: "Your #{user.company_name} verification code is: %{code}"
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
# Send with tracking metadata
|
|
272
|
-
result = user.send_email_code(context: {
|
|
273
|
-
ip: request.ip,
|
|
274
|
-
user_agent: request.user_agent,
|
|
275
|
-
action: 'password_reset'
|
|
276
|
-
})
|
|
227
|
+
## Result Object
|
|
277
228
|
|
|
278
|
-
|
|
279
|
-
result = user.verify_sms_code(params[:code])
|
|
229
|
+
Every operation returns a `VerifyIt::Result`:
|
|
280
230
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
231
|
+
| Method | Type | Description |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| `success?` | Boolean | Operation completed without error |
|
|
234
|
+
| `verified?` | Boolean | Code matched successfully |
|
|
235
|
+
| `rate_limited?` | Boolean | Rate limit was exceeded |
|
|
236
|
+
| `locked?` | Boolean | Max verification attempts reached |
|
|
237
|
+
| `error` | Symbol | `:invalid_code`, `:rate_limited`, `:code_not_found`, `:locked`, `:delivery_failed` |
|
|
238
|
+
| `message` | String | Human-readable description |
|
|
239
|
+
| `code` | String | The verification code (only when `test_mode: true`) |
|
|
240
|
+
| `expires_at` | Time | When the code expires |
|
|
241
|
+
| `attempts` | Integer | Number of verification attempts so far |
|
|
286
242
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
```ruby
|
|
290
|
-
VerifyIt.configure do |config|
|
|
291
|
-
config.storage = :redis
|
|
292
|
-
config.redis_client = Redis.new(url: ENV['REDIS_URL'])
|
|
293
|
-
|
|
294
|
-
config.sms_sender = ->(to:, code:, context:) {
|
|
295
|
-
TwilioService.send_sms(to: to, body: "Your code is: #{code}")
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
config.test_mode = Rails.env.test?
|
|
299
|
-
config.bypass_delivery = Rails.env.test?
|
|
300
|
-
end
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
## Result Object API
|
|
304
|
-
|
|
305
|
-
All operations return a `VerifyIt::Result` object:
|
|
306
|
-
|
|
307
|
-
```ruby
|
|
308
|
-
result.success? # true if operation succeeded
|
|
309
|
-
result.verified? # true if code was verified successfully
|
|
310
|
-
result.locked? # true if max attempts exceeded
|
|
311
|
-
result.rate_limited? # true if rate limit hit
|
|
312
|
-
result.error # Symbol error code (:invalid_code, :rate_limited, etc.)
|
|
313
|
-
result.message # Human-readable message
|
|
314
|
-
result.code # Verification code (only in test_mode)
|
|
315
|
-
result.expires_at # Time when code expires
|
|
316
|
-
result.attempts # Number of verification attempts
|
|
317
|
-
```
|
|
243
|
+
---
|
|
318
244
|
|
|
319
245
|
## Testing
|
|
320
246
|
|
|
321
|
-
|
|
247
|
+
Configure VerifyIt in your spec helper to avoid real delivery and expose codes:
|
|
322
248
|
|
|
323
249
|
```ruby
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
250
|
+
# spec/support/verify_it.rb
|
|
251
|
+
RSpec.configure do |config|
|
|
252
|
+
config.before(:each) do
|
|
253
|
+
VerifyIt.configure do |c|
|
|
254
|
+
c.storage = :memory
|
|
255
|
+
c.test_mode = true
|
|
256
|
+
c.bypass_delivery = true
|
|
257
|
+
c.secret_key_base = "test_secret"
|
|
330
258
|
end
|
|
331
259
|
end
|
|
332
|
-
|
|
333
|
-
it "verifies phone number" do
|
|
334
|
-
post '/verify/send', params: { phone: '+15551234567' }
|
|
335
|
-
|
|
336
|
-
# Get code from response (test mode)
|
|
337
|
-
code = JSON.parse(response.body)['code']
|
|
338
|
-
|
|
339
|
-
post '/verify/confirm', params: { phone: '+15551234567', code: code }
|
|
340
|
-
expect(response).to have_http_status(:success)
|
|
341
|
-
end
|
|
342
260
|
end
|
|
343
261
|
```
|
|
344
262
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
VerifyIt is designed to be thread-safe:
|
|
348
|
-
|
|
349
|
-
- Memory storage uses `Mutex` for synchronization
|
|
350
|
-
- Redis operations are atomic
|
|
351
|
-
- No shared mutable state in core logic
|
|
263
|
+
In a request spec:
|
|
352
264
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
- Sorted sets for identifier tracking
|
|
358
|
-
- Atomic operations for counters
|
|
359
|
-
|
|
360
|
-
### Memory Storage
|
|
361
|
-
- Fast for testing and development
|
|
362
|
-
- Not recommended for production
|
|
363
|
-
- Data lost on restart
|
|
265
|
+
```ruby
|
|
266
|
+
it "verifies phone number" do
|
|
267
|
+
post "/verify/send", params: { phone: "+15551234567" }
|
|
268
|
+
code = JSON.parse(response.body)["code"] # available in test_mode
|
|
364
269
|
|
|
365
|
-
|
|
270
|
+
post "/verify/confirm", params: { phone: "+15551234567", code: code }
|
|
271
|
+
expect(response).to have_http_status(:ok)
|
|
272
|
+
end
|
|
273
|
+
```
|
|
366
274
|
|
|
367
|
-
|
|
275
|
+
---
|
|
368
276
|
|
|
369
|
-
|
|
370
|
-
- `:invalid_code` - Code doesn't match
|
|
371
|
-
- `:code_not_found` - No code stored or expired
|
|
372
|
-
- `:locked` - Max attempts reached
|
|
373
|
-
- `:delivery_failed` - Delivery provider error
|
|
277
|
+
## Security
|
|
374
278
|
|
|
375
|
-
|
|
279
|
+
- **`secret_key_base` is required** — codes are hashed with HMAC-SHA256 before storage
|
|
280
|
+
- Enable rate limiting (on by default) in all environments
|
|
281
|
+
- Use `:redis` or `:database` storage in production (`:memory` does not survive restarts)
|
|
282
|
+
- Keep `code_ttl` short — 5 minutes is a sensible default
|
|
283
|
+
- Use `on_verify_failure` to monitor and alert on repeated failures
|
|
284
|
+
- Always serve verification endpoints over HTTPS
|
|
376
285
|
|
|
377
|
-
|
|
378
|
-
2. **Use Redis storage** for distributed systems
|
|
379
|
-
3. **Set short TTLs** (5 minutes recommended)
|
|
380
|
-
4. **Monitor failed attempts** via callbacks
|
|
381
|
-
5. **Use HTTPS** for all verification endpoints
|
|
382
|
-
6. **Sanitize phone numbers** before storage
|
|
286
|
+
---
|
|
383
287
|
|
|
384
288
|
## Development
|
|
385
289
|
|
|
386
|
-
After checking out the repo:
|
|
387
|
-
|
|
388
290
|
```bash
|
|
389
|
-
bin/setup
|
|
390
|
-
bundle exec rspec
|
|
391
|
-
|
|
392
|
-
|
|
291
|
+
bin/setup # Install dependencies
|
|
292
|
+
bundle exec rspec # Run tests
|
|
293
|
+
bundle exec rubocop # Lint
|
|
294
|
+
bin/console # Interactive console
|
|
393
295
|
```
|
|
394
296
|
|
|
395
|
-
## Roadmap
|
|
396
|
-
|
|
397
|
-
- [x] Database storage adapter
|
|
398
|
-
- [ ] Voice verification support
|
|
399
|
-
- [ ] WebAuthn integration
|
|
400
|
-
- [ ] Backup code generation
|
|
401
|
-
- [ ] Multi-factor authentication helpers
|
|
402
|
-
- [x] Rails installer (CLI)
|
|
403
|
-
|
|
404
297
|
## Contributing
|
|
405
298
|
|
|
406
299
|
Bug reports and pull requests are welcome on GitHub at https://github.com/JeremasPosta/verify_it. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/JeremasPosta/verify_it/blob/main/CODE_OF_CONDUCT.md).
|
data/lib/verify_it/cli.rb
CHANGED
|
@@ -37,6 +37,7 @@ module VerifyIt
|
|
|
37
37
|
puts "=" * 50
|
|
38
38
|
puts
|
|
39
39
|
|
|
40
|
+
load_rails_environment
|
|
40
41
|
check_rails_environment
|
|
41
42
|
|
|
42
43
|
storage_type = prompt_storage_type
|
|
@@ -57,6 +58,11 @@ module VerifyIt
|
|
|
57
58
|
end
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
def load_rails_environment
|
|
62
|
+
env_file = File.join(Dir.pwd, "config", "environment.rb")
|
|
63
|
+
require env_file if File.exist?(env_file)
|
|
64
|
+
end
|
|
65
|
+
|
|
60
66
|
def check_rails_environment
|
|
61
67
|
unless defined?(Rails)
|
|
62
68
|
puts "Error: Rails environment not detected."
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module VerifyIt
|
|
6
|
+
# Hashes verification codes with HMAC-SHA256 before storage.
|
|
7
|
+
#
|
|
8
|
+
# Using a secret key makes offline brute-force of the small OTP keyspace
|
|
9
|
+
# infeasible even if the storage layer is compromised.
|
|
10
|
+
module CodeHasher
|
|
11
|
+
# @param code [String] the plaintext verification code
|
|
12
|
+
# @param secret [String] the HMAC secret (config.secret_key_base)
|
|
13
|
+
# @return [String] hex-encoded HMAC-SHA256 digest
|
|
14
|
+
# @raise [ArgumentError] if secret is nil or empty
|
|
15
|
+
def self.digest(code, secret:)
|
|
16
|
+
if secret.nil? || secret.to_s.empty?
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"VerifyIt requires secret_key_base to be configured. " \
|
|
19
|
+
"Set config.secret_key_base in your initializer."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, code.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -19,7 +19,8 @@ module VerifyIt
|
|
|
19
19
|
:on_verify_failure,
|
|
20
20
|
:namespace,
|
|
21
21
|
:test_mode,
|
|
22
|
-
:bypass_delivery
|
|
22
|
+
:bypass_delivery,
|
|
23
|
+
:secret_key_base
|
|
23
24
|
|
|
24
25
|
def initialize
|
|
25
26
|
# Default values
|
|
@@ -41,6 +42,7 @@ module VerifyIt
|
|
|
41
42
|
@namespace = nil
|
|
42
43
|
@test_mode = false
|
|
43
44
|
@bypass_delivery = false
|
|
45
|
+
@secret_key_base = nil
|
|
44
46
|
end
|
|
45
47
|
end
|
|
46
48
|
end
|
|
@@ -1,79 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
VerifyIt.configure do |config|
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
config.
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
# Required: set a secret key to hash verification codes before storage.
|
|
5
|
+
# It is not required to use the Rails secret key base.
|
|
6
|
+
config.secret_key_base = Rails.application.secret_key_base
|
|
7
|
+
|
|
8
|
+
<% if storage_type == "redis" %>
|
|
9
9
|
config.storage = :redis
|
|
10
|
-
config.redis_client = Redis.new(
|
|
11
|
-
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
|
|
12
|
-
)
|
|
10
|
+
config.redis_client = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
|
13
11
|
<% elsif storage_type == "database" %>
|
|
14
|
-
# Database storage (uses ActiveRecord)
|
|
15
12
|
config.storage = :database
|
|
13
|
+
<% else %>
|
|
14
|
+
# config.storage = :memory # default
|
|
16
15
|
<% end %>
|
|
17
16
|
|
|
18
|
-
#
|
|
19
|
-
config.code_length = 6
|
|
20
|
-
config.code_ttl = 300 # 5 minutes
|
|
21
|
-
config.code_format = :numeric # :numeric, :alphanumeric, or :alpha
|
|
22
|
-
|
|
23
|
-
# Rate limiting
|
|
24
|
-
config.max_send_attempts = 3
|
|
25
|
-
config.max_verification_attempts = 5
|
|
26
|
-
config.max_identifier_changes = 5
|
|
27
|
-
config.rate_limit_window = 3600 # 1 hour
|
|
28
|
-
|
|
29
|
-
# Delivery channels
|
|
30
|
-
config.delivery_channel = :sms # :sms or :email
|
|
31
|
-
|
|
32
|
-
# SMS delivery (configure with your provider)
|
|
33
|
-
config.sms_sender = ->(to:, code:, context:) {
|
|
34
|
-
# Example with Twilio:
|
|
35
|
-
# twilio_client = Twilio::REST::Client.new(
|
|
36
|
-
# ENV['TWILIO_ACCOUNT_SID'],
|
|
37
|
-
# ENV['TWILIO_AUTH_TOKEN']
|
|
38
|
-
# )
|
|
39
|
-
# twilio_client.messages.create(
|
|
40
|
-
# to: to,
|
|
41
|
-
# from: ENV['TWILIO_PHONE_NUMBER'],
|
|
42
|
-
# body: "Your verification code is: #{code}"
|
|
43
|
-
# )
|
|
44
|
-
|
|
45
|
-
# For development, just log it:
|
|
46
|
-
Rails.logger.info("SMS to #{to}: Your verification code is #{code}")
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
# Email delivery (configure with your mailer)
|
|
17
|
+
# Required: configure your delivery channel(s)
|
|
50
18
|
config.email_sender = ->(to:, code:, context:) {
|
|
51
|
-
# Example with ActionMailer:
|
|
52
19
|
# VerificationMailer.send_code(to, code, context).deliver_now
|
|
53
|
-
|
|
54
|
-
# For development, just log it:
|
|
55
|
-
Rails.logger.info("Email to #{to}: Your verification code is #{code}")
|
|
56
20
|
}
|
|
57
21
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# }
|
|
62
|
-
|
|
63
|
-
# config.on_verify_success = ->(record:, identifier:) {
|
|
64
|
-
# Rails.logger.info("Verification successful for #{identifier}")
|
|
65
|
-
# }
|
|
66
|
-
|
|
67
|
-
# config.on_verify_failure = ->(record:, identifier:, attempts:) {
|
|
68
|
-
# Rails.logger.warn("Verification failed for #{identifier} (#{attempts} attempts)")
|
|
69
|
-
# }
|
|
70
|
-
|
|
71
|
-
# Namespace (for multi-tenant apps)
|
|
72
|
-
# config.namespace = ->(record) {
|
|
73
|
-
# record.respond_to?(:organization_id) ? "org:#{record.organization_id}" : nil
|
|
74
|
-
# }
|
|
22
|
+
config.sms_sender = ->(to:, code:, context:) {
|
|
23
|
+
# e.g. Twilio, Vonage, etc.
|
|
24
|
+
}
|
|
75
25
|
|
|
76
|
-
#
|
|
77
|
-
config.
|
|
78
|
-
config.
|
|
26
|
+
# Some other config options (with defaults):
|
|
27
|
+
# config.code_length = 6
|
|
28
|
+
# config.code_ttl = 300
|
|
29
|
+
# config.code_format = :numeric
|
|
30
|
+
# config.max_send_attempts = 3
|
|
31
|
+
# config.max_verification_attempts = 5
|
|
32
|
+
# config.delivery_channel = :email
|
|
33
|
+
# config.on_send = ->(record:, identifier:, channel:) {}
|
|
34
|
+
# config.on_verify_success = ->(record:, identifier:) {}
|
|
35
|
+
# config.on_verify_failure = ->(record:, identifier:, attempts:) {}
|
|
79
36
|
end
|
data/lib/verify_it/verifiable.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
3
5
|
module VerifyIt
|
|
4
6
|
module Verifiable
|
|
5
7
|
extend ActiveSupport::Concern
|
|
@@ -11,11 +13,6 @@ module VerifyIt
|
|
|
11
13
|
raise ArgumentError, "Invalid channel: #{channel}. Must be :sms or :email"
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
# Validate attribute exists (check at definition time)
|
|
15
|
-
unless method_defined?(attribute) || private_method_defined?(attribute)
|
|
16
|
-
raise ArgumentError, "Attribute :#{attribute} does not respond to #{name}"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
16
|
# Check for method conflicts
|
|
20
17
|
send_method = "send_#{channel}_code"
|
|
21
18
|
verify_method = "verify_#{channel}_code"
|
data/lib/verify_it/verifier.rb
CHANGED
|
@@ -41,11 +41,12 @@ module VerifyIt
|
|
|
41
41
|
)
|
|
42
42
|
expires_at = Time.now + VerifyIt.configuration.code_ttl
|
|
43
43
|
|
|
44
|
-
# Store code
|
|
44
|
+
# Store hashed code
|
|
45
|
+
hashed_code = CodeHasher.digest(code, secret: VerifyIt.configuration.secret_key_base)
|
|
45
46
|
storage.store_code(
|
|
46
47
|
identifier: to,
|
|
47
48
|
record: record,
|
|
48
|
-
code:
|
|
49
|
+
code: hashed_code,
|
|
49
50
|
expires_at: expires_at
|
|
50
51
|
)
|
|
51
52
|
|
|
@@ -115,7 +116,7 @@ module VerifyIt
|
|
|
115
116
|
current_attempts = rate_limiter.record_verification_attempt(identifier: to, record: record)
|
|
116
117
|
|
|
117
118
|
# Verify code
|
|
118
|
-
if stored_code == code.
|
|
119
|
+
if stored_code == CodeHasher.digest(code, secret: VerifyIt.configuration.secret_key_base)
|
|
119
120
|
# Success - cleanup
|
|
120
121
|
storage.delete_code(identifier: to, record: record)
|
|
121
122
|
storage.reset_attempts(identifier: to, record: record)
|
data/lib/verify_it/version.rb
CHANGED
data/lib/verify_it.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "verify_it/version"
|
|
|
4
4
|
require_relative "verify_it/configuration"
|
|
5
5
|
require_relative "verify_it/result"
|
|
6
6
|
require_relative "verify_it/code_generator"
|
|
7
|
+
require_relative "verify_it/code_hasher"
|
|
7
8
|
require_relative "verify_it/rate_limiter"
|
|
8
9
|
require_relative "verify_it/storage/base"
|
|
9
10
|
require_relative "verify_it/storage/memory_storage"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: verify_it
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeremias Ramirez
|
|
@@ -135,8 +135,9 @@ dependencies:
|
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: '1.5'
|
|
138
|
-
description:
|
|
139
|
-
|
|
138
|
+
description: A storage-agnostic verification system for Ruby applications. VerifyIt
|
|
139
|
+
provides a flexible framework for implementing SMS and email verifications with
|
|
140
|
+
built-in rate limiting and multiple storage backends.
|
|
140
141
|
email:
|
|
141
142
|
- iguanadejere4@googlemail.com
|
|
142
143
|
executables:
|
|
@@ -156,6 +157,7 @@ files:
|
|
|
156
157
|
- lib/verify_it.rb
|
|
157
158
|
- lib/verify_it/cli.rb
|
|
158
159
|
- lib/verify_it/code_generator.rb
|
|
160
|
+
- lib/verify_it/code_hasher.rb
|
|
159
161
|
- lib/verify_it/configuration.rb
|
|
160
162
|
- lib/verify_it/delivery/base.rb
|
|
161
163
|
- lib/verify_it/delivery/email_delivery.rb
|
|
@@ -181,7 +183,6 @@ homepage: https://github.com/JeremasPosta/verify_it
|
|
|
181
183
|
licenses:
|
|
182
184
|
- MIT
|
|
183
185
|
metadata:
|
|
184
|
-
homepage_uri: https://github.com/JeremasPosta/verify_it
|
|
185
186
|
source_code_uri: https://github.com/JeremasPosta/verify_it
|
|
186
187
|
changelog_uri: https://github.com/JeremasPosta/verify_it/blob/main/CHANGELOG.md
|
|
187
188
|
bug_tracker_uri: https://github.com/JeremasPosta/verify_it/issues
|