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.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Storage
5
+ class RedisStorage < Base
6
+ def initialize(redis_client)
7
+ @redis = redis_client
8
+ raise ArgumentError, "Redis client is required for RedisStorage" unless @redis
9
+ end
10
+
11
+ def store_code(identifier:, record:, code:, expires_at:)
12
+ key = build_key(identifier: identifier, record: record, suffix: "code")
13
+ ttl = (expires_at - Time.now).to_i
14
+ @redis.setex(key, ttl, code)
15
+ end
16
+
17
+ def fetch_code(identifier:, record:)
18
+ key = build_key(identifier: identifier, record: record, suffix: "code")
19
+ @redis.get(key)
20
+ end
21
+
22
+ def delete_code(identifier:, record:)
23
+ key = build_key(identifier: identifier, record: record, suffix: "code")
24
+ @redis.del(key)
25
+ end
26
+
27
+ def increment_attempts(identifier:, record:)
28
+ key = build_key(identifier: identifier, record: record, suffix: "attempts")
29
+ count = @redis.incr(key)
30
+ @redis.expire(key, VerifyIt.configuration.rate_limit_window) if count == 1
31
+ count
32
+ end
33
+
34
+ def attempts(identifier:, record:)
35
+ key = build_key(identifier: identifier, record: record, suffix: "attempts")
36
+ @redis.get(key).to_i
37
+ end
38
+
39
+ def reset_attempts(identifier:, record:)
40
+ key = build_key(identifier: identifier, record: record, suffix: "attempts")
41
+ @redis.del(key)
42
+ end
43
+
44
+ def increment_send_count(identifier:, record:)
45
+ key = build_key(identifier: identifier, record: record, suffix: "send_count")
46
+ count = @redis.incr(key)
47
+ @redis.expire(key, VerifyIt.configuration.rate_limit_window) if count == 1
48
+ count
49
+ end
50
+
51
+ def send_count(identifier:, record:)
52
+ key = build_key(identifier: identifier, record: record, suffix: "send_count")
53
+ @redis.get(key).to_i
54
+ end
55
+
56
+ def reset_send_count(identifier:, record:)
57
+ key = build_key(identifier: identifier, record: record, suffix: "send_count")
58
+ @redis.del(key)
59
+ end
60
+
61
+ def track_identifier_change(record:, identifier:)
62
+ key = build_key(identifier: "", record: record, suffix: "identifier_changes")
63
+ score = Time.now.to_f
64
+ @redis.zadd(key, score, "#{identifier}:#{score}")
65
+ @redis.expire(key, VerifyIt.configuration.rate_limit_window)
66
+
67
+ # Remove old entries
68
+ cutoff = Time.now.to_f - VerifyIt.configuration.rate_limit_window
69
+ @redis.zremrangebyscore(key, "-inf", cutoff)
70
+ end
71
+
72
+ def identifier_changes(record:)
73
+ key = build_key(identifier: "", record: record, suffix: "identifier_changes")
74
+ cutoff = Time.now.to_f - VerifyIt.configuration.rate_limit_window
75
+ @redis.zcount(key, cutoff, "+inf")
76
+ end
77
+
78
+ def cleanup(identifier:, record:)
79
+ pattern = build_key(identifier: identifier, record: record, suffix: "*")
80
+ keys = @redis.keys(pattern)
81
+ @redis.del(*keys) if keys.any?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ VerifyIt.configure do |config|
4
+ <% if storage_type == "memory" %>
5
+ # Memory storage (for development/testing only)
6
+ config.storage = :memory
7
+ <% elsif storage_type == "redis" %>
8
+ # Redis storage (recommended for production)
9
+ config.storage = :redis
10
+ config.redis_client = Redis.new(
11
+ url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
12
+ )
13
+ <% elsif storage_type == "database" %>
14
+ # Database storage (uses ActiveRecord)
15
+ config.storage = :database
16
+ <% end %>
17
+
18
+ # Code settings
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)
50
+ config.email_sender = ->(to:, code:, context:) {
51
+ # Example with ActionMailer:
52
+ # 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
+ }
57
+
58
+ # Lifecycle callbacks (optional)
59
+ # config.on_send = ->(record:, identifier:, channel:) {
60
+ # Rails.logger.info("Verification sent to #{identifier} via #{channel}")
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
+ # }
75
+
76
+ # Test mode (expose codes for testing)
77
+ config.test_mode = Rails.env.test?
78
+ config.bypass_delivery = Rails.env.test?
79
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateVerifyItTables < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :verify_it_codes do |t|
6
+ t.string :identifier, null: false
7
+ t.string :record_type
8
+ t.integer :record_id
9
+ t.string :code, null: false
10
+ t.datetime :expires_at, null: false
11
+ t.timestamps
12
+
13
+ t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record"
14
+ t.index :expires_at
15
+ end
16
+
17
+ create_table :verify_it_attempts do |t|
18
+ t.string :identifier, null: false
19
+ t.string :record_type
20
+ t.integer :record_id
21
+ t.string :attempt_type, null: false # 'verification' or 'send'
22
+ t.integer :count, default: 0, null: false
23
+ t.datetime :expires_at, null: false
24
+ t.timestamps
25
+
26
+ t.index [:identifier, :record_type, :record_id, :attempt_type],
27
+ name: "index_verify_it_attempts_on_record_and_type"
28
+ t.index :expires_at
29
+ end
30
+
31
+ create_table :verify_it_identifier_changes do |t|
32
+ t.string :record_type
33
+ t.integer :record_id
34
+ t.string :identifier, null: false
35
+ t.datetime :created_at, null: false
36
+
37
+ t.index [:record_type, :record_id], name: "index_verify_it_identifier_changes_on_record"
38
+ t.index :created_at
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Verifiable
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ def verifies(attribute, channel: :sms)
9
+ define_method("send_verification_code") do |context: {}|
10
+ identifier = send(attribute)
11
+ VerifyIt.send_code(
12
+ to: identifier,
13
+ record: self,
14
+ channel: channel,
15
+ context: context
16
+ )
17
+ end
18
+
19
+ define_method("verify_code") do |code|
20
+ identifier = send(attribute)
21
+ VerifyIt.verify_code(
22
+ to: identifier,
23
+ code: code,
24
+ record: self
25
+ )
26
+ end
27
+
28
+ define_method("cleanup_verification") do
29
+ identifier = send(attribute)
30
+ VerifyIt.cleanup(
31
+ to: identifier,
32
+ record: self
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class Verifier
5
+ attr_reader :storage, :rate_limiter
6
+
7
+ def initialize(storage:)
8
+ @storage = storage
9
+ @rate_limiter = RateLimiter.new(storage)
10
+ end
11
+
12
+ def send_code(to:, record:, channel: nil, context: {})
13
+ channel ||= VerifyIt.configuration.delivery_channel
14
+
15
+ # Check send rate limit
16
+ limit_check = rate_limiter.check_send_limit(identifier: to, record: record)
17
+ unless limit_check[:allowed]
18
+ return Result.new(
19
+ success: false,
20
+ error: :rate_limited,
21
+ message: limit_check[:reason],
22
+ rate_limited: true
23
+ )
24
+ end
25
+
26
+ # Check identifier change limit
27
+ change_limit = rate_limiter.check_identifier_change_limit(record: record)
28
+ unless change_limit[:allowed]
29
+ return Result.new(
30
+ success: false,
31
+ error: :rate_limited,
32
+ message: change_limit[:reason],
33
+ rate_limited: true
34
+ )
35
+ end
36
+
37
+ # Generate code
38
+ code = CodeGenerator.generate(
39
+ length: VerifyIt.configuration.code_length,
40
+ format: VerifyIt.configuration.code_format
41
+ )
42
+ expires_at = Time.now + VerifyIt.configuration.code_ttl
43
+
44
+ # Store code
45
+ storage.store_code(
46
+ identifier: to,
47
+ record: record,
48
+ code: code,
49
+ expires_at: expires_at
50
+ )
51
+
52
+ # Reset verification attempts for new code
53
+ storage.reset_attempts(identifier: to, record: record)
54
+
55
+ # Record send attempt
56
+ rate_limiter.record_send(identifier: to, record: record)
57
+
58
+ # Track identifier change if needed
59
+ rate_limiter.record_identifier_change(record: record, identifier: to)
60
+
61
+ # Deliver code
62
+ begin
63
+ delivery = get_delivery_adapter(channel)
64
+ delivery.deliver(to: to, code: code, context: context)
65
+ rescue StandardError => e
66
+ return Result.new(
67
+ success: false,
68
+ error: :delivery_failed,
69
+ message: "Failed to deliver verification code: #{e.message}"
70
+ )
71
+ end
72
+
73
+ # Call on_send callback if configured
74
+ callback = VerifyIt.configuration.on_send
75
+ callback&.call(record: record, identifier: to, channel: channel)
76
+
77
+ # Build result
78
+ result_data = {
79
+ success: true,
80
+ message: "Verification code sent successfully",
81
+ expires_at: expires_at
82
+ }
83
+
84
+ # Include code in test mode
85
+ result_data[:code] = code if VerifyIt.configuration.test_mode
86
+
87
+ Result.new(**result_data)
88
+ end
89
+
90
+ def verify_code(to:, code:, record:)
91
+ # Check verification rate limit
92
+ limit_check = rate_limiter.check_verification_limit(identifier: to, record: record)
93
+ unless limit_check[:allowed]
94
+ return Result.new(
95
+ success: false,
96
+ error: :locked,
97
+ message: limit_check[:reason],
98
+ locked: true,
99
+ attempts: storage.attempts(identifier: to, record: record)
100
+ )
101
+ end
102
+
103
+ # Fetch stored code
104
+ stored_code = storage.fetch_code(identifier: to, record: record)
105
+
106
+ unless stored_code
107
+ return Result.new(
108
+ success: false,
109
+ error: :code_not_found,
110
+ message: "No verification code found or code has expired"
111
+ )
112
+ end
113
+
114
+ # Increment attempts
115
+ current_attempts = rate_limiter.record_verification_attempt(identifier: to, record: record)
116
+
117
+ # Verify code
118
+ if stored_code == code.to_s
119
+ # Success - cleanup
120
+ storage.delete_code(identifier: to, record: record)
121
+ storage.reset_attempts(identifier: to, record: record)
122
+ storage.reset_send_count(identifier: to, record: record)
123
+
124
+ # Call success callback if configured
125
+ callback = VerifyIt.configuration.on_verify_success
126
+ callback&.call(record: record, identifier: to)
127
+
128
+ Result.new(
129
+ success: true,
130
+ message: "Verification successful",
131
+ verified: true,
132
+ attempts: current_attempts
133
+ )
134
+ else
135
+ # Failure
136
+ max_attempts = VerifyIt.configuration.max_verification_attempts
137
+ locked = current_attempts >= max_attempts
138
+
139
+ # Call failure callback if configured
140
+ callback = VerifyIt.configuration.on_verify_failure
141
+ callback&.call(record: record, identifier: to, attempts: current_attempts)
142
+
143
+ Result.new(
144
+ success: false,
145
+ error: :invalid_code,
146
+ message: locked ? "Maximum verification attempts exceeded" : "Invalid verification code",
147
+ attempts: current_attempts,
148
+ locked: locked
149
+ )
150
+ end
151
+ end
152
+
153
+ def cleanup(to:, record:)
154
+ storage.cleanup(identifier: to, record: record)
155
+ Result.new(
156
+ success: true,
157
+ message: "Verification data cleaned up"
158
+ )
159
+ end
160
+
161
+ private
162
+
163
+ def get_delivery_adapter(channel)
164
+ case channel
165
+ when :sms
166
+ Delivery::SmsDelivery.new
167
+ when :email
168
+ Delivery::EmailDelivery.new
169
+ else
170
+ raise ArgumentError, "Invalid delivery channel: #{channel}"
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ VERSION = "0.1.0"
5
+ end
data/lib/verify_it.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verify_it/version"
4
+ require_relative "verify_it/configuration"
5
+ require_relative "verify_it/result"
6
+ require_relative "verify_it/code_generator"
7
+ require_relative "verify_it/rate_limiter"
8
+ require_relative "verify_it/storage/base"
9
+ require_relative "verify_it/storage/memory_storage"
10
+ require_relative "verify_it/storage/redis_storage"
11
+ require_relative "verify_it/storage/database_storage"
12
+ require_relative "verify_it/delivery/base"
13
+ require_relative "verify_it/delivery/sms_delivery"
14
+ require_relative "verify_it/delivery/email_delivery"
15
+ require_relative "verify_it/verifier"
16
+
17
+ module VerifyIt
18
+ class Error < StandardError; end
19
+ class ConfigurationError < Error; end
20
+ class StorageError < Error; end
21
+ class DeliveryError < Error; end
22
+
23
+ class << self
24
+ attr_writer :configuration
25
+
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield(configuration)
32
+ end
33
+
34
+ def send_code(to:, record:, channel: nil, context: {})
35
+ verifier.send_code(to: to, record: record, channel: channel, context: context)
36
+ end
37
+
38
+ def verify_code(to:, code:, record:)
39
+ verifier.verify_code(to: to, code: code, record: record)
40
+ end
41
+
42
+ def cleanup(to:, record:)
43
+ verifier.cleanup(to: to, record: record)
44
+ end
45
+
46
+ def reset!
47
+ @configuration = Configuration.new
48
+ @verifier = nil
49
+ @storage = nil
50
+ end
51
+
52
+ private
53
+
54
+ def verifier
55
+ @verifier ||= Verifier.new(storage: storage)
56
+ end
57
+
58
+ def storage
59
+ @storage ||= build_storage
60
+ end
61
+
62
+ def build_storage
63
+ case configuration.storage
64
+ when :memory
65
+ Storage::MemoryStorage.new
66
+ when :redis
67
+ unless configuration.redis_client
68
+ raise ConfigurationError, "Redis client must be configured when using :redis storage"
69
+ end
70
+
71
+ Storage::RedisStorage.new(configuration.redis_client)
72
+ when :database
73
+ Storage::DatabaseStorage.new
74
+ else
75
+ raise ConfigurationError, "Invalid storage type: #{configuration.storage}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ # Load Rails integration if Rails is present
82
+ require_relative "verify_it/railtie" if defined?(Rails)
Binary file
data/sig/verify_it.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module VerifyIt
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end