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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifyIt
4
+ class VerificationsController < ApplicationController
5
+ VALID_CHANNELS = %i[sms email].freeze
6
+
7
+ before_action :validate_resolvers_configured!
8
+
9
+ # POST /send { channel: "sms"|"email" }
10
+ def create
11
+ record = resolve_record
12
+ return render json: { error: "Unauthorized" }, status: :unauthorized if record.nil?
13
+
14
+ channel = resolve_channel
15
+ identifier = resolve_identifier(record, channel)
16
+ result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {})
17
+
18
+ if result.success?
19
+ payload = { message: I18n.t("verify_it.responses.sent") }
20
+ payload[:code] = result.code if VerifyIt.configuration.test_mode
21
+ render json: payload, status: :created
22
+ elsif result.rate_limited?
23
+ render json: { error: I18n.t("verify_it.errors.rate_limited") }, status: :too_many_requests
24
+ else
25
+ render json: { error: I18n.t("verify_it.errors.#{result.error || :delivery_failed}") },
26
+ status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ # POST /confirm { channel: "sms"|"email", code: "123456" }
31
+ def verify
32
+ record = resolve_record
33
+ return render json: { error: "Unauthorized" }, status: :unauthorized if record.nil?
34
+
35
+ channel = resolve_channel
36
+ identifier = resolve_identifier(record, channel)
37
+ result = VerifyIt.verify_code(to: identifier, code: params[:code].to_s, record: record)
38
+
39
+ if result.success?
40
+ render json: { message: I18n.t("verify_it.responses.verified") }, status: :ok
41
+ elsif result.locked?
42
+ render json: { error: I18n.t("verify_it.errors.locked") }, status: :too_many_requests
43
+ elsif result.rate_limited?
44
+ render json: { error: I18n.t("verify_it.errors.rate_limited") }, status: :too_many_requests
45
+ else
46
+ render json: { error: I18n.t("verify_it.errors.#{result.error || :invalid_code}") },
47
+ status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def validate_resolvers_configured!
54
+ unless VerifyIt.configuration.current_record_resolver
55
+ raise ConfigurationError, "VerifyIt: configure current_record_resolver to use HTTP endpoints."
56
+ end
57
+ return if VerifyIt.configuration.identifier_resolver
58
+
59
+ raise ConfigurationError, "VerifyIt: configure identifier_resolver to use HTTP endpoints."
60
+ end
61
+
62
+ def resolve_record = VerifyIt.configuration.current_record_resolver.call(request)
63
+
64
+ def resolve_channel
65
+ ch = params[:channel]&.to_sym
66
+ VALID_CHANNELS.include?(ch) ? ch : VerifyIt.configuration.delivery_channel
67
+ end
68
+
69
+ def resolve_identifier(record, channel) = VerifyIt.configuration.identifier_resolver.call(record, channel)
70
+ end
71
+ end
@@ -0,0 +1,16 @@
1
+ en:
2
+ verify_it:
3
+ responses:
4
+ sent: "Verification code sent."
5
+ verified: "Successfully verified."
6
+ errors:
7
+ rate_limited: "Too many attempts. Please try again later."
8
+ invalid_code: "The code you entered is invalid."
9
+ code_not_found: "No active verification code found. Please request a new one."
10
+ locked: "Your account is temporarily locked due to too many failed attempts."
11
+ delivery_failed: "Failed to deliver verification code. Please try again."
12
+ sms:
13
+ default_message: "%{code} is your verification code."
14
+ email:
15
+ default_subject: "Your verification code"
16
+ default_message: "Your verification code is %{code}."
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ VerifyIt::Engine.routes.draw do
4
+ post "send", to: "verifications#create", as: :send_verification
5
+ post "confirm", to: "verifications#verify", as: :confirm_verification
6
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/migration"
5
+
6
+ module VerifyIt
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a VerifyIt initializer, mounts the engine in routes, " \
14
+ "and generates a migration when using database storage."
15
+
16
+ class_option :storage, type: :string, default: "memory",
17
+ desc: "Storage backend to use: memory, redis, or database"
18
+
19
+ def self.next_migration_number(dirname)
20
+ next_num = current_migration_number(dirname) + 1
21
+ ActiveRecord::Migration.next_migration_number(next_num)
22
+ end
23
+
24
+ def create_initializer
25
+ template "initializer.rb.erb", "config/initializers/verify_it.rb"
26
+ end
27
+
28
+ def mount_engine
29
+ route 'mount VerifyIt::Engine, at: "/verify_it"'
30
+ end
31
+
32
+ def generate_migration
33
+ return unless storage == "database"
34
+
35
+ migration_template "create_verify_it_tables.rb",
36
+ "db/migrate/create_verify_it_tables.rb"
37
+ end
38
+
39
+ private
40
+
41
+ def storage
42
+ options[:storage]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,6 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
- class CreateVerifyItTables < ActiveRecord::Migration[7.0]
1
+ class CreateVerifyItTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
2
  def change
