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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class RateLimiter
5
+ def initialize(storage)
6
+ @storage = storage
7
+ end
8
+
9
+ def check_send_limit(identifier:, record:)
10
+ current_count = @storage.send_count(identifier: identifier, record: record)
11
+ max_attempts = VerifyIt.configuration.max_send_attempts
12
+
13
+ if current_count >= max_attempts
14
+ {
15
+ allowed: false,
16
+ reason: "Maximum send attempts (#{max_attempts}) exceeded within rate limit window"
17
+ }
18
+ else
19
+ { allowed: true }
20
+ end
21
+ end
22
+
23
+ def check_verification_limit(identifier:, record:)
24
+ current_attempts = @storage.attempts(identifier: identifier, record: record)
25
+ max_attempts = VerifyIt.configuration.max_verification_attempts
26
+
27
+ if current_attempts >= max_attempts
28
+ {
29
+ allowed: false,
30
+ locked: true,
31
+ reason: "Maximum verification attempts (#{max_attempts}) exceeded"
32
+ }
33
+ else
34
+ { allowed: true }
35
+ end
36
+ end
37
+
38
+ def check_identifier_change_limit(record:)
39
+ changes = @storage.identifier_changes(record: record)
40
+ max_changes = VerifyIt.configuration.max_identifier_changes
41
+
42
+ if changes >= max_changes
43
+ {
44
+ allowed: false,
45
+ reason: "Maximum identifier changes (#{max_changes}) exceeded within rate limit window"
46
+ }
47
+ else
48
+ { allowed: true }
49
+ end
50
+ end
51
+
52
+ def record_send(identifier:, record:)
53
+ @storage.increment_send_count(identifier: identifier, record: record)
54
+ end
55
+
56
+ def record_verification_attempt(identifier:, record:)
57
+ @storage.increment_attempts(identifier: identifier, record: record)
58
+ end
59
+
60
+ def record_identifier_change(record:, identifier:)
61
+ @storage.track_identifier_change(record: record, identifier: identifier)
62
+ end
63
+
64
+ def reset_verification_attempts(identifier:, record:)
65
+ @storage.reset_attempts(identifier: identifier, record: record)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class Result
5
+ attr_reader :error, :message, :code, :expires_at, :attempts
6
+
7
+ def initialize(success:, error: nil, message: nil, code: nil, expires_at: nil, attempts: 0,
8
+ rate_limited: false, locked: false, verified: false)
9
+ @success = success
10
+ @error = error
11
+ @message = message
12
+ @code = code
13
+ @expires_at = expires_at
14
+ @attempts = attempts
15
+ @rate_limited = rate_limited
16
+ @locked = locked
17
+ @verified = verified
18
+ end
19
+
20
+ def success?
21
+ @success
22
+ end
23
+
24
+ def rate_limited?
25
+ @rate_limited
26
+ end
27
+
28
+ def locked?
29
+ @locked
30
+ end
31
+
32
+ def verified?
33
+ @verified
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Storage
5
+ class Base
6
+ def store_code(identifier:, record:, code:, expires_at:)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def fetch_code(identifier:, record:)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def delete_code(identifier:, record:)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def increment_attempts(identifier:, record:)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def attempts(identifier:, record:)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def reset_attempts(identifier:, record:)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def increment_send_count(identifier:, record:)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def send_count(identifier:, record:)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ def reset_send_count(identifier:, record:)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def track_identifier_change(record:, identifier:)
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def identifier_changes(record:)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def cleanup(identifier:, record:)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ protected
55
+
56
+ def build_key(identifier:, record:, suffix:)
57
+ namespace = VerifyIt.configuration.namespace
58
+ ns = namespace.respond_to?(:call) ? namespace.call(record) : nil
59
+
60
+ parts = []
61
+ parts << ns if ns
62
+ parts << record.class.name if record
63
+ parts << record.id if record.respond_to?(:id)
64
+ parts << identifier
65
+ parts << suffix
66
+
67
+ parts.join(":")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Storage
5
+ class DatabaseStorage < Base
6
+ def initialize
7
+ # Verify ActiveRecord is available
8
+ unless defined?(::ActiveRecord)
9
+ raise StandardError, "ActiveRecord is required for DatabaseStorage"
10
+ end
11
+
12
+ # Load models only when needed
13
+ require_relative "models/code"
14
+ require_relative "models/attempt"
15
+ require_relative "models/identifier_change"
16
+ end
17
+
18
+ def store_code(identifier:, record:, code:, expires_at:)
19
+ attrs = {
20
+ identifier: identifier.to_s,
21
+ code: code,
22
+ expires_at: expires_at
23
+ }
24
+
25
+ if record
26
+ attrs[:record_type] = record.class.name
27
+ attrs[:record_id] = record.id
28
+ end
29
+
30
+ # Find existing or create new
31
+ existing = find_code(identifier: identifier, record: record)
32
+ if existing
33
+ existing.update!(attrs)
34
+ else
35
+ Models::Code.create!(attrs)
36
+ end
37
+ end
38
+
39
+ def fetch_code(identifier:, record:)
40
+ code_record = find_code(identifier: identifier, record: record)
41
+ return nil unless code_record
42
+ return nil if code_record.expired?
43
+
44
+ code_record.code
45
+ end
46
+
47
+ def delete_code(identifier:, record:)
48
+ code_record = find_code(identifier: identifier, record: record)
49
+ code_record&.destroy
50
+ end
51
+
52
+ def increment_attempts(identifier:, record:)
53
+ increment_attempt_count(
54
+ identifier: identifier,
55
+ record: record,
56
+ attempt_type: "verification"
57
+ )
58
+ end
59
+
60
+ def attempts(identifier:, record:)
61
+ get_attempt_count(
62
+ identifier: identifier,
63
+ record: record,
64
+ attempt_type: "verification"
65
+ )
66
+ end
67
+
68
+ def reset_attempts(identifier:, record:)
69
+ reset_attempt_count(
70
+ identifier: identifier,
71
+ record: record,
72
+ attempt_type: "verification"
73
+ )
74
+ end
75
+
76
+ def increment_send_count(identifier:, record:)
77
+ increment_attempt_count(
78
+ identifier: identifier,
79
+ record: record,
80
+ attempt_type: "send"
81
+ )
82
+ end
83
+
84
+ def send_count(identifier:, record:)
85
+ get_attempt_count(
86
+ identifier: identifier,
87
+ record: record,
88
+ attempt_type: "send"
89
+ )
90
+ end
91
+
92
+ def reset_send_count(identifier:, record:)
93
+ reset_attempt_count(
94
+ identifier: identifier,
95
+ record: record,
96
+ attempt_type: "send"
97
+ )
98
+ end
99
+
100
+ def track_identifier_change(record:, identifier:)
101
+ attrs = {
102
+ identifier: identifier.to_s,
103
+ created_at: Time.now
104
+ }
105
+
106
+ if record
107
+ attrs[:record_type] = record.class.name
108
+ attrs[:record_id] = record.id
109
+ end
110
+
111
+ Models::IdentifierChange.create!(attrs)
112
+
113
+ # Cleanup old changes outside the rate limit window
114
+ cleanup_old_identifier_changes(record: record)
115
+ end
116
+
117
+ def identifier_changes(record:)
118
+ return 0 unless record
119
+
120
+ window = VerifyIt.configuration.rate_limit_window
121
+ Models::IdentifierChange
122
+ .for_record(record)
123
+ .recent(window)
124
+ .count
125
+ end
126
+
127
+ def cleanup(identifier:, record:)
128
+ # Delete codes
129
+ scope = Models::Code.for_identifier(identifier)
130
+ scope = scope.for_record(record) if record
131
+ scope.delete_all
132
+
133
+ # Delete attempts
134
+ scope = Models::Attempt.for_identifier(identifier)
135
+ scope = scope.for_record(record) if record
136
+ scope.delete_all
137
+ end
138
+
139
+ private
140
+
141
+ def find_code(identifier:, record:)
142
+ scope = Models::Code.for_identifier(identifier)
143
+ scope = scope.for_record(record) if record
144
+ scope.first
145
+ end
146
+
147
+ def find_attempt(identifier:, record:, attempt_type:)
148
+ scope = Models::Attempt
149
+ .for_identifier(identifier)
150
+ .where(attempt_type: attempt_type)
151
+
152
+ scope = scope.for_record(record) if record
153
+ scope.first
154
+ end
155
+
156
+ def increment_attempt_count(identifier:, record:, attempt_type:)
157
+ attempt = find_attempt(
158
+ identifier: identifier,
159
+ record: record,
160
+ attempt_type: attempt_type
161
+ )
162
+
163
+ window = VerifyIt.configuration.rate_limit_window
164
+ expires_at = Time.now + window
165
+
166
+ if attempt.nil? || attempt.expired?
167
+ # Create new attempt record
168
+ attrs = {
169
+ identifier: identifier.to_s,
170
+ attempt_type: attempt_type,
171
+ count: 1,
172
+ expires_at: expires_at
173
+ }
174
+
175
+ if record
176
+ attrs[:record_type] = record.class.name
177
+ attrs[:record_id] = record.id
178
+ end
179
+
180
+ # Delete old expired attempt if exists
181
+ attempt&.destroy
182
+
183
+ Models::Attempt.create!(attrs)
184
+ 1
185
+ else
186
+ # Increment existing
187
+ attempt.increment!(:count)
188
+ attempt.count
189
+ end
190
+ end
191
+
192
+ def get_attempt_count(identifier:, record:, attempt_type:)
193
+ attempt = find_attempt(
194
+ identifier: identifier,
195
+ record: record,
196
+ attempt_type: attempt_type
197
+ )
198
+
199
+ return 0 unless attempt
200
+ return 0 if attempt.expired?
201
+
202
+ attempt.count
203
+ end
204
+
205
+ def reset_attempt_count(identifier:, record:, attempt_type:)
206
+ attempt = find_attempt(
207
+ identifier: identifier,
208
+ record: record,
209
+ attempt_type: attempt_type
210
+ )
211
+
212
+ attempt&.destroy
213
+ end
214
+
215
+ def cleanup_old_identifier_changes(record:)
216
+ return unless record
217
+
218
+ window = VerifyIt.configuration.rate_limit_window
219
+ Models::IdentifierChange
220
+ .for_record(record)
221
+ .cleanup_old(window)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/time"
4
+
5
+ module VerifyIt
6
+ module Storage
7
+ class MemoryStorage < Base
8
+ def initialize
9
+ @data = {}
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def store_code(identifier:, record:, code:, expires_at:)
14
+ key = build_key(identifier: identifier, record: record, suffix: "code")
15
+ @mutex.synchronize do
16
+ @data[key] = { code: code, expires_at: expires_at }
17
+ end
18
+ end
19
+
20
+ def fetch_code(identifier:, record:)
21
+ key = build_key(identifier: identifier, record: record, suffix: "code")
22
+ @mutex.synchronize do
23
+ data = @data[key]
24
+ return nil unless data
25
+ return nil if data[:expires_at] < Time.now
26
+
27
+ data[:code]
28
+ end
29
+ end
30
+
31
+ def delete_code(identifier:, record:)
32
+ key = build_key(identifier: identifier, record: record, suffix: "code")
33
+ @mutex.synchronize do
34
+ @data.delete(key)
35
+ end
36
+ end
37
+
38
+ def increment_attempts(identifier:, record:)
39
+ key = build_key(identifier: identifier, record: record, suffix: "attempts")
40
+ @mutex.synchronize do
41
+ @data[key] ||= { count: 0, expires_at: Time.now + VerifyIt.configuration.rate_limit_window }
42
+ if @data[key][:expires_at] < Time.now
43
+ @data[key] = { count: 1, expires_at: Time.now + VerifyIt.configuration.rate_limit_window }
44
+ else
45
+ @data[key][:count] += 1
46
+ end
47
+ @data[key][:count]
48
+ end
49
+ end
50
+
51
+ def attempts(identifier:, record:)
52
+ key = build_key(identifier: identifier, record: record, suffix: "attempts")
53
+ @mutex.synchronize do
54
+ data = @data[key]
55
+ return 0 unless data
56
+ return 0 if data[:expires_at] < Time.now
57
+
58
+ data[:count]
59
+ end
60
+ end
61
+
62
+ def reset_attempts(identifier:, record:)
63
+ key = build_key(identifier: identifier, record: record, suffix: "attempts")
64
+ @mutex.synchronize do
65
+ @data.delete(key)
66
+ end
67
+ end
68
+
69
+ def increment_send_count(identifier:, record:)
70
+ key = build_key(identifier: identifier, record: record, suffix: "send_count")
71
+ @mutex.synchronize do
72
+ @data[key] ||= { count: 0, expires_at: Time.now + VerifyIt.configuration.rate_limit_window }
73
+ if @data[key][:expires_at] < Time.now
74
+ @data[key] = { count: 1, expires_at: Time.now + VerifyIt.configuration.rate_limit_window }
75
+ else
76
+ @data[key][:count] += 1
77
+ end
78
+ @data[key][:count]
79
+ end
80
+ end
81
+
82
+ def send_count(identifier:, record:)
83
+ key = build_key(identifier: identifier, record: record, suffix: "send_count")
84
+ @mutex.synchronize do
85
+ data = @data[key]
86
+ return 0 unless data
87
+ return 0 if data[:expires_at] < Time.now
88
+
89
+ data[:count]
90
+ end
91
+ end
92
+
93
+ def reset_send_count(identifier:, record:)
94
+ key = build_key(identifier: identifier, record: record, suffix: "send_count")
95
+ @mutex.synchronize do
96
+ @data.delete(key)
97
+ end
98
+ end
99
+
100
+ def track_identifier_change(record:, identifier:)
101
+ key = build_key(identifier: "", record: record, suffix: "identifier_changes")
102
+ @mutex.synchronize do
103
+ @data[key] ||= []
104
+ @data[key] << { identifier: identifier, timestamp: Time.now }
105
+ # Keep only changes within the rate limit window
106
+ cutoff = Time.now - VerifyIt.configuration.rate_limit_window
107
+ @data[key].select! { |change| change[:timestamp] > cutoff }
108
+ end
109
+ end
110
+
111
+ def identifier_changes(record:)
112
+ key = build_key(identifier: "", record: record, suffix: "identifier_changes")
113
+ @mutex.synchronize do
114
+ changes = @data[key] || []
115
+ cutoff = Time.now - VerifyIt.configuration.rate_limit_window
116
+ changes.select { |change| change[:timestamp] > cutoff }.count
117
+ end
118
+ end
119
+
120
+ def cleanup(identifier:, record:)
121
+ @mutex.synchronize do
122
+ keys_to_delete = @data.keys.select do |key|
123
+ key.include?(identifier.to_s) && (record.nil? || key.include?(record.class.name))
124
+ end
125
+ keys_to_delete.each { |key| @data.delete(key) }
126
+ end
127
+ end
128
+
129
+ def clear_all
130
+ @mutex.synchronize do
131
+ @data.clear
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Storage
5
+ module Models
6
+ class Attempt < ::ActiveRecord::Base
7
+ self.table_name = "verify_it_attempts"
8
+
9
+ validates :identifier, presence: true
10
+ validates :attempt_type, presence: true, inclusion: { in: %w[verification send] }
11
+ validates :count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
12
+ validates :expires_at, presence: true
13
+
14
+ scope :active, -> { where("expires_at > ?", Time.now) }
15
+ scope :expired, -> { where("expires_at <= ?", Time.now) }
16
+ scope :for_identifier, ->(identifier) { where(identifier: identifier) }
17
+ scope :for_record, ->(record) do
18
+ where(record_type: record.class.name, record_id: record.id)
19
+ end
20
+ scope :verification_type, -> { where(attempt_type: "verification") }
21
+ scope :send_type, -> { where(attempt_type: "send") }
22
+
23
+ def expired?
24
+ expires_at <= Time.now
25
+ end
26
+
27
+ def self.cleanup_expired
28
+ expired.delete_all
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Storage
5
+ module Models
6
+ class Code < ::ActiveRecord::Base
7
+ self.table_name = "verify_it_codes"
8
+
9
+ validates :identifier, presence: true
10
+ validates :code, presence: true
11
+ validates :expires_at, presence: true
12
+
13
+ scope :active, -> { where("expires_at > ?", Time.now) }
14
+ scope :expired, -> { where("expires_at <= ?", Time.now) }
15
+ scope :for_identifier, ->(identifier) { where(identifier: identifier) }
16
+ scope :for_record, ->(record) do
17
+ where(record_type: record.class.name, record_id: record.id)
18
+ end
19
+
20
+ def expired?
21
+ expires_at <= Time.now
22
+ end
23
+
24
+ def self.cleanup_expired
25
+ expired.delete_all
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ module Storage
5
+ module Models
6
+ class IdentifierChange < ::ActiveRecord::Base
7
+ self.table_name = "verify_it_identifier_changes"
8
+
9
+ validates :identifier, presence: true
10
+ validates :created_at, presence: true
11
+
12
+ scope :for_record, ->(record) do
13
+ where(record_type: record.class.name, record_id: record.id)
14
+ end
15
+ scope :recent, ->(window) { where("created_at > ?", Time.now - window) }
16
+
17
+ def self.cleanup_old(window)
18
+ where("created_at <= ?", Time.now - window).delete_all
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end