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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +220 -0
- data/Rakefile +31 -0
- data/app/assets/stylesheets/two_step/application.css +259 -0
- data/app/controllers/two_step/application_controller.rb +9 -0
- data/app/controllers/two_step/two_step_challenges_controller.rb +52 -0
- data/app/controllers/two_step/two_step_setups_controller.rb +78 -0
- data/app/helpers/two_step/application_helper.rb +23 -0
- data/app/jobs/two_step/application_job.rb +4 -0
- data/app/mailers/two_step/application_mailer.rb +6 -0
- data/app/models/two_step/application_record.rb +5 -0
- data/app/views/layouts/two_step/application.html.erb +28 -0
- data/app/views/two_step/two_step_challenges/new.html.erb +27 -0
- data/app/views/two_step/two_step_setups/complete.html.erb +20 -0
- data/app/views/two_step/two_step_setups/new.html.erb +28 -0
- data/config/locales/en.yml +39 -0
- data/config/locales/ja.yml +39 -0
- data/config/routes.rb +8 -0
- data/lib/generators/two_step/install_generator.rb +47 -0
- data/lib/generators/two_step/templates/initializer.rb.erb +28 -0
- data/lib/generators/two_step/templates/migration.rb.erb +8 -0
- data/lib/two_step/backup_codes.rb +33 -0
- data/lib/two_step/configuration.rb +129 -0
- data/lib/two_step/engine.rb +15 -0
- data/lib/two_step/models/authenticatable.rb +109 -0
- data/lib/two_step/version.rb +5 -0
- data/lib/two_step.rb +14 -0
- metadata +217 -0
|
@@ -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,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,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
|
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
|