5
3
  create_table :verify_it_codes do |t|
6
4
  t.string :identifier, null: false
@@ -1,16 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  VerifyIt.configure do |config|
4
- <% if storage_type == "redis" %>
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 == "redis" -%>
5
9
  config.storage = :redis
6
10
  config.redis_client = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
7
- <% elsif storage_type == "database" %>
11
+ <% elsif storage == "database" -%>
8
12
  config.storage = :database
9
- <% else %>
13
+ <% else -%>
10
14
  # config.storage = :memory # default
11
- <% end %>
15
+ <% end -%>
12
16
 
13
- # Required: configure your delivery channel(s)
17
+ # Required: configure your delivery channel(s).
14
18
  config.email_sender = ->(to:, code:, context:) {
15
19
  # VerificationMailer.send_code(to, code, context).deliver_now
16
20
  }
@@ -19,7 +23,17 @@ VerifyIt.configure do |config|
19
23
  # e.g. Twilio, Vonage, etc.
20
24
  }
21
25
 
22
- # Optional overrides (sensible defaults are set automatically):
26
+ # Required: resolve the current authenticated record from a request.
27
+ config.current_record_resolver = ->(request) {
28
+ # User.find_by(id: request.session[:user_id])
29
+ }
30
+
31
+ # Required: resolve the delivery identifier (phone/email) from the record and channel.
32
+ config.identifier_resolver = ->(record, channel) {
33
+ # channel == :sms ? record.phone_number : record.email
34
+ }
35
+
36
+ # Optional overrides (shown with defaults):
23
37
  # config.code_length = 6
24
38
  # config.code_ttl = 300
25
39
  # config.code_format = :numeric
@@ -16,17 +16,17 @@ module VerifyIt
16
16
  end
17
17
 
18
18
  def self.generate_numeric(length)
19
- Array.new(length) { rand(0..9) }.join
19
+ Array.new(length) { SecureRandom.random_number(10) }.join
20
20
  end
21
21
 
22
22
  def self.generate_alphanumeric(length)
23
23
  chars = ("0".."9").to_a + ("A".."Z").to_a
24
- Array.new(length) { chars.sample }.join
24
+ Array.new(length) { chars.sample(random: SecureRandom) }.join
25
25
  end
26
26
 
27
27
  def self.generate_alpha(length)
28
28
  chars = ("A".."Z").to_a
29
- Array.new(length) { chars.sample }.join
29
+ Array.new(length) { chars.sample(random: SecureRandom) }.join
30
30
  end
31
31
  end
32
32
  end
@@ -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,20 @@ module VerifyIt
19
19
  :on_verify_failure,
20
20
  :namespace,
21
21
  :test_mode,
22
- :bypass_delivery
22
+ :bypass_delivery,
23
+ :secret_key_base,
24
+ :current_record_resolver,
25
+ :identifier_resolver
26
+
27
+ def validate!
28
+ if secret_key_base.nil? || secret_key_base.to_s.strip.empty?
29
+ raise ConfigurationError, "VerifyIt: secret_key_base must be configured before use."
30
+ end
31
+
32
+ return unless test_mode && defined?(Rails) && Rails.env.production?
33
+
34
+ raise ConfigurationError, "VerifyIt: test_mode must not be enabled in production."
35
+ end
23
36
 
24
37
  def initialize
25
38
  # Default values
@@ -41,6 +54,9 @@ module VerifyIt
41
54
  @namespace = nil
42
55
  @test_mode = false
43
56
  @bypass_delivery = false
57
+ @secret_key_base = nil
58
+ @current_record_resolver = nil
59
+ @identifier_resolver = nil
44
60
  end
45
61
  end
