verify_it 0.1.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.
data/README.md ADDED
@@ -0,0 +1,414 @@
1
+ # VerifyIt
2
+
3
+ A production-grade, storage-agnostic verification system for Ruby applications. VerifyIt provides a flexible framework for implementing SMS, email, and other verification workflows with built-in rate limiting and multiple storage backends.
4
+
5
+ ## Features
6
+
7
+ - **Storage Agnostic**: Redis, Memory, or Database storage
8
+ - **Delivery Agnostic**: Bring your own SMS/email provider (Twilio, SendGrid, etc.)
9
+ - **Built-in Rate Limiting**: Configurable limits for sends and verifications
10
+ - **Thread-Safe**: Designed for concurrent access
11
+ - **Test Mode**: Expose codes for testing without delivery
12
+ - **Rails Optional**: Works standalone or with Rails
13
+ - **Zero Runtime Dependencies**: Only ActiveSupport required
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'verify_it'
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ ```bash
26
+ bundle install
27
+ ```
28
+
29
+ Or install it yourself as:
30
+
31
+ ```bash
32
+ gem install verify_it
33
+ ```
34
+
35
+ ### Rails Installation
36
+
37
+ For Rails applications, use the installer to generate configuration files:
38
+
39
+ ```bash
40
+ bundle exec verify_it install
41
+ ```
42
+
43
+ This will:
44
+ 1. Prompt you to select a storage backend (Memory, Redis, or Database)
45
+ 2. Generate an initializer at `config/initializers/verify_it.rb`
46
+ 3. Generate a migration (if Database storage is selected)
47
+
48
+ If you selected Database storage, run the migration:
49
+
50
+ ```bash
51
+ rails db:migrate
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ ### Basic Configuration
57
+
58
+ ```ruby
59
+ require 'verify_it'
60
+
61
+ VerifyIt.configure do |config|
62
+ # Storage backend
63
+ config.storage = :memory # or :redis, :database
64
+
65
+ # For Redis storage
66
+ # config.redis_client = Redis.new(url: ENV['REDIS_URL'])
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)
80
+ config.sms_sender = ->(to:, code:, context:) {
81
+ # Use custom template from context, or default
82
+ message = if context[:message_template]
83
+ context[:message_template] % { code: code }
84
+ else
85
+ "Your verification code is: #{code}"
86
+ end
87
+
88
+ twilio_client.messages.create(
89
+ to: to,
90
+ from: '+15551234567',
91
+ body: message
92
+ )
93
+ }
94
+
95
+ # Email delivery
96
+ config.email_sender = ->(to:, code:, context:) {
97
+ # Context available for customization
98
+ UserMailer.verification_code(to, code, context).deliver_now
99
+ }
100
+ end
101
+ ```
102
+
103
+ ### Sending a Verification Code
104
+
105
+ ```ruby
106
+ result = VerifyIt.send_code(
107
+ to: "+15551234567",
108
+ record: current_user,
109
+ channel: :sms,
110
+ context: { user_id: current_user.id }
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?
119
+ end
120
+ ```
121
+
122
+ ### Verifying a Code
123
+
124
+ ```ruby
125
+ result = VerifyIt.verify_code(
126
+ to: "+15551234567",
127
+ code: params[:code],
128
+ record: current_user
129
+ )
130
+
131
+ if result.verified?
132
+ # Success! Code is valid
133
+ session[:verified] = true
134
+ redirect_to dashboard_path
135
+ elsif result.locked?
136
+ # Too many failed attempts
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"
141
+ end
142
+ ```
143
+
144
+ ### Cleanup
145
+
146
+ ```ruby
147
+ # Clean up verification data for a user
148
+ VerifyIt.cleanup(
149
+ to: "+15551234567",
150
+ record: current_user
151
+ )
152
+ ```
153
+
154
+ ## Configuration Options
155
+
156
+ ### Storage Backends
157
+
158
+ #### Memory Storage (Default)
159
+ Perfect for testing and development:
160
+
161
+ ```ruby
162
+ config.storage = :memory
163
+ ```
164
+
165
+ #### Redis Storage
166
+ Recommended for production:
167
+
168
+ ```ruby
169
+ config.storage = :redis
170
+ config.redis_client = Redis.new(url: ENV['REDIS_URL'])
171
+ ```
172
+
173
+ #### Database Storage
174
+ Uses ActiveRecord models for persistent storage:
175
+
176
+ ```ruby
177
+ config.storage = :database
178
+ ```
179
+
180
+ Requires running the migration:
181
+
182
+ ```bash
183
+ rails generate verify_it:install
184
+ rails db:migrate
185
+ ```
186
+
187
+ Creates three tables:
188
+ - `verify_it_codes` - Stores verification codes
189
+ - `verify_it_attempts` - Tracks verification and send attempts
190
+ - `verify_it_identifier_changes` - Tracks identifier change history
191
+
192
+ ### Code Settings
193
+
194
+ ```ruby
195
+ config.code_length = 6 # Length of verification code
196
+ config.code_ttl = 300 # Time-to-live in seconds (5 minutes)
197
+ config.code_format = :numeric # :numeric, :alphanumeric, or :alpha
198
+ ```
199
+
200
+ ### Rate Limiting
201
+
202
+ ```ruby
203
+ config.max_send_attempts = 3 # Max sends per window
204
+ config.max_verification_attempts = 5 # Max verification tries per window
205
+ config.max_identifier_changes = 5 # Max identifier changes per window
206
+ config.rate_limit_window = 3600 # Window duration in seconds
207
+ ```
208
+
209
+ ### Callbacks
210
+
211
+ Hook into the verification lifecycle:
212
+
213
+ ```ruby
214
+ config.on_send = ->(record:, identifier:, channel:) {
215
+ Analytics.track('verification_sent', user_id: record.id)
216
+ }
217
+
218
+ config.on_verify_success = ->(record:, identifier:) {
219
+ Analytics.track('verification_success', user_id: record.id)
220
+ }
221
+
222
+ config.on_verify_failure = ->(record:, identifier:, attempts:) {
223
+ Analytics.track('verification_failure', user_id: record.id, attempts: attempts)
224
+ }
225
+ ```
226
+
227
+ ### Namespacing
228
+
229
+ Isolate verification data by tenant or organization:
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:
240
+
241
+ ```ruby
242
+ config.test_mode = true # Includes code in Result object
243
+ config.bypass_delivery = true # Skip actual delivery
244
+ ```
245
+
246
+ ## Rails Integration
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
265
+
266
+ # Send with custom message template
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
+ })
277
+
278
+ # Verify code
279
+ result = user.verify_sms_code(params[:code])
280
+
281
+ # Cleanup
282
+ user.cleanup_sms_verification
283
+ ```
284
+
285
+ ### Rails Configuration
286
+
287
+ Create an initializer `config/initializers/verify_it.rb`:
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
+ ```
318
+
319
+ ## Testing
320
+
321
+ ### RSpec Example
322
+
323
+ ```ruby
324
+ RSpec.describe "Phone Verification", type: :request do
325
+ before do
326
+ VerifyIt.configure do |config|
327
+ config.storage = :memory
328
+ config.test_mode = true
329
+ config.bypass_delivery = true
330
+ end
331
+ 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
+ end
343
+ ```
344
+
345
+ ## Thread Safety
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
352
+
353
+ ## Performance Considerations
354
+
355
+ ### Redis Storage
356
+ - Uses key expiration for TTL
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
364
+
365
+ ## Error Handling
366
+
367
+ VerifyIt uses explicit error states in Result objects:
368
+
369
+ - `:rate_limited` - Rate limit exceeded
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
374
+
375
+ ## Security Best Practices
376
+
377
+ 1. **Always use rate limiting** in production
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
383
+
384
+ ## Development
385
+
386
+ After checking out the repo:
387
+
388
+ ```bash
389
+ bin/setup # Install dependencies
390
+ bundle exec rspec # Run tests
391
+ bin/console # Interactive console
392
+ bundle exec rake build # Build gem
393
+ ```
394
+
395
+ ## Roadmap
396
+
397
+ - [ ] Database storage adapter
398
+ - [ ] Voice verification support
399
+ - [ ] WebAuthn integration
400
+ - [ ] Backup code generation
401
+ - [ ] Multi-factor authentication helpers
402
+ - [ ] Rails generators
403
+
404
+ ## Contributing
405
+
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).
407
+
408
+ ## License
409
+
410
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
411
+
412
+ ## Code of Conduct
413
+
414
+ Everyone interacting in the VerifyIt project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/JeremasPosta/verify_it/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/verify_it ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "verify_it"
5
+ require "verify_it/cli"
6
+
7
+ VerifyIt::CLI.start(ARGV)
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "erb"
5
+ require_relative "../verify_it" unless defined?(VerifyIt::VERSION)
6
+
7
+ module VerifyIt
8
+ class CLI
9
+ def self.start(args)
10
+ new(args).run
11
+ end
12
+
13
+ def initialize(args)
14
+ @args = args
15
+ @command = args[0]
16
+ end
17
+
18
+ def run
19
+ case @command
20
+ when "install"
21
+ install
22
+ when "version", "-v", "--version"
23
+ puts "VerifyIt #{VerifyIt::VERSION}"
24
+ when "help", "-h", "--help", nil
25
+ show_help
26
+ else
27
+ puts "Unknown command: #{@command}"
28
+ show_help
29
+ exit 1
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def install
36
+ puts "VerifyIt Installer"
37
+ puts "=" * 50
38
+ puts
39
+
40
+ check_rails_environment
41
+
42
+ storage_type = prompt_storage_type
43
+ generate_initializer(storage_type)
44
+ generate_migration if storage_type == "database"
45
+
46
+ puts
47
+ puts "✓ Installation complete!"
48
+ puts
49
+ puts "Next steps:"
50
+ puts " 1. Review the generated initializer"
51
+ if storage_type == "database"
52
+ puts " 2. Run: rails db:migrate"
53
+ puts " 3. Configure your delivery method in the initializer"
54
+ else
55
+ puts " 2. Configure your delivery method in the initializer"
56
+ puts " 3. Start using VerifyIt in your models!"
57
+ end
58
+ end
59
+
60
+ def check_rails_environment
61
+ unless defined?(Rails)
62
+ puts "Error: Rails environment not detected."
63
+ puts "Please run this command from your Rails application root."
64
+ exit 1
65
+ end
66
+ end
67
+
68
+ def prompt_storage_type
69
+ puts "Select storage backend:"
70
+ puts " 1) Memory (for development/testing)"
71
+ puts " 2) Redis (recommended for production)"
72
+ puts " 3) Database (uses ActiveRecord)"
73
+ puts
74
+
75
+ loop do
76
+ print "Enter choice (1-3): "
77
+ choice = $stdin.gets.chomp
78
+
79
+ case choice
80
+ when "1"
81
+ return "memory"
82
+ when "2"
83
+ return "redis"
84
+ when "3"
85
+ return "database"
86
+ else
87
+ puts "Invalid choice. Please enter 1, 2, or 3."
88
+ end
89
+ end
90
+ end
91
+
92
+ def generate_initializer(storage_type)
93
+ template_path = File.expand_path("../templates/initializer.rb.erb", __FILE__)
94
+ output_path = Rails.root.join("config", "initializers", "verify_it.rb")
95
+
96
+ if File.exist?(output_path)
97
+ print "Initializer already exists. Overwrite? (y/n): "
98
+ return unless $stdin.gets.chomp.downcase == "y"
99
+ end
100
+
101
+ template = ERB.new(File.read(template_path))
102
+ content = template.result(binding)
103
+
104
+ FileUtils.mkdir_p(File.dirname(output_path))
105
+ File.write(output_path, content)
106
+
107
+ puts "✓ Created initializer: config/initializers/verify_it.rb"
108
+ end
109
+
110
+ def generate_migration
111
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
112
+ template_path = File.expand_path("../templates/migration.rb.erb", __FILE__)
113
+ output_path = Rails.root.join("db", "migrate", "#{timestamp}_create_verify_it_tables.rb")
114
+
115
+ template = ERB.new(File.read(template_path))
116
+ content = template.result(binding)
117
+
118
+ FileUtils.mkdir_p(File.dirname(output_path))
119
+ File.write(output_path, content)
120
+
121
+ puts "✓ Created migration: db/migrate/#{timestamp}_create_verify_it_tables.rb"
122
+ end
123
+
124
+ def show_help
125
+ puts "VerifyIt - Verification system for Ruby applications"
126
+ puts
127
+ puts "Usage:"
128
+ puts " verify_it install Generate initializer and optional migration"
129
+ puts " verify_it version Show version number"
130
+ puts " verify_it help Show this help message"
131
+ puts
132
+ puts "For more information, visit: https://github.com/JeremasPosta/verify_it"
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class CodeGenerator
5
+ def self.generate(length:, format:)
6
+ case format
7
+ when :numeric
8
+ generate_numeric(length)
9
+ when :alphanumeric
10
+ generate_alphanumeric(length)
11
+ when :alpha
12
+ generate_alpha(length)
13
+ else
14
+ raise ArgumentError, "Invalid code format: #{format}. Must be :numeric, :alphanumeric, or :alpha"
15
+ end
16
+ end
17
+
18
+ def self.generate_numeric(length)
19
+ Array.new(length) { rand(0..9) }.join
20
+ end
21
+
22
+ def self.generate_alphanumeric(length)
23
+ chars = ("0".."9").to_a + ("A".."Z").to_a
24
+ Array.new(length) { chars.sample }.join
25
+ end
26
+
27
+ def self.generate_alpha(length)
28
+ chars = ("A".."Z").to_a
29
+ Array.new(length) { chars.sample }.join
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class Configuration
5
+ attr_accessor :storage,
6
+ :redis_client,
7
+ :delivery_channel,
8
+ :code_length,
9
+ :code_ttl,
10
+ :code_format,
11
+ :max_send_attempts,
12
+ :max_verification_attempts,
13
+ :max_identifier_changes,
14
+ :rate_limit_window,
15
+ :sms_sender,
16
+ :email_sender,
17
+ :on_send,
18
+ :on_verify_success,
19
+ :on_verify_failure,
20
+ :namespace,
21
+ :test_mode,
22
+ :bypass_delivery
23
+
24
+ def initialize
25
+ # Default values
26
+ @storage = :memory
27
+ @redis_client = nil
28
+ @delivery_channel = :sms
29
+ @code_length = 6
30
+ @code_ttl = 300 # 5 minutes
31
+ @code_format = :numeric
32
+ @max_send_attempts = 3
33
+ @max_verification_attempts = 5
34
+ @max_identifier_changes = 5
35
+ @rate_limit_window = 3600 # 1 hour
36
+ @sms_sender = nil
37
+ @email_sender = nil
38
+ @on_send = nil
39
+ @on_verify_success = nil
40
+ @on_verify_failure = nil
41
+ @namespace = nil
42
+ @test_mode = false
43
+ @bypass_delivery = false
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Delivery
5
+ class Base
6
+ def deliver(to:, code:, context:)
7
+ raise NotImplementedError
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Delivery
5
+ class EmailDelivery < Base
6
+ def deliver(to:, code:, context:)
7
+ return if VerifyIt.configuration.bypass_delivery
8
+
9
+ sender = VerifyIt.configuration.email_sender
10
+ raise ConfigurationError, "Email sender not configured" unless sender
11
+
12
+ sender.call(to: to, code: code, context: context)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Delivery
5
+ class SmsDelivery < Base
6
+ def deliver(to:, code:, context:)
7
+ return if VerifyIt.configuration.bypass_delivery
8
+
9
+ sender = VerifyIt.configuration.sms_sender
10
+ raise ConfigurationError, "SMS sender not configured" unless sender
11
+
12
+ sender.call(to: to, code: code, context: context)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module VerifyIt
6
+ class Railtie < Rails::Railtie
7
+ initializer "verify_it.active_record" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ require_relative "verifiable"
10
+ include VerifyIt::Verifiable
11
+ end
12
+ end
13
+ end
14
+ end