verify_it 0.2.0 → 0.4.0.beta

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.
data/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # VerifyIt
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/verify_it.svg)](https://badge.fury.io/rb/verify_it)
4
+
3
5
  A storage-agnostic verification system for Ruby applications.
6
+
4
7
  VerifyIt provides a flexible framework for implementing SMS and email verifications with built-in rate limiting and multiple storage backends.
5
8
 
9
+ It allows applications to generate and validate verification codes locally, helping reduce the operational cost associated with per-verification billing models.
10
+
6
11
  ## Features
7
12
 
8
13
  - **Storage Agnostic**: Redis, Memory, or Database storage
@@ -15,393 +20,387 @@ VerifyIt provides a flexible framework for implementing SMS and email verificati
15
20
 
16
21
  ## Installation
17
22
 
18
- Add this line to your application's Gemfile:
23
+ Add to your Gemfile:
19
24
 
20
25
  ```ruby
21
26
  gem 'verify_it'
22
27
  ```
23
28
 
24
- And then execute:
25
-
26
29
  ```bash
27
30
  bundle install
28
31
  ```
29
32
 
30
- Or install it yourself as:
31
-
32
- ```bash
33
- gem install verify_it
34
- ```
35
-
36
- ### Rails Installation
33
+ ## Rails Setup
37
34
 
38
- For Rails applications, use the installer to generate configuration files:
35
+ ### 1. Generate configuration files
39
36
 
40
37
  ```bash
41
38
  bundle exec verify_it install
42
39
  ```
43
40
 
44
- This will:
45
- 1. Prompt you to select a storage backend (Memory, Redis, or Database)
46
- 2. Generate an initializer at `config/initializers/verify_it.rb`
47
- 3. Generate a migration (if Database storage is selected)
41
+ The installer will ask you to choose a storage backend and generate:
42
+
43
+ - `config/initializers/verify_it.rb` — your main configuration
44
+ - A database migration (if you chose Database storage)
48
45
 
49
- If you selected Database storage, run the migration:
46
+ If you chose Database storage, run:
50
47
 
51
48
  ```bash
52
49
  rails db:migrate
53
50
  ```
54
51
 
55
- # Quick Start
52
+ ### 2. Configure the initializer
56
53
 
57
- ## Rails Integration
54
+ Open `config/initializers/verify_it.rb` and fill in your delivery lambdas.
58
55
 
59
- VerifyIt automatically integrates with Rails when detected.
56
+ **Redis + Twilio example:**
60
57
 
61
- ### Model Integration
58
+ ```ruby
59
+ VerifyIt.configure do |config|
60
+ config.secret_key_base = Rails.application.secret_key_base
61
+
62
+ config.storage = :redis
63
+ config.redis_client = Redis.new(url: ENV["REDIS_URL"])
64
+
65
+ config.sms_sender = ->(to:, code:, context:) {
66
+ Twilio::REST::Client.new.messages.create(
67
+ from: ENV["TWILIO_NUMBER"],
68
+ to: to,
69
+ body: "Your verification code is: #{code}"
70
+ )
71
+ }
72
+
73
+ config.email_sender = ->(to:, code:, context:) {
74
+ EmailClient.new(to:).send("Your verification code is: #{code}")
75
+ }
76
+
77
+ config.test_mode = Rails.env.test?
78
+ config.bypass_delivery = Rails.env.test?
79
+ end
80
+ ```
81
+
82
+ ### 3. Add `Verifiable` to your model
62
83
 
63
84
  ```ruby
64
85
  class User < ApplicationRecord
65
86
  include VerifyIt::Verifiable
66
-
67
- verifies :phone_number, channel: :sms
87
+
88
+ verifies :phone_number # defaults to channel: :sms
68
89
  verifies :email, channel: :email
69
90
  end
91
+ ```
70
92
 
71
- # Usage
72
- user = User.find(params[:id])
93
+ This generates the following methods on `User`:
73
94
 
74
- # Send code (no context needed)
75
- result = user.send_sms_code
95
+ | Method | Description |
96
+ |---|---|
97
+ | `send_sms_code` | Send a code to `phone_number` via SMS |
98
+ | `send_email_code` | Send a code to `email` via email |
99
+ | `verify_sms_code(code)` | Verify an SMS code |
100
+ | `verify_email_code(code)` | Verify an email code |
101
+ | `cleanup_sms_verification` | Remove stored verification data |
102
+ | `cleanup_email_verification` | Remove stored verification data |
76
103
 
77
- # Send with custom message template
78
- result = user.send_sms_code(context: {
79
- message_template: "Your #{user.company_name} verification code is: %{code}"
80
- })
104
+ ### 4. Use it in your controllers
81
105
 
82
- # Send with tracking metadata
83
- result = user.send_email_code(context: {
84
- ip: request.ip,
85
- user_agent: request.user_agent,
86
- action: 'password_reset'
87
- })
106
+ **Sending a code:**
88
107
 
89
- # Verify code
90
- result = user.verify_sms_code(params[:code])
108
+ ```ruby
109
+ class VerificationsController < ApplicationController
110
+ def create
111
+ result = current_user.send_sms_code
112
+
113
+ if result.success?
114
+ render json: { expires_at: result.expires_at }
115
+ elsif result.rate_limited?
116
+ render json: { error: "Too many requests" }, status: :too_many_requests
117
+ else
118
+ render json: { error: result.message }, status: :unprocessable_entity
119
+ end
120
+ end
121
+ end
122
+ ```
91
123
 
92
- # Cleanup
93
- user.cleanup_sms_verification
124
+ **Verifying a code:**
125
+
126
+ ```ruby
127
+ class VerificationsController < ApplicationController
128
+ def update
129
+ result = current_user.verify_sms_code(params[:code])
130
+
131
+ if result.verified?
132
+ session[:phone_verified] = true
133
+ render json: { verified: true }
134
+ elsif result.locked?
135
+ render json: { error: "Too many failed attempts. Try again later." }, status: :forbidden
136
+ else
137
+ remaining = VerifyIt.configuration.max_verification_attempts - result.attempts
138
+ render json: { error: "Invalid code. #{remaining} attempts remaining." }
139
+ end
140
+ end
141
+ end
94
142
  ```
95
143
 
96
- ### Rails Configuration
144
+ ### Passing context
97
145
 
98
- Create an initializer `config/initializers/verify_it.rb`:
146
+ Both `send_*` methods accept an optional `context:` hash that is forwarded to your sender lambda. Use it for custom message templates or request metadata:
99
147
 
100
148
  ```ruby
101
- VerifyIt.configure do |config|
102
- config.storage = :redis
103
- config.redis_client = Redis.new(url: ENV['REDIS_URL'])
104
-
105
- config.sms_sender = ->(to:, code:, context:) {
106
- TwilioService.send_sms(to: to, body: "Your code is: #{code}")
107
- }
108
-
109
- config.test_mode = Rails.env.test?
110
- config.bypass_delivery = Rails.env.test?
111
- end
149
+ current_user.send_sms_code(context: {
150
+ message_template: :code_verification_v1,
151
+ locale: :es
152
+ })
153
+
154
+ current_user.send_email_code(context: {
155
+ ip: request.ip,
156
+ user_agent: request.user_agent
157
+ })
112
158
  ```
113
159
 
114
- ## Plain Ruby Configuration
160
+ ---
161
+
162
+ ## Plain Ruby
115
163
 
116
164
  ```ruby
117
- require 'verify_it'
165
+ require "verify_it"
118
166
 
119
167
  VerifyIt.configure do |config|
120
- # Storage backend
121
- config.storage = :memory # or :redis, :database
122
-
123
- # For Redis storage
124
- # config.redis_client = Redis.new(url: ENV['REDIS_URL'])
125
-
126
- # Code settings
127
- config.code_length = 6
128
- config.code_ttl = 300 # 5 minutes
129
- config.code_format = :numeric # or :alphanumeric, :alpha
130
-
131
- # Rate limiting
132
- config.max_send_attempts = 3
133
- config.max_verification_attempts = 5
134
- config.max_identifier_changes = 5
135
- config.rate_limit_window = 3600 # 1 hour
136
-
137
- # Delivery (SMS example with Twilio)
168
+ config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
169
+ config.storage = :memory # :memory, :redis, or :database
170
+
138
171
  config.sms_sender = ->(to:, code:, context:) {
139
- # Use custom template from context, or default
140
- message = if context[:message_template]
141
- context[:message_template] % { code: code }
142
- else
143
- "Your verification code is: #{code}"
144
- end
145
-
146
- twilio_client.messages.create(
147
- to: to,
148
- from: '+15551234567',
149
- body: message
150
- )
151
- }
152
-
153
- # Email delivery
154
- config.email_sender = ->(to:, code:, context:) {
155
- # Context available for customization
156
- UserMailer.verification_code(to, code, context).deliver_now
172
+ MySmsSender.deliver(to: to, body: "Your code: #{code}")
157
173
  }
158
174
  end
159
- ```
160
175
 
161
- ### Sending a Verification Code
176
+ # Send a code
177
+ result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: current_user)
162
178
 
