two_step 1.0.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,23 @@
1
+ module TwoStep
2
+ module ApplicationHelper
3
+ def two_step_layout_title
4
+ TwoStep.configuration.resolve_layout_title(controller: controller)
5
+ end
6
+
7
+ def two_step_layout_stylesheets
8
+ TwoStep.configuration.resolve_layout_stylesheets(controller: controller)
9
+ end
10
+
11
+ def two_step_layout_html_attributes
12
+ TwoStep.configuration.resolve_layout_html_attributes(controller: controller)
13
+ end
14
+
15
+ def two_step_layout_body_attributes
16
+ TwoStep.configuration.resolve_layout_body_attributes(controller: controller)
17
+ end
18
+
19
+ def two_step_layout_brand
20
+ TwoStep.configuration.resolve_layout_brand(controller: controller)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ module TwoStep
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module TwoStep
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module TwoStep
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,28 @@
1
+ <!DOCTYPE html>
2
+ <html <%= tag.attributes(two_step_layout_html_attributes) %>>
3
+ <head>
4
+ <title><%= two_step_layout_title %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= yield :head %>
10
+
11
+ <% two_step_layout_stylesheets.each do |stylesheet| %>
12
+ <%= stylesheet_link_tag stylesheet, media: "all" %>
13
+ <% end %>
14
+ </head>
15
+ <body <%= tag.attributes(two_step_layout_body_attributes) %>>
16
+ <div class="two_step-shell__mesh" aria-hidden="true"></div>
17
+ <main class="two_step-card">
18
+ <% if two_step_layout_brand.present? %>
19
+ <p class="two_step-brand"><%= two_step_layout_brand %></p>
20
+ <% end %>
21
+ <% flash.each do |type, message| %>
22
+ <p class="two_step-flash two_step-flash--<%= type %>"><%= message %></p>
23
+ <% end %>
24
+
25
+ <%= yield %>
26
+ </main>
27
+ </body>
28
+ </html>
@@ -0,0 +1,27 @@
1
+ <header class="two_step-header">
2
+ <p class="two_step-kicker"><%= t("two_step.views.challenge.kicker", default: "Security check") %></p>
3
+ <h1><%= t("two_step.views.challenge.title", default: "Two-Factor Authentication") %></h1>
4
+ <p class="two_step-copy"><%= t("two_step.views.challenge.subtitle", default: "Enter the 6-digit code from your authenticator app to continue.") %></p>
5
+ </header>
6
+
7
+ <%= form_with url: two_step_challenge_path, method: :post, class: "two_step-form" do |form| %>
8
+ <div class="two_step-field">
9
+ <%= form.label :otp_code, t("two_step.views.challenge.otp_label", default: "Authenticator code") %>
10
+ <%= form.text_field :otp_code, autocomplete: "one-time-code", inputmode: "numeric", maxlength: 6, pattern: "\\d{6}", placeholder: "123456", autofocus: true %>
11
+ <p class="two_step-field-note"><%= t("two_step.views.challenge.otp_help", default: "Codes refresh every 30 seconds.") %></p>
12
+ </div>
13
+ <%= form.submit t("two_step.views.challenge.submit", default: "Verify code") %>
14
+ <% end %>
15
+
16
+ <details class="two_step-details">
17
+ <summary><%= t("two_step.views.challenge.use_backup", default: "Use a backup code instead") %></summary>
18
+ <p class="two_step-copy"><%= t("two_step.views.challenge.backup_help", default: "Backup codes are one-time use. Enter one exactly as saved.") %></p>
19
+
20
+ <%= form_with url: two_step_challenge_path, method: :post, class: "two_step-form" do |form| %>
21
+ <div class="two_step-field">
22
+ <%= form.label :backup_code, t("two_step.views.challenge.backup_label", default: "Backup code") %>
23
+ <%= form.text_field :backup_code, autocomplete: "one-time-code", placeholder: "ABC-123-DEF-456" %>
24
+ </div>
25
+ <%= form.submit t("two_step.views.challenge.submit_backup", default: "Verify backup code") %>
26
+ <% end %>
27
+ </details>
@@ -0,0 +1,20 @@
1
+ <header class="two_step-header">
2
+ <p class="two_step-kicker"><%= t("two_step.views.complete.kicker", default: "Setup complete") %></p>
3
+ <h1><%= t("two_step.views.complete.title") %></h1>
4
+ <p class="two_step-copy"><%= t("two_step.views.complete.backup_codes") %></p>
5
+ </header>
6
+
7
+ <div class="two_step-warning">
8
+ <strong><%= t("two_step.views.complete.warning_title", default: "Save these now") %></strong>
9
+ <p><%= t("two_step.views.complete.warning_body", default: "Each backup code can be used only once. Keep them in a password manager or another secure place.") %></p>
10
+ </div>
11
+
12
+ <ul class="two_step-backup-codes">
13
+ <% @backup_codes.each do |code| %>
14
+ <li><code><%= code %></code></li>
15
+ <% end %>
16
+ </ul>
17
+
18
+ <p class="two_step-actions">
19
+ <%= link_to t("two_step.views.complete.done"), TwoStep.configuration.resolve_after_two_step_login_path(setup_resource, controller: controller), class: "two_step-link-button" %>
20
+ </p>
@@ -0,0 +1,28 @@
1
+ <header class="two_step-header">
2
+ <p class="two_step-kicker"><%= t("two_step.views.setup.kicker", default: "Security setup") %></p>
3
+ <h1><%= t("two_step.views.setup.title") %></h1>
4
+ <p class="two_step-copy"><%= t("two_step.views.setup.subtitle", default: "Protect your account with one more verification step.") %></p>
5
+ <% if setup_requires_login_challenge? %>
6
+ <p class="two_step-badge"><%= t("two_step.views.setup.required_now", default: "Required before this sign-in can continue") %></p>
7
+ <% end %>
8
+ </header>
9
+
10
+ <ol class="two_step-steps">
11
+ <li><%= t("two_step.views.setup.scan_qr") %></li>
12
+ <li><%= t("two_step.views.setup.manual_fallback", default: "If scanning fails, enter the setup key manually.") %></li>
13
+ <li><%= t("two_step.views.setup.confirm_label") %></li>
14
+ </ol>
15
+
16
+ <div class="two_step-qr" role="img" aria-label="<%= t("two_step.views.setup.qr_label", default: "QR code for authenticator setup") %>">
17
+ <%= @qr_svg %>
18
+ </div>
19
+ <p class="two_step-manual-key"><%= t("two_step.views.setup.manual_key") %> <code><%= setup_resource.otp_secret %></code></p>
20
+
21
+ <%= form_with url: two_step_setup_path, method: :post, class: "two_step-form" do |form| %>
22
+ <div class="two_step-field">
23
+ <%= form.label :otp_code, t("two_step.views.setup.confirm_label") %>
24
+ <%= form.text_field :otp_code, autocomplete: "one-time-code", inputmode: "numeric", maxlength: 6, pattern: "\\d{6}", placeholder: "123456" %>
25
+ <p class="two_step-field-note"><%= t("two_step.views.setup.confirm_help", default: "Open your authenticator app and enter the latest 6-digit code.") %></p>
26
+ </div>
27
+ <%= form.submit t("two_step.views.setup.enable") %>
28
+ <% end %>
@@ -0,0 +1,39 @@
1
+ en:
2
+ two_step:
3
+ challenges:
4
+ invalid_otp: "Invalid two-factor code."
5
+ invalid_backup: "Invalid backup code."
6
+ setups:
7
+ invalid_otp: "Invalid code. Please try again."
8
+ disabled: "TwoStep disabled."
9
+ views:
10
+ challenge:
11
+ kicker: "Security check"
12
+ title: "Two-Factor Authentication"
13
+ subtitle: "Enter the 6-digit code from your authenticator app to continue."
14
+ otp_label: "Authenticator code"
15
+ otp_help: "Codes refresh every 30 seconds."
16
+ submit: "Verify code"
17
+ use_backup: "Use a backup code instead"
18
+ backup_label: "Backup code"
19
+ backup_help: "Backup codes are one-time use. Enter one exactly as saved."
20
+ submit_backup: "Verify backup code"
21
+ setup:
22
+ kicker: "Security setup"
23
+ title: "Set Up Two-Factor Authentication"
24
+ subtitle: "Protect your account with one more verification step."
25
+ required_now: "Required before this sign-in can continue"
26
+ scan_qr: "Scan this QR code with your authenticator app:"
27
+ manual_fallback: "If scanning fails, enter the setup key manually."
28
+ qr_label: "QR code for authenticator setup"
29
+ manual_key: "Or enter this key manually:"
30
+ confirm_label: "Enter the 6-digit code to confirm"
31
+ confirm_help: "Open your authenticator app and enter the latest 6-digit code."
32
+ enable: "Enable"
33
+ complete:
34
+ kicker: "Setup complete"
35
+ title: "Two-Factor Authentication Enabled"
36
+ backup_codes: "Save these backup codes in a secure place. Each can be used once."
37
+ warning_title: "Save these now"
38
+ warning_body: "Each backup code can be used only once. Keep them in a password manager or another secure place."
39
+ done: "Continue"
@@ -0,0 +1,39 @@
1
+ ja:
2
+ two_step:
3
+ challenges:
4
+ invalid_otp: "2段階認証コードが正しくありません。"
5
+ invalid_backup: "バックアップコードが正しくありません。"
6
+ setups:
7
+ invalid_otp: "コードが正しくありません。もう一度お試しください。"
8
+ disabled: "2段階認証を無効にしました。"
9
+ views:
10
+ challenge:
11
+ kicker: "セキュリティ確認"
12
+ title: "2段階認証"
13
+ subtitle: "続行するには、認証アプリに表示される6桁コードを入力してください。"
14
+ otp_label: "認証コード"
15
+ otp_help: "コードは30秒ごとに更新されます。"
16
+ submit: "コードを確認"
17
+ use_backup: "バックアップコードを使う"
18
+ backup_label: "バックアップコード"
19
+ backup_help: "バックアップコードは1回限り有効です。保存した形式で入力してください。"
20
+ submit_backup: "バックアップコードを確認"
21
+ setup:
22
+ kicker: "セキュリティ設定"
23
+ title: "2段階認証を設定"
24
+ subtitle: "もう1つの確認ステップでアカウントを保護します。"
25
+ required_now: "このサインインを続行するには設定が必要です"
26
+ scan_qr: "認証アプリでこのQRコードを読み取ってください:"
27
+ manual_fallback: "読み取れない場合は、セットアップキーを手動で入力してください。"
28
+ qr_label: "認証アプリ設定用のQRコード"
29
+ manual_key: "手動入力キー:"
30
+ confirm_label: "確認のため6桁コードを入力"
31
+ confirm_help: "認証アプリを開き、最新の6桁コードを入力してください。"
32
+ enable: "有効化する"
33
+ complete:
34
+ kicker: "設定完了"
35
+ title: "2段階認証を有効にしました"
36
+ backup_codes: "バックアップコードを安全な場所に保管してください。各コードは1回だけ使えます。"
37
+ warning_title: "今すぐ保存してください"
38
+ warning_body: "各バックアップコードは1回のみ使用可能です。パスワードマネージャーなど安全な場所に保管してください。"
39
+ done: "続行"
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ TwoStep::Engine.routes.draw do
4
+ resource :two_step_challenge, only: %i[new create], path: "challenge"
5
+ resource :two_step_setup, only: %i[new create], path: "setup" do
6
+ post :disable, on: :member
7
+ end
8
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module TwoStep
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :model, type: :string, default: "User", desc: "Model name (e.g. User, Admin)"
12
+
13
+ desc "Adds TwoStep columns and initializer"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "migration.rb.erb",
18
+ "db/migrate/add_two_step_to_#{table_name}.rb",
19
+ migration_version: migration_version
20
+ )
21
+ end
22
+
23
+ def create_initializer
24
+ template "initializer.rb.erb", "config/initializers/two_step.rb"
25
+ end
26
+
27
+ def show_readme
28
+ say "Add to your #{model} model:", :green
29
+ say " include TwoStep::Models::Authenticatable"
30
+ say " encrypts :otp_secret # recommended (Rails 7+)"
31
+ end
32
+
33
+ private
34
+
35
+ def table_name
36
+ model.underscore.pluralize
37
+ end
38
+
39
+ def model
40
+ options[:model]
41
+ end
42
+
43
+ def migration_version
44
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ TwoStep.configure do |config|
4
+ config.issuer = "<%= Rails.application.class.module_parent_name %>"
5
+
6
+ config.resource_finder = ->(session) {
7
+ <%= model %>.find_by(id: session[:two_step_pending_<%= model.underscore %>_id])
8
+ }
9
+
10
+ config.current_resource_finder = ->(session) {
11
+ <%= model %>.find_by(id: session[:<%= model.underscore %>_id])
12
+ }
13
+
14
+ config.login_path = "/login"
15
+ config.after_two_step_login_path = "/"
16
+ config.layout_title = -> { "#{config.issuer} Security" }
17
+ config.layout_stylesheets = ["two_step/application"]
18
+ config.layout_html_attributes = -> { {lang: I18n.locale} }
19
+ config.layout_body_attributes = {class: "two_step-shell"}
20
+ config.layout_brand = config.issuer
21
+
22
+ # The controller is passed as the third argument when you need route helpers
23
+ # or `reset_session` for session fixation protection.
24
+ config.on_authentication_success = ->(resource, session, _controller) {
25
+ session.delete(:two_step_pending_<%= model.underscore %>_id)
26
+ session[:<%= model.underscore %>_id] = resource.id
27
+ }
28
+ end
@@ -0,0 +1,8 @@
1
+ class AddTwoStepTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :<%= table_name %>, :otp_secret, :string
4
+ add_column :<%= table_name %>, :otp_required_for_login, :boolean, default: false, null: false
5
+ add_column :<%= table_name %>, :otp_backup_codes, :text
6
+ add_column :<%= table_name %>, :last_otp_at, :integer
7
+ end
8
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TwoStep
6
+ module BackupCodes
7
+ extend self
8
+
9
+ CHARSET = (("A".."Z").to_a + ("2".."9").to_a - %w[I L O]).freeze
10
+
11
+ # 5 segments (15 chars total) to provide ~74 bits of entropy.
12
+ SEGMENT_COUNT = 5
13
+ SEGMENT_LENGTH = 3
14
+
15
+ def generate_one
16
+ SEGMENT_COUNT.times.map { random_segment }.join("-")
17
+ end
18
+
19
+ def generate(count: TwoStep.configuration.backup_code_count)
20
+ Array.new(count) { generate_one }
21
+ end
22
+
23
+ def normalize(code)
24
+ code.to_s.strip.upcase.gsub(/[^A-Z2-9]/, "")
25
+ end
26
+
27
+ private
28
+
29
+ def random_segment
30
+ SEGMENT_LENGTH.times.map { CHARSET.sample(random: SecureRandom) }.join
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module TwoStep
6
+ class Configuration
7
+ attr_accessor :issuer,
8
+ :backup_code_count,
9
+ :qr_code_module_size,
10
+ :otp_drift_behind,
11
+ :otp_drift_ahead,
12
+ :resource_finder,
13
+ :current_resource_finder,
14
+ :login_path,
15
+ :after_two_step_login_path,
16
+ :on_authentication_success,
17
+ :backup_code_digest_method,
18
+ :backup_code_verify_method,
19
+ :layout_title,
20
+ :layout_stylesheets,
21
+ :layout_html_attributes,
22
+ :layout_body_attributes,
23
+ :layout_brand
24
+
25
+ def initialize
26
+ @issuer = "Rails App"
27
+ @backup_code_count = 10
28
+ @qr_code_module_size = 4
29
+ @otp_drift_behind = 30
30
+ @otp_drift_ahead = 30
31
+ @resource_finder = ->(*) {}
32
+ @current_resource_finder = ->(*) {}
33
+ @login_path = "/"
34
+ @after_two_step_login_path = "/"
35
+ @on_authentication_success = ->(*) {}
36
+ @layout_title = -> { "#{issuer} Security" }
37
+ @layout_stylesheets = ["two_step/application"]
38
+ @layout_html_attributes = -> { {lang: I18n.locale} }
39
+ @layout_body_attributes = {class: "two_step-shell"}
40
+ @layout_brand = -> { issuer }
41
+
42
+ # Switched to SHA256 for O(1) performance during generation.
43
+ # Because the backup codes are 15 characters of high-entropy randomness,
44
+ # a slow-hash like BCrypt is unnecessary and harms user experience.
45
+ @backup_code_digest_method = ->(normalized_code) {
46
+ Digest::SHA256.hexdigest(normalized_code)
47
+ }
48
+ @backup_code_verify_method = ->(normalized_code, hashed) {
49
+ Rack::Utils.secure_compare(Digest::SHA256.hexdigest(normalized_code), hashed)
50
+ }
51
+ end
52
+
53
+ def find_pending_resource(session, controller: nil)
54
+ resolve_callable(resource_finder, session, controller)
55
+ end
56
+
57
+ def find_current_resource(session, controller: nil)
58
+ resolve_callable(current_resource_finder, session, controller)
59
+ end
60
+
61
+ def resolve_login_path(controller: nil)
62
+ resolve_callable(login_path, controller)
63
+ end
64
+
65
+ def resolve_after_two_step_login_path(resource = nil, controller: nil)
66
+ resolve_callable(after_two_step_login_path, resource, controller)
67
+ end
68
+
69
+ def run_authentication_success(resource, session, controller: nil)
70
+ resolve_callable(on_authentication_success, resource, session, controller)
71
+ end
72
+
73
+ def resolve_layout_title(controller: nil)
74
+ resolve_callable(layout_title, controller)
75
+ end
76
+
77
+ def resolve_layout_stylesheets(controller: nil)
78
+ Array(resolve_callable(layout_stylesheets, controller)).flatten.compact
79
+ end
80
+
81
+ def resolve_layout_html_attributes(controller: nil)
82
+ resolve_hash(resolve_callable(layout_html_attributes, controller))
83
+ end
84
+
85
+ def resolve_layout_body_attributes(controller: nil)
86
+ resolve_hash(resolve_callable(layout_body_attributes, controller))
87
+ end
88
+
89
+ def resolve_layout_brand(controller: nil)
90
+ resolve_callable(layout_brand, controller)
91
+ end
92
+
93
+ private
94
+
95
+ def resolve_callable(value, *args)
96
+ return value unless value.respond_to?(:call)
97
+
98
+ # Simplified and safer arity handling
99
+ arity = value.arity
100
+ if arity >= 0
101
+ value.call(*args.take(arity))
102
+ else
103
+ # For variable length (*args) or unspecified blocks, pass everything
104
+ value.call(*args)
105
+ end
106
+ end
107
+
108
+ def resolve_hash(value)
109
+ value.to_h
110
+ rescue
111
+ {}
112
+ end
113
+ end
114
+
115
+ class << self
116
+ def configuration
117
+ @configuration ||= Configuration.new
118
+ end
119
+
120
+ def configure
121
+ yield(configuration) if block_given?
122
+ configuration
123
+ end
124
+
125
+ def reset_configuration!
126
+ @configuration = Configuration.new
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoStep
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace TwoStep
6
+
7
+ config.generators do |g|
8
+ g.test_framework :test_unit
9
+ end
10
+
11
+ initializer "two_step.load_configuration", before: :load_config_initializers do
12
+ # Host app configures via config/initializers/two_step.rb
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "rotp"
5
+
6
+ module TwoStep
7
+ module Models
8
+ module Authenticatable
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ serialize :otp_backup_codes, coder: JSON
13
+ end
14
+
15
+ def otp_enabled?
16
+ otp_required_for_login? && otp_secret.present?
17
+ end
18
+
19
+ def generate_otp_secret!
20
+ # update_columns avoids instantiation overhead and bypasses unrelated model validations
21
+ update_columns(otp_secret: ROTP::Base32.random, last_otp_at: nil)
22
+ end
23
+
24
+ def ensure_otp_secret!
25
+ return otp_secret if otp_secret.present?
26
+
27
+ generate_otp_secret!
28
+ otp_secret
29
+ end
30
+
31
+ def otp_provisioning_uri(account_label = nil)
32
+ raise ArgumentError, "otp_secret is blank" if otp_secret.blank?
33
+
34
+ label = (account_label || (respond_to?(:email) ? email : id)).to_s
35
+ build_totp.provisioning_uri(label)
36
+ end
37
+
38
+ def current_otp
39
+ build_totp.now if otp_secret.present?
40
+ end
41
+
42
+ def verify_otp(code)
43
+ return false if otp_secret.blank? || code.blank?
44
+
45
+ timestamp = verified_otp_timestamp(code)
46
+ return false unless timestamp
47
+ return false if last_otp_at.present? && last_otp_at >= timestamp
48
+
49
+ update_column(:last_otp_at, timestamp)
50
+ true
51
+ end
52
+
53
+ def generate_backup_codes!
54
+ codes = BackupCodes.generate
55
+ hashed_codes = codes.map { |plain| digest_backup_code(plain) }
56
+
57
+ update_columns(otp_backup_codes: hashed_codes)
58
+ codes
59
+ end
60
+
61
+ def consume_backup_code(code)
62
+ normalized = BackupCodes.normalize(code)
63
+ return false if normalized.blank?
64
+
65
+ # Stored codes are automatically parsed as an Array via `serialize`
66
+ stored = Array(otp_backup_codes)
67
+ return false if stored.empty?
68
+
69
+ index = stored.index { |hashed| backup_code_matches?(normalized, hashed) }
70
+ return false unless index
71
+
72
+ stored.delete_at(index)
73
+ update_columns(otp_backup_codes: stored.empty? ? nil : stored)
74
+ true
75
+ end
76
+
77
+ def disable_otp!
78
+ update_columns(
79
+ otp_secret: nil,
80
+ otp_required_for_login: false,
81
+ otp_backup_codes: nil,
82
+ last_otp_at: nil
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ def build_totp
89
+ ROTP::TOTP.new(otp_secret, issuer: TwoStep.configuration.issuer)
90
+ end
91
+
92
+ def verified_otp_timestamp(code)
93
+ build_totp.verify(
94
+ code.to_s.strip,
95
+ drift_behind: TwoStep.configuration.otp_drift_behind,
96
+ drift_ahead: TwoStep.configuration.otp_drift_ahead
97
+ )
98
+ end
99
+
100
+ def digest_backup_code(code)
101
+ TwoStep.configuration.backup_code_digest_method.call(BackupCodes.normalize(code))
102
+ end
103
+
104
+ def backup_code_matches?(normalized_code, hashed_code)
105
+ TwoStep.configuration.backup_code_verify_method.call(normalized_code, hashed_code)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoStep
4
+ VERSION = "1.0.0"
5
+ end
data/lib/two_step.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "rotp"
5
+ require "rqrcode"
6
+ require "bcrypt"
7
+ require "two_step/version"
8
+ require "two_step/configuration"
9
+ require "two_step/backup_codes"
10
+ require "two_step/engine"
11
+ require "two_step/models/authenticatable"
12
+
13
+ module TwoStep
14
+ end