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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 37a12a608ceb35e05197580c831770a73bf341a148f811aab06cdb22e42fc00b
4
+ data.tar.gz: c6b73ff378950bde63adafc2f1166ddabf58d8209714043cdcacd58ad2a66a7e
5
+ SHA512:
6
+ metadata.gz: 5d56b0ef8afc9d883bf542009ea17ccd05c0ff982d2b86cff90e7c3eae94917c5ab61953f060e84934bae59d95aa7ce382f7fcc1333efa5482513d40daf9d99e
7
+ data.tar.gz: 983f9d818207b64651b4f4432c02a3b323a0b9f99f7cab4552014b1e2df3235d73a40c8e5481bf708b7745427555e4fdb211d69c605e0aeca35de642a151c47c
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] - 2026-05-23
4
+
5
+ Initial release.
6
+
7
+ ### Added
8
+
9
+ - Mountable Rails engine for TOTP-based multi-factor authentication in session-based applications
10
+ - `TwoStep::Models::Authenticatable` concern for OTP secrets, provisioning URIs, replay protection, backup code generation, and backup code consumption
11
+ - TwoStep setup and challenge flows with QR-code enrollment, manual setup key fallback, TOTP verification, and one-time backup code support
12
+ - Install generator that creates the initializer and migration for `otp_secret`, `otp_required_for_login`, `otp_backup_codes`, and `last_otp_at`
13
+ - Configurable host-app integration hooks for pending resource lookup, current resource lookup, redirects, session completion, and layout metadata
14
+ - Built-in engine views, styles, routes, and English/Japanese translations
15
+
16
+ ### Security
17
+
18
+ - Backup codes stored as digests by default and verified with constant-time comparison
19
+ - Replay protection for TOTP codes via `last_otp_at`
20
+ - Safe relative-path handling for setup disable redirects
21
+ - Support for encrypted `otp_secret` storage in host applications
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # TwoStep
2
+
3
+ `two_step` is a mountable Rails engine that adds TOTP-based multi-factor authentication to session-based Rails apps. It stays out of the password step, so it works with custom authentication flows as well as libraries such as Clearance or Sorcery.
4
+
5
+ ## Features
6
+
7
+ - TOTP verification compatible with Google Authenticator, 1Password, Authy, and similar apps
8
+ - QR-code enrollment with a manual setup key fallback
9
+ - One-time backup codes in the format `XXX-XXX-XXX-XXX-XXX`
10
+ - SHA-256 backup code digests by default, with configurable digest and verify hooks
11
+ - Replay protection through `last_otp_at`
12
+ - Mountable engine with isolated controllers, routes, views, locales, and assets
13
+ - Built-in English and Japanese copy plus a branded default UI
14
+ - Host-application hooks for resource lookup, redirects, layout metadata, and post-TwoStep session handling
15
+
16
+ ## Requirements
17
+
18
+ - Ruby `>= 3.2`
19
+ - Rails `>= 7.1`
20
+
21
+ ## Installation
22
+
23
+ Add the gem and install:
24
+
25
+ ```ruby
26
+ gem "two_step"
27
+ ```
28
+
29
+ ```bash
30
+ bundle install
31
+ bin/rails generate two_step:install
32
+ bin/rails db:migrate
33
+ ```
34
+
35
+ If you use a model other than `User`, pass it to the generator:
36
+
37
+ ```bash
38
+ bin/rails generate two_step:install --model Admin
39
+ ```
40
+
41
+ Include the concern in your authenticatable model:
42
+
43
+ ```ruby
44
+ class User < ApplicationRecord
45
+ include TwoStep::Models::Authenticatable
46
+ encrypts :otp_secret
47
+ end
48
+ ```
49
+
50
+ `encrypts :otp_secret` is recommended on Rails 7+ so the shared secret is not stored in plaintext.
51
+
52
+ Mount the engine:
53
+
54
+ ```ruby
55
+ Rails.application.routes.draw do
56
+ mount TwoStep::Engine => "/two_step"
57
+ end
58
+ ```
59
+
60
+ The generator creates `config/initializers/two_step.rb` and a migration that adds:
61
+
62
+ - `otp_secret`
63
+ - `otp_required_for_login`
64
+ - `otp_backup_codes`
65
+ - `last_otp_at`
66
+
67
+ ## Quick Start
68
+
69
+ The host app handles the password step first and redirects into the engine only when TwoStep is required.
70
+
71
+ ```ruby
72
+ class SessionsController < ApplicationController
73
+ def create
74
+ user = User.authenticate_by(email: params[:email], password: params[:password])
75
+
76
+ if user&.otp_enabled?
77
+ reset_session
78
+ session[:two_step_pending_user_id] = user.id
79
+ redirect_to two_step.new_two_step_challenge_path
80
+ elsif user
81
+ reset_session
82
+ session[:user_id] = user.id
83
+ redirect_to dashboard_path
84
+ else
85
+ flash.now[:alert] = "Invalid email or password"
86
+ render :new, status: :unprocessable_entity
87
+ end
88
+ end
89
+ end
90
+ ```
91
+
92
+ The setup screen can be used from an already signed-in security settings page or from a pending-login flow. When setup succeeds for a pending-login user, the engine runs your `on_authentication_success` hook immediately.
93
+
94
+ ## Routes
95
+
96
+ | Route | Purpose |
97
+ | --- | --- |
98
+ | `GET /two_step/setup/new` | Show the QR code, manual key, and enrollment form |
99
+ | `POST /two_step/setup` | Verify the first TOTP code, enable TwoStep, and reveal backup codes |
100
+ | `POST /two_step/setup/disable` | Disable TwoStep and clear secrets, backup codes, and replay state |
101
+ | `GET /two_step/challenge/new` | Prompt for a TOTP code or backup code |
102
+ | `POST /two_step/challenge` | Complete the TwoStep challenge |
103
+
104
+ `POST /two_step/setup/disable` also accepts an optional `return_to` parameter, but only relative paths beginning with `/` are honored.
105
+
106
+ ## Configuration
107
+
108
+ The initializer is the public integration contract between the engine and your app:
109
+
110
+ ```ruby
111
+ TwoStep.configure do |config|
112
+ config.issuer = "MyApp"
113
+ config.backup_code_count = 10
114
+ config.qr_code_module_size = 4
115
+ config.otp_drift_behind = 30
116
+ config.otp_drift_ahead = 30
117
+
118
+ config.resource_finder = ->(session) {
119
+ User.find_by(id: session[:two_step_pending_user_id])
120
+ }
121
+
122
+ config.current_resource_finder = ->(session) {
123
+ User.find_by(id: session[:user_id])
124
+ }
125
+
126
+ config.login_path = "/login"
127
+ config.after_two_step_login_path = "/"
128
+
129
+ config.on_authentication_success = ->(resource, session, _controller) {
130
+ session.delete(:two_step_pending_user_id)
131
+ session[:user_id] = resource.id
132
+ }
133
+
134
+ config.layout_title = -> { "#{config.issuer} Security" }
135
+ config.layout_stylesheets = ["two_step/application"]
136
+ config.layout_html_attributes = -> { {lang: I18n.locale} }
137
+ config.layout_body_attributes = {class: "two_step-shell"}
138
+ config.layout_brand = -> { config.issuer }
139
+ end
140
+ ```
141
+
142
+ Notes:
143
+
144
+ - `resource_finder` and `current_resource_finder` may accept either `session` alone or `session, controller`.
145
+ - `login_path` can be a string or a callable that receives `controller`.
146
+ - `after_two_step_login_path` can be a string or a callable that receives `resource, controller`.
147
+ - `on_authentication_success` can accept `resource, session` or `resource, session, controller`.
148
+ - `layout_title`, `layout_stylesheets`, `layout_html_attributes`, `layout_body_attributes`, and `layout_brand` can be plain values or callables that receive `controller`.
149
+
150
+ Example using controller-aware hooks:
151
+
152
+ ```ruby
153
+ TwoStep.configure do |config|
154
+ config.login_path = ->(controller) { controller.main_app.login_path }
155
+ config.after_two_step_login_path = ->(_resource, controller) { controller.main_app.dashboard_path }
156
+
157
+ config.on_authentication_success = lambda do |resource, _session, controller|
158
+ controller.reset_session
159
+ controller.session[:user_id] = resource.id
160
+ end
161
+
162
+ config.layout_stylesheets = ["two_step/application", "two_step/host"]
163
+ config.layout_body_attributes = ->(controller) {
164
+ {class: "two_step-shell", data: {screen: controller.action_name}}
165
+ }
166
+ end
167
+ ```
168
+
169
+ ## Backup Codes
170
+
171
+ Generated backup codes use uppercase letters and digits `2-9`, excluding ambiguous characters such as `I`, `L`, and `O`. Users can enter them with or without separators.
172
+
173
+ By default, the engine stores backup codes as SHA-256 digests and verifies them with a constant-time comparison:
174
+
175
+ ```ruby
176
+ config.backup_code_digest_method = ->(normalized_code) {
177
+ Digest::SHA256.hexdigest(normalized_code)
178
+ }
179
+
180
+ config.backup_code_verify_method = ->(normalized_code, hashed_code) {
181
+ Rack::Utils.secure_compare(Digest::SHA256.hexdigest(normalized_code), hashed_code)
182
+ }
183
+ ```
184
+
185
+ You can replace both hooks if your application needs a different storage strategy.
186
+
187
+ ## Security Notes
188
+
189
+ - Encrypt `otp_secret` when your app supports Active Record encryption.
190
+ - Rotate the session after password authentication, and optionally again after TwoStep completes.
191
+ - Rate-limit both password and TwoStep endpoints in the host application.
192
+ - Treat backup codes like passwords: display them once, store them hashed, and never log them.
193
+ - The setup screen preserves an existing secret until TwoStep is explicitly disabled, which avoids breaking a user's authenticator app on refresh.
194
+
195
+ ## Customization
196
+
197
+ - Override engine views by copying templates from `app/views/two_step/...` into the host app.
198
+ - Add host stylesheets and list them in `config.layout_stylesheets`.
199
+ - Customize page title, brand label, HTML attributes, and body attributes through the layout hooks.
200
+ - Use the controller-aware callbacks when you need host route helpers or custom session behavior.
201
+ - Switch locales through normal Rails I18n handling; the engine ships with English and Japanese translations.
202
+
203
+ ## Development
204
+
205
+ This repository uses [Standard](https://github.com/standardrb/standard) with `standard-rails`.
206
+
207
+ ```bash
208
+ bin/setup
209
+ bin/lint
210
+ bin/test
211
+ bundle exec rake coverage
212
+ docker compose build test
213
+ docker compose run --rm test
214
+ ```
215
+
216
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [SECURITY.md](SECURITY.md) for responsible disclosure.
217
+
218
+ ## License
219
+
220
+ MIT. See [MIT-LICENSE](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+
10
+ desc "Run the engine test suite"
11
+ task :test do
12
+ if ENV["COVERAGE"]
13
+ Rake::Task["coverage"].invoke
14
+ else
15
+ Rake::Task["app:test"].invoke
16
+ end
17
+ end
18
+
19
+ desc "Run tests with SimpleCov (100% threshold)"
20
+ task :coverage do
21
+ ENV["COVERAGE"] = "1"
22
+
23
+ sh(
24
+ "bundle",
25
+ "exec",
26
+ "ruby",
27
+ "-Itest",
28
+ "-e",
29
+ 'Dir["test/**/*_test.rb"].sort.each { |file| require File.expand_path(file) }'
30
+ )
31
+ end
@@ -0,0 +1,259 @@
1
+ /*
2
+ *= require_self
3
+ */
4
+
5
+ :root {
6
+ --two_step-bg: radial-gradient(circle at 20% 20%, #f8efe3 0%, #fdf7ef 42%, #ffffff 100%);
7
+ --two_step-surface: #ffffff;
8
+ --two_step-border: #d9cbb4;
9
+ --two_step-text: #26170d;
10
+ --two_step-muted: #6e5a45;
11
+ --two_step-accent: #9b4626;
12
+ --two_step-accent-dark: #78341b;
13
+ --two_step-accent-soft: #f9eee8;
14
+ --two_step-success-bg: #eef7ee;
15
+ --two_step-success-border: #7ca67d;
16
+ --two_step-alert-bg: #fff1ec;
17
+ --two_step-alert-border: #d97857;
18
+ --two_step-warning-bg: #fff8ed;
19
+ --two_step-warning-border: #d9a861;
20
+ }
21
+
22
+ body.two_step-shell {
23
+ margin: 0;
24
+ min-height: 100vh;
25
+ background: var(--two_step-bg);
26
+ color: var(--two_step-text);
27
+ font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
28
+ position: relative;
29
+ }
30
+
31
+ .two_step-shell__mesh {
32
+ position: fixed;
33
+ inset: 0;
34
+ background-image: radial-gradient(rgba(155, 70, 38, 0.08) 0.06rem, transparent 0.06rem);
35
+ background-size: 1.35rem 1.35rem;
36
+ pointer-events: none;
37
+ }
38
+
39
+ .two_step-card {
40
+ position: relative;
41
+ z-index: 1;
42
+ box-sizing: border-box;
43
+ width: min(34rem, calc(100% - 2rem));
44
+ margin: 2.5rem auto;
45
+ padding: 2rem;
46
+ background: var(--two_step-surface);
47
+ border: 1px solid var(--two_step-border);
48
+ border-radius: 1.25rem;
49
+ box-shadow: 0 1.5rem 4rem rgba(35, 24, 13, 0.12);
50
+ }
51
+
52
+ .two_step-brand {
53
+ margin: 0 0 1.2rem;
54
+ font-size: 0.82rem;
55
+ font-weight: 700;
56
+ letter-spacing: 0.12em;
57
+ color: var(--two_step-muted);
58
+ text-transform: uppercase;
59
+ }
60
+
61
+ .two_step-header {
62
+ display: grid;
63
+ gap: 0.5rem;
64
+ }
65
+
66
+ .two_step-kicker {
67
+ margin: 0;
68
+ color: var(--two_step-accent);
69
+ font-size: 0.85rem;
70
+ font-weight: 700;
71
+ letter-spacing: 0.08em;
72
+ text-transform: uppercase;
73
+ }
74
+
75
+ .two_step-card h1 {
76
+ margin-top: 0;
77
+ margin-bottom: 0;
78
+ font-size: clamp(1.8rem, 4vw, 2.2rem);
79
+ line-height: 1.1;
80
+ }
81
+
82
+ .two_step-copy,
83
+ .two_step-manual-key,
84
+ .two_step-actions {
85
+ margin-top: 0.2rem;
86
+ margin-bottom: 0;
87
+ color: var(--two_step-muted);
88
+ }
89
+
90
+ .two_step-badge {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ width: fit-content;
94
+ margin: 0.4rem 0 0;
95
+ padding: 0.35rem 0.7rem;
96
+ border-radius: 999px;
97
+ border: 1px solid var(--two_step-warning-border);
98
+ background: var(--two_step-warning-bg);
99
+ color: #7b4d13;
100
+ font-size: 0.82rem;
101
+ font-weight: 600;
102
+ }
103
+
104
+ .two_step-steps {
105
+ margin: 1.25rem 0 0;
106
+ padding-left: 1.2rem;
107
+ color: var(--two_step-muted);
108
+ display: grid;
109
+ gap: 0.3rem;
110
+ }
111
+
112
+ .two_step-qr {
113
+ display: flex;
114
+ justify-content: center;
115
+ padding: 1rem;
116
+ margin: 1rem 0;
117
+ background: #fffaf4;
118
+ border: 1px solid var(--two_step-border);
119
+ border-radius: 1rem;
120
+ }
121
+
122
+ .two_step-form {
123
+ display: grid;
124
+ gap: 1rem;
125
+ margin-top: 1.5rem;
126
+ }
127
+
128
+ .two_step-field {
129
+ display: grid;
130
+ gap: 0.4rem;
131
+ }
132
+
133
+ .two_step-card input[type="text"] {
134
+ width: 100%;
135
+ box-sizing: border-box;
136
+ padding: 0.85rem 1rem;
137
+ border: 1px solid var(--two_step-border);
138
+ border-radius: 0.85rem;
139
+ font: inherit;
140
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
141
+ }
142
+
143
+ .two_step-card input[type="text"]:focus {
144
+ outline: none;
145
+ border-color: var(--two_step-accent);
146
+ box-shadow: 0 0 0 0.2rem rgba(155, 70, 38, 0.15);
147
+ }
148
+
149
+ .two_step-card input[type="submit"] {
150
+ border: 0;
151
+ border-radius: 999px;
152
+ padding: 0.85rem 1.2rem;
153
+ background: var(--two_step-accent);
154
+ color: #ffffff;
155
+ font: inherit;
156
+ font-weight: 600;
157
+ cursor: pointer;
158
+ }
159
+
160
+ .two_step-card input[type="submit"]:hover {
161
+ background: var(--two_step-accent-dark);
162
+ }
163
+
164
+ .two_step-field-note {
165
+ margin: 0;
166
+ color: var(--two_step-muted);
167
+ font-size: 0.84rem;
168
+ }
169
+
170
+ .two_step-details {
171
+ margin-top: 1.5rem;
172
+ padding-top: 1rem;
173
+ border-top: 1px solid var(--two_step-border);
174
+ }
175
+
176
+ .two_step-details summary {
177
+ cursor: pointer;
178
+ font-weight: 600;
179
+ }
180
+
181
+ .two_step-details .two_step-copy {
182
+ margin-top: 0.7rem;
183
+ }
184
+
185
+ .two_step-warning {
186
+ margin-top: 1.1rem;
187
+ padding: 0.9rem 1rem;
188
+ border-radius: 0.85rem;
189
+ border: 1px solid var(--two_step-warning-border);
190
+ background: var(--two_step-warning-bg);
191
+ }
192
+
193
+ .two_step-warning p {
194
+ margin: 0.35rem 0 0;
195
+ color: #735232;
196
+ }
197
+
198
+ .two_step-backup-codes {
199
+ margin-top: 1rem;
200
+ padding-left: 1.25rem;
201
+ display: grid;
202
+ gap: 0.35rem;
203
+ }
204
+
205
+ .two_step-backup-codes code,
206
+ .two_step-manual-key code {
207
+ font-size: 0.95rem;
208
+ background: #fbf5ee;
209
+ padding: 0.18rem 0.4rem;
210
+ border-radius: 0.4rem;
211
+ }
212
+
213
+ .two_step-actions {
214
+ margin-top: 1.3rem;
215
+ }
216
+
217
+ .two_step-link-button {
218
+ display: inline-flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ border-radius: 999px;
222
+ background: var(--two_step-accent);
223
+ color: #fff;
224
+ text-decoration: none;
225
+ padding: 0.72rem 1.1rem;
226
+ font-weight: 600;
227
+ }
228
+
229
+ .two_step-link-button:hover {
230
+ background: var(--two_step-accent-dark);
231
+ }
232
+
233
+ .two_step-flash {
234
+ margin-top: 0;
235
+ margin-bottom: 1rem;
236
+ padding: 0.85rem 1rem;
237
+ border-radius: 0.85rem;
238
+ }
239
+
240
+ .two_step-flash--notice {
241
+ background: var(--two_step-success-bg);
242
+ border: 1px solid var(--two_step-success-border);
243
+ }
244
+
245
+ .two_step-flash--alert {
246
+ background: var(--two_step-alert-bg);
247
+ border: 1px solid var(--two_step-alert-border);
248
+ }
249
+
250
+ @media (max-width: 640px) {
251
+ .two_step-card {
252
+ margin: 1.5rem auto;
253
+ padding: 1.25rem;
254
+ }
255
+
256
+ .two_step-steps {
257
+ margin-top: 1rem;
258
+ }
259
+ }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoStep
4
+ # Base for all engine controllers. Namespace isolation keeps routes, helpers,
5
+ # and constants separate from the host application (see TwoStep::Engine).
6
+ class ApplicationController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+ end
9
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoStep
4
+ class TwoStepChallengesController < ApplicationController
5
+ before_action :require_pending_resource
6
+
7
+ def new
8
+ end
9
+
10
+ def create
11
+ resource = pending_resource
12
+ if params[:backup_code].present?
13
+ verify_backup(resource)
14
+ else
15
+ verify_otp(resource)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def verify_backup(resource)
22
+ if resource.consume_backup_code(params[:backup_code])
23
+ complete_two_step(resource)
24
+ else
25
+ flash.now[:alert] = I18n.t("two_step.challenges.invalid_backup")
26
+ render :new, status: 422
27
+ end
28
+ end
29
+
30
+ def verify_otp(resource)
31
+ if resource.verify_otp(params[:otp_code])
32
+ complete_two_step(resource)
33
+ else
34
+ flash.now[:alert] = I18n.t("two_step.challenges.invalid_otp")
35
+ render :new, status: 422
36
+ end
37
+ end
38
+
39
+ def pending_resource
40
+ @pending_resource ||= TwoStep.configuration.find_pending_resource(session, controller: self)
41
+ end
42
+
43
+ def require_pending_resource
44
+ redirect_to TwoStep.configuration.resolve_login_path(controller: self) unless pending_resource
45
+ end
46
+
47
+ def complete_two_step(resource)
48
+ TwoStep.configuration.run_authentication_success(resource, session, controller: self)
49
+ redirect_to TwoStep.configuration.resolve_after_two_step_login_path(resource, controller: self)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwoStep
4
+ class TwoStepSetupsController < ApplicationController
5
+ before_action :require_setup_resource, only: %i[new create]
6
+ before_action :require_current_resource, only: :disable
7
+
8
+ helper_method :current_resource, :setup_resource, :setup_requires_login_challenge?
9
+
10
+ def new
11
+ setup_resource.ensure_otp_secret!
12
+ @qr_svg = generate_qr_svg(setup_resource.otp_provisioning_uri)
13
+ end
14
+
15
+ def create
16
+ if setup_resource.verify_otp(params[:otp_code])
17
+ setup_resource.update_columns(otp_required_for_login: true)
18
+ @backup_codes = setup_resource.generate_backup_codes!
19
+
20
+ if setup_requires_login_challenge?
21
+ TwoStep.configuration.run_authentication_success(setup_resource, session, controller: self)
22
+ end
23
+
24
+ render :complete
25
+ else
26
+ flash.now[:alert] = I18n.t("two_step.setups.invalid_otp")
27
+ @qr_svg = generate_qr_svg(setup_resource.otp_provisioning_uri)
28
+ render :new, status: :unprocessable_content
29
+ end
30
+ end
31
+
32
+ def disable
33
+ current_resource.disable_otp!
34
+ redirect_to(
35
+ disable_redirect_path || TwoStep.configuration.resolve_after_two_step_login_path(current_resource, controller: self),
36
+ notice: I18n.t("two_step.setups.disabled")
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def current_resource
43
+ @current_resource ||= TwoStep.configuration.find_current_resource(session, controller: self)
44
+ end
45
+
46
+ def setup_resource
47
+ @setup_resource ||= current_resource || TwoStep.configuration.find_pending_resource(session, controller: self)
48
+ end
49
+
50
+ def setup_requires_login_challenge?
51
+ setup_resource.present? && current_resource.blank?
52
+ end
53
+
54
+ def require_setup_resource
55
+ redirect_to TwoStep.configuration.resolve_login_path(controller: self) unless setup_resource
56
+ end
57
+
58
+ def require_current_resource
59
+ redirect_to TwoStep.configuration.resolve_login_path(controller: self) unless current_resource
60
+ end
61
+
62
+ def generate_qr_svg(uri)
63
+ RQRCode::QRCode.new(uri).as_svg(
64
+ module_size: TwoStep.configuration.qr_code_module_size,
65
+ standalone: true,
66
+ use_path: true
67
+ ).html_safe
68
+ end
69
+
70
+ def disable_redirect_path
71
+ candidate = params[:return_to].to_s
72
+ return if candidate.blank?
73
+ return if !candidate.start_with?("/") || candidate.start_with?("//")
74
+
75
+ candidate
76
+ end
77
+ end
78
+ end