163
- ```ruby
164
- result = VerifyIt.send_code(
165
- to: "+15551234567",
166
- record: current_user,
167
- channel: :sms,
168
- context: { user_id: current_user.id }
169
- )
170
-
171
- if result.success?
172
- puts "Code sent successfully!"
173
- puts "Expires at: #{result.expires_at}"
174
- else
175
- puts "Error: #{result.message}"
176
- puts "Rate limited!" if result.rate_limited?
177
- end
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)
178
184
  ```
179
185
 
180
- ### Verifying a Code
186
+ ---
181
187
 
182
- ```ruby
183
- result = VerifyIt.verify_code(
184
- to: "+15551234567",
185
- code: params[:code],
186
- record: current_user
187
- )
188
-
189
- if result.verified?
190
- # Success! Code is valid
191
- session[:verified] = true
192
- redirect_to dashboard_path
193
- elsif result.locked?
194
- # Too many failed attempts
195
- flash[:error] = "Account locked due to too many attempts"
196
- elsif result.success? == false
197
- # Invalid code
198
- flash[:error] = "Invalid verification code. #{5 - result.attempts} attempts remaining"
199
- end
200
- ```
188
+ ## Rails Engine (HTTP Endpoints)
189
+
190
+ VerifyIt ships a mountable Rails Engine that exposes two JSON endpoints. This is
191
+ optional, if you prefer to not use the engine, skip to the next section.
201
192
 