46
62
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module VerifyIt
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace VerifyIt
8
+
9
+ initializer "verify_it.i18n" do
10
+ config.i18n.load_path += Dir[Engine.root.join("config", "locales", "**", "*.yml").to_s]
11
+ end
12
+
13
+ initializer "verify_it.active_record" do
14
+ ActiveSupport.on_load(:active_record) do
15
+ include VerifyIt::Verifiable
16
+ end
17
+ end
18
+ end
19
+ end
@@ -5,9 +5,7 @@ module VerifyIt
5
5
  class DatabaseStorage < Base
6
6
  def initialize
7
7
  # Verify ActiveRecord is available
8
- unless defined?(::ActiveRecord)
9
- raise StandardError, "ActiveRecord is required for DatabaseStorage"
10
- end
8
+ raise StandardError, "ActiveRecord is required for DatabaseStorage" unless defined?(::ActiveRecord)
11
9
 
12
10
  # Load models only when needed
13
11
  require_relative "models/code"
@@ -130,10 +128,13 @@ module VerifyIt
130
128
  scope = scope.for_record(record) if record
131
129
  scope.delete_all
132
130
 
133
- # Delete attempts
131
+ # Delete attempts for this identifier
134
132
  scope = Models::Attempt.for_identifier(identifier)
135
133
  scope = scope.for_record(record) if record
136
134
  scope.delete_all
135
+
136
+ # Purge all globally expired attempt records
137
+ Models::Attempt.cleanup_expired
137
138
  end
138
139
 
139
140
  private
@@ -146,8 +147,8 @@ module VerifyIt
146
147
 
147
148
  def find_attempt(identifier:, record:, attempt_type:)
148
149
  scope = Models::Attempt
149
- .for_identifier(identifier)
150
- .where(attempt_type: attempt_type)
150
+ .for_identifier(identifier)
151
+ .where(attempt_type: attempt_type)
151
152
 
152
153
  scope = scope.for_record(record) if record
153
154
  scope.first
@@ -118,10 +118,10 @@ module VerifyIt
118
118
  end
119
119
 
120
120
  def cleanup(identifier:, record:)
121
+ keys_to_delete = %w[code attempts send_count].map do |suffix|
122
+ build_key(identifier: identifier, record: record, suffix: suffix)
123
+ end
121
124
  @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
125
  keys_to_delete.each { |key| @data.delete(key) }
126
126
  end
127
127
  end
@@ -14,9 +14,9 @@ module VerifyIt
14
14
  scope :active, -> { where("expires_at > ?", Time.now) }
15
15
  scope :expired, -> { where("expires_at <= ?", Time.now) }
16
16
  scope :for_identifier, ->(identifier) { where(identifier: identifier) }
17
- scope :for_record, ->(record) do
17
+ scope :for_record, lambda { |record|
18
18
  where(record_type: record.class.name, record_id: record.id)
19
- end
19
+ }
20
20
  scope :verification_type, -> { where(attempt_type: "verification") }
21
21
  scope :send_type, -> { where(attempt_type: "send") }
22
22
 
@@ -13,9 +13,9 @@ module VerifyIt
13
13
  scope :active, -> { where("expires_at > ?", Time.now) }
14
14
  scope :expired, -> { where("expires_at <= ?", Time.now) }
15
15
  scope :for_identifier, ->(identifier) { where(identifier: identifier) }
16
- scope :for_record, ->(record) do
16
+ scope :for_record, lambda { |record|
17
17
  where(record_type: record.class.name, record_id: record.id)
18
- end
18
+ }
19
19
 
20
20
  def expired?
21
21
  expires_at <= Time.now
@@ -9,9 +9,9 @@ module VerifyIt
9
9
  validates :identifier, presence: true
10
10
  validates :created_at, presence: true
11
11
 
12
- scope :for_record, ->(record) do
12
+ scope :for_record, lambda { |record|
13
13
  where(record_type: record.class.name, record_id: record.id)
14
- end
14
+ }
15
15
  scope :recent, ->(window) { where("created_at > ?", Time.now - window) }
16
16
 
17
17
  def self.cleanup_old(window)
@@ -18,9 +18,7 @@ module VerifyIt
18
18
  verify_method = "verify_#{channel}_code"
19
19
  cleanup_method = "cleanup_#{channel}_verification"
20
20
 
21
- if method_defined?(send_method)
22
- raise ArgumentError, "Method #{send_method} is already defined on #{name}"
23
- end
21
+ raise ArgumentError, "Method #{send_method} is already defined on #{name}" if method_defined?(send_method)
24
22
 
25
23
  define_method(send_method) do |context: {}|
26
24
  identifier = send(attribute)