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
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
|