202
- ### Cleanup
193
+ ### Mount the engine
203
194
 
204
195
  ```ruby
205
- # Clean up verification data for a user
206
- VerifyIt.cleanup(
207
- to: "+15551234567",
208
- record: current_user
209
- )
196
+ # config/routes.rb
197
+ mount VerifyIt::Engine, at: "/verify"
210
198
  ```
211
199
 
212
- ## Configuration Options
200
+ This adds two routes:
213
201
 
214
- ### Storage Backends
202
+ | Method | Path | Action |
203
+ |---|---|---|
204
+ | POST | `/verify/send` | Send a verification code |
205
+ | POST | `/verify/confirm` | Confirm a verification code |
215
206
 
216
- #### Memory Storage (Default)
217
- Perfect for testing and development:
207
+ ### Configure the resolvers
208
+
209
+ Two additional config options are **required** when using the engine endpoints:
218
210
 
219
211
  ```ruby
220
- config.storage = :memory
221
- ```
212
+ # config/initializers/verify_it.rb
213
+ VerifyIt.configure do |config|
214
+ config.secret_key_base = Rails.application.secret_key_base
215
+ config.storage = :redis
216
+ config.redis_client = Redis.new
222
217
 
223
- #### Redis Storage
224
- Recommended for production:
218
+ # Resolve the authenticated record from the request (e.g. from a session or JWT).
219
+ config.current_record_resolver = ->(request) {
220
+ User.find_by(id: request.session[:user_id])
221
+ }
225
222
 
226
- ```ruby
227
- config.storage = :redis
228
- config.redis_client = Redis.new(url: ENV['REDIS_URL'])
223
+ # Derive the delivery identifier from the record and the requested channel.
224
+ config.identifier_resolver = ->(record, channel) {
225
+ channel == :sms ? record.phone_number : record.email
226
+ }
227
+
228
+ config.sms_sender = ->(to:, code:, context:) {
229
+ Twilio::REST::Client.new.messages.create(
230
+ from: ENV["TWILIO_NUMBER"],
231
+ to: to,
232
+ body: I18n.t("verify_it.sms.default_message", code: code)
233
+ )
234
+ }
235
+ end
229
236
  ```
230
237
 
231
- #### Database Storage
232
- Uses ActiveRecord models for persistent storage:
238
+ ### Request/response examples
233
239
 
234
- ```ruby
235
- config.storage = :database
240
+ **Send a code:**
241
+
242
+ ```bash
243
+ POST /verify/send
244
+ Content-Type: application/json
245
+
246
+ { "channel": "sms" }
247
+
248
+ # 201 Created
249
+ { "message": "Verification code sent." }
250
+
251
+ # 429 Too Many Requests
252
+ { "error": "Too many attempts. Please try again later." }
236
253
  ```
237
254
 
238
- Requires running the migration:
255
+ **Confirm a code:**
239
256
 
240
257
  ```bash
241
- bundle exec verify_it install
242
- rails db:migrate
243
- ```
258
+ POST /verify/confirm
259
+ Content-Type: application/json
244
260
 
245
- Creates three tables:
246
- - `verify_it_codes` - Stores verification codes
247
- - `verify_it_attempts` - Tracks verification and send attempts
248
- - `verify_it_identifier_changes` - Tracks identifier change history
261
+ { "channel": "sms", "code": "123456" }
249
262
 
250
- ### Code Settings
263
+ # 200 OK
264
+ { "message": "Successfully verified." }
251
265
 
252
- ```ruby
253
- config.code_length = 6 # Length of verification code
254
- config.code_ttl = 300 # Time-to-live in seconds (5 minutes)
255
- config.code_format = :numeric # :numeric, :alphanumeric, or :alpha
266
+ # 422 Unprocessable Entity
267
+ { "error": "The code you entered is invalid." }
256
268
  ```
257
269
 
258
- ### Rate Limiting
270
+ ### I18n overrides
259
271
 
260
- ```ruby
261
- config.max_send_attempts = 3 # Max sends per window
262
- config.max_verification_attempts = 5 # Max verification tries per window
263
- config.max_identifier_changes = 5 # Max identifier changes per window
264
- config.rate_limit_window = 3600 # Window duration in seconds
272
+ All messages are stored in `config/locales/en.yml` and can be overridden in your
273
+ application's locale files:
274
+
275
+ ```yaml
276
+ en:
277
+ verify_it:
278
+ sms:
279
+ default_message: "%{code} is your one-time passcode."
280
+ responses:
281
+ verified: "Identity confirmed."
265
282
  ```
266
283
 
267
- ### Callbacks
284
+ ---
285
+
286
+ ## Configuration Reference
287
+
288
+ | Option | Default | Accepted values |
289
+ |---|---|---|
290
+ | `secret_key_base` | `nil` unless using Rails | Any string — **required** |
291
+ | `storage` | `:memory` | `:memory`, `:redis`, `:database` |
292
+ | `redis_client` | `nil` | A `Redis` instance (required when `storage: :redis`) |
293
+ | `code_length` | `6` | Integer |
294
+ | `code_ttl` | `300` | Seconds (integer) |
295
+ | `code_format` | `:numeric` | `:numeric`, `:alphanumeric`, `:alpha` |
296
+ | `max_send_attempts` | `3` | Integer |
297
+ | `max_verification_attempts` | `5` | Integer |
298
+ | `max_identifier_changes` | `5` | Integer |
299
+ | `rate_limit_window` | `3600` | Seconds (integer) |
300
+ | `sms_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
301
+ | `email_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
302
+ | `on_send` | `nil` | Lambda `(record:, identifier:, channel:) { }` |
303
+ | `on_verify_success` | `nil` | Lambda `(record:, identifier:) { }` |
304
+ | `on_verify_failure` | `nil` | Lambda `(record:, identifier:, attempts:) { }` |
305
+ | `namespace` | `nil` | Lambda `(record) { }` returning a string or nil |
306
+ | `test_mode` | `false` | Boolean — exposes `result.code` |
307
+ | `bypass_delivery` | `false` | Boolean — skips actual delivery |
268
308
 
269
- Hook into the verification lifecycle:
309
+ ### Callbacks
270
310
 
271
311
  ```ruby
272
312
  config.on_send = ->(record:, identifier:, channel:) {
273
- Analytics.track('verification_sent', user_id: record.id)
313
+ Analytics.track("verification_sent", user_id: record.id, channel: channel)
274
314
  }
275
315
 
276
316
  config.on_verify_success = ->(record:, identifier:) {
277
- Analytics.track('verification_success', user_id: record.id)
317
+ Analytics.track("verification_success", user_id: record.id)
278
318
  }
279
319
 
280
320
  config.on_verify_failure = ->(record:, identifier:, attempts:) {
281
- Analytics.track('verification_failure', user_id: record.id, attempts: attempts)
321
+ Analytics.track("verification_failure", user_id: record.id, attempts: attempts)
282
322
  }
283
323
  ```
284
324
 
285
325
  ### Namespacing
286
326
 
287
- Isolate verification data by tenant or organization:
327
+ Isolate verification data per tenant:
288
328
 
289
329
  ```ruby
290
- config.namespace = ->(record) {
291
- record.respond_to?(:organization_id) ? "org:#{record.organization_id}" : nil
292
- }
330
+ config.namespace = ->(record) { "org:#{record.organization_id}" }
293
331
  ```
294
332
 
295
- ### Test Mode
333
+ ---
296
334
 
297
- Expose codes in test environment:
298
-
299
- ```ruby
300
- config.test_mode = true # Includes code in Result object
301
- config.bypass_delivery = true # Skip actual delivery
302
- ```
335
+ ## Result Object
303
336
 
304
- ## Result Object API
337
+ Every operation returns a `VerifyIt::Result`:
305
338
 
306
- All operations return a `VerifyIt::Result` object:
339
+ | Method | Type | Description |
340
+ |---|---|---|
341
+ | `success?` | Boolean | Operation completed without error |
342
+ | `verified?` | Boolean | Code matched successfully |
343
+ | `rate_limited?` | Boolean | Rate limit was exceeded |
344
+ | `locked?` | Boolean | Max verification attempts reached |
345
+ | `error` | Symbol | `:invalid_code`, `:rate_limited`, `:code_not_found`, `:locked`, `:delivery_failed` |
346
+ | `message` | String | Human-readable description |
347
+ | `code` | String | The verification code (only when `test_mode: true`) |
348
+ | `expires_at` | Time | When the code expires |
349
+ | `attempts` | Integer | Number of verification attempts so far |
307
350
 
308
- ```ruby
309
- result.success? # true if operation succeeded
310
- result.verified? # true if code was verified successfully
311
- result.locked? # true if max attempts exceeded
312
- result.rate_limited? # true if rate limit hit
313
- result.error # Symbol error code (:invalid_code, :rate_limited, etc.)
314
- result.message # Human-readable message
315
- result.code # Verification code (only in test_mode)
316
- result.expires_at # Time when code expires
317
- result.attempts # Number of verification attempts
318
- ```
351
+ ---
319
352
 
320
353
  ## Testing
321
354
 
322
- ### RSpec Example
355
+ Configure VerifyIt in your spec helper to avoid real delivery and expose codes:
323
356
 
324
357
  ```ruby
325
- RSpec.describe "Phone Verification", type: :request do
326
- before do
327
- VerifyIt.configure do |config|
328
- config.storage = :memory
329
- config.test_mode = true
330
- config.bypass_delivery = true
358
+ # spec/support/verify_it.rb
359
+ RSpec.configure do |config|
360
+ config.before(:each) do
361
+ VerifyIt.configure do |c|
362
+ c.storage = :memory
363
+ c.test_mode = true
364
+ c.bypass_delivery = true
365
+ c.secret_key_base = "test_secret"
331
366
  end
332
367
  end
333
-
334
- it "verifies phone number" do
335
- post '/verify/send', params: { phone: '+15551234567' }
336
-
337
- # Get code from response (test mode)
338
- code = JSON.parse(response.body)['code']
339
-
340
- post '/verify/confirm', params: { phone: '+15551234567', code: code }
341
- expect(response).to have_http_status(:success)
342
- end
343
368
  end
344
369
  ```
345
370
 
346
- ## Thread Safety
347
-
348
- VerifyIt is designed to be thread-safe:
349
-
350
- - Memory storage uses `Mutex` for synchronization
351
- - Redis operations are atomic
352
- - No shared mutable state in core logic
353
-
354
- ## Performance Considerations
355
-
356
- ### Redis Storage
357
- - Uses key expiration for TTL
358
- - Sorted sets for identifier tracking
359
- - Atomic operations for counters
371
+ In a request spec:
360
372
 
361
- ### Memory Storage
362
- - Fast for testing and development
363
- - Not recommended for production
364
- - Data lost on restart
373
+ ```ruby
374
+ it "verifies phone number" do
375
+ post "/verify/send", params: { phone: "+15551234567" }
376
+ code = JSON.parse(response.body)["code"] # available in test_mode
365
377
 
366
- ## Error Handling
378
+ post "/verify/confirm", params: { phone: "+15551234567", code: code }
379
+ expect(response).to have_http_status(:ok)
380
+ end
381
+ ```
367
382
 
368
- VerifyIt uses explicit error states in Result objects:
383
+ ---
369
384
 
370
- - `:rate_limited` - Rate limit exceeded
371
- - `:invalid_code` - Code doesn't match
372
- - `:code_not_found` - No code stored or expired
373
- - `:locked` - Max attempts reached
374
- - `:delivery_failed` - Delivery provider error
385
+ ## Security
375
386
 
376
- ## Security Best Practices
387
+ - **`secret_key_base` is required** — codes are hashed with HMAC-SHA256 before storage
388
+ - Do not disable rate limiting
389
+ - Use `:redis` or `:database` storage in production
390
+ - Keep `code_ttl` short.
391
+ - Use `on_verify_failure` to monitor and alert on repeated failures
377
392
 
378
- 1. **Always use rate limiting** in production
379
- 2. **Use Redis storage** for distributed systems
380
- 3. **Set short TTLs** (5 minutes recommended)
381
- 4. **Monitor failed attempts** via callbacks
382
- 5. **Use HTTPS** for all verification endpoints
383
- 6. **Sanitize phone numbers** before storage
393
+ ---
384
394
 
385
395
  ## Development
386
396
 
387
- After checking out the repo:
388
-
389
397
  ```bash
390
- bin/setup # Install dependencies
391
- bundle exec rspec # Run tests
392
- bin/console # Interactive console
393
- bundle exec rake build # Build gem
398
+ bin/setup # Install dependencies
399
+ bundle exec rspec # Run tests
400
+ bundle exec rubocop # Lint
401
+ bin/console # Interactive console
394
402
  ```
395
403
 
396
- ## Roadmap
397
-
398
- - [x] Database storage adapter
399
- - [ ] Voice verification support
400
- - [ ] WebAuthn integration
401
- - [ ] Backup code generation
402
- - [ ] Multi-factor authentication helpers
403
- - [x] Rails installer (CLI)
404
-
405
404
  ## Contributing
406
405
 
407
406
  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).
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class ApplicationController < ActionController::API
5
+ rescue_from VerifyIt::ConfigurationError do |_error|
6
+ render json: { error: "Service configuration error" }, status: :internal_server_error
7
+ end
8
+ end
9
+ end