Skip to content

Commit 1d79949

Browse files
authored
Feature-flagged sudo mode for 2fa setting (#10671)
RFC: #10653 The following PRs were extracted from this one and shipped separately: - #10780 - #10826 - #10903 ## Changes - Adds the model relationships described in #10903 (comment) - Adds `logins` and `initial_login` relations to `UserSession` - Adds `initial_login` relation to `Login` - Updates the logic in `Login` to only require a single factor in the case of a reauthentication - Adds `UserSession#sudo_mode?` to determine whether sudo mode is enabled based on the creation times of its associated logins - Adds `SudoModeHandler` which handles intercepting requests and rendering the reauthentication page - Requires sudo mode if the 2fa setting is changed and the user has the feature flag enabled - Extensive testing
1 parent 87bb1c5 commit 1d79949

File tree

12 files changed

+1017
-2
lines changed

12 files changed

+1017
-2
lines changed

app/assets/stylesheets/_utilities.scss

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,15 @@ tr.strikethrough td:before {
232232
.scrollbar-hidden::-webkit-scrollbar {
233233
display: none;
234234
}
235+
236+
// https://piccalil.li/blog/link-button/
237+
.link-button {
238+
display: inline;
239+
padding: 0;
240+
border: 0;
241+
font: inherit;
242+
text-decoration: underline;
243+
cursor: pointer;
244+
background: transparent;
245+
color: currentColor;
246+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# frozen_string_literal: true
2+
3+
class SudoModeHandler
4+
# The default rank for authentication factors (lower is better)
5+
# - 0 is deliberately left available so we can use it for the user's preference
6+
# - `:backup_code` is explicitly omitted as we don't want to use that for
7+
# reauthentication
8+
FACTOR_PREFERENCES = {
9+
webauthn: 1,
10+
totp: 2,
11+
sms: 3,
12+
email: 4,
13+
}.freeze
14+
15+
Params = Struct.new(
16+
# When the form is submitted, `submit_method` determines which
17+
# authentication method the user selected, which in turn determines whether
18+
# `login_code` or webauthn_response` is relevant
19+
:submit_method,
20+
:login_code,
21+
:webauthn_response,
22+
# When the user first sees the reauthentication page we create a login for
23+
# them whose ID is passed around over subsequent operations. It is required
24+
# when completing the form.
25+
:login_id,
26+
# When the user clicks on an alternate authentication method, the form will be
27+
# submitted with this param indicating which method they chose.
28+
:switch_method,
29+
keyword_init: true
30+
)
31+
32+
# @param controller_instance [ApplicationController]
33+
def initialize(controller_instance:)
34+
@controller_instance = controller_instance
35+
end
36+
37+
# @return [Boolean] whether sudo mode was obtained and sensitive actions can proceed
38+
def call
39+
unless sudo_params.submit_method.present?
40+
render_reauthentication_page
41+
return false
42+
end
43+
44+
login = Login.incomplete.active.find_by_hashid(sudo_params.login_id)
45+
46+
# If the login doesn't exist, was completed, or has expired, treat this as a
47+
# new request
48+
unless login
49+
flash.now[:error] = "Login has expired. Please try again."
50+
render_reauthentication_page
51+
return false
52+
end
53+
54+
service = ProcessLoginService.new(login:)
55+
56+
ok =
57+
case sudo_params.submit_method
58+
when "email", "sms"
59+
service.process_login_code(
60+
code: sudo_params.login_code,
61+
sms: sudo_params.submit_method == "sms"
62+
)
63+
when "totp"
64+
service.process_totp(
65+
code: sudo_params.login_code
66+
)
67+
when "webauthn"
68+
service.process_webauthn(
69+
raw_credential: sudo_params.webauthn_response,
70+
challenge: session[:webauthn_challenge]
71+
)
72+
else
73+
raise ActionController::ParameterMissing.new(:submit_method)
74+
end
75+
76+
unless ok
77+
flash.now[:error] = service.errors.full_messages.to_sentence
78+
render_reauthentication_page(login:)
79+
return false
80+
end
81+
82+
login.update!(user_session: current_session)
83+
current_session.reload
84+
85+
true
86+
end
87+
88+
private
89+
90+
attr_reader(:controller_instance)
91+
92+
delegate(
93+
:current_user,
94+
:current_session,
95+
:params,
96+
:session,
97+
:request,
98+
:flash,
99+
to: :controller_instance,
100+
private: true
101+
)
102+
103+
def sudo_params
104+
@sudo_params ||= begin
105+
nested = params[:_sudo]
106+
107+
if nested.is_a?(ActionController::Parameters)
108+
Params.new(
109+
**nested.permit(
110+
:submit_method,
111+
:switch_method,
112+
:login_id,
113+
:login_code,
114+
:webauthn_response,
115+
)
116+
)
117+
else
118+
Params.new
119+
end
120+
end
121+
end
122+
123+
def sorted_factors(login)
124+
factor_preference = FACTOR_PREFERENCES
125+
126+
# Put the user's preference first
127+
user_preference = sudo_params.switch_method.presence || session[:login_preference].presence
128+
if user_preference.present? && factor_preference.key?(user_preference.to_sym)
129+
factor_preference = factor_preference.merge(user_preference.to_sym => 0)
130+
end
131+
132+
# Filter available factors down to only those present in
133+
# `FACTOR_PREFERENCES` and sort based on their associated ranks.
134+
(login.available_factors & FACTOR_PREFERENCES.keys)
135+
.sort_by { |factor| factor_preference.fetch(factor) }
136+
end
137+
138+
# Extracts the request parameters as a flat list of key-value pairs (instead
139+
# of following Rack's nesting conventions) so that we can re-submit them along
140+
# with the sudo credentials.
141+
def forwarded_params
142+
Rack::Utils
143+
# Re-encoding params to immediately parse them may seem wasteful, but it
144+
# means we don't have to re-implement Rack's nested param encoding logic
145+
.parse_query(request.request_parameters.to_query)
146+
.reject do |name, _value_or_values|
147+
case name
148+
# Both of these fields will be regenerated by `form_tag`
149+
when "authenticity_token", "_method"
150+
true
151+
else
152+
# Skip anything that is part of the sudo functionality
153+
name.start_with?("_sudo")
154+
end
155+
end
156+
.each_with_object([]) do |(name, value_or_values), array|
157+
# `Rack::Utils.parse_query` returns a hash of param names to values but
158+
# returns an array of values for repeated params (as is the convention
159+
# params like `tags[]`)
160+
if value_or_values.is_a?(Array)
161+
value_or_values.each do |value|
162+
array << [name, value]
163+
end
164+
else
165+
array << [name, value_or_values]
166+
end
167+
end
168+
end
169+
170+
def find_or_create_login!
171+
existing =
172+
if sudo_params.login_id.present?
173+
Login.incomplete.active.find_by_hashid(sudo_params.login_id)
174+
end
175+
176+
return existing if existing
177+
178+
raise("Session does not have an initial login") unless current_session.initial_login
179+
180+
Login.create!(
181+
user: current_user,
182+
initial_login: current_session.initial_login
183+
)
184+
end
185+
186+
def render_reauthentication_page(login: find_or_create_login!)
187+
default_factor, *additional_factors = sorted_factors(login)
188+
189+
# In the case where we know we're going to ask for an SMS or email code,
190+
# send it ahead of time so the user doesn't have to perform an additional
191+
# step
192+
if [:sms, :email].include?(default_factor)
193+
LoginCodeService::Request.new(
194+
email: current_user.email,
195+
sms: default_factor == :sms,
196+
ip_address: request.remote_ip,
197+
user_agent: request.user_agent
198+
).run
199+
end
200+
201+
# Remove extra content from the layout so we only have the
202+
# reauthentication form on the page.
203+
controller_instance.instance_variable_set(:@no_app_shell, true)
204+
205+
controller_instance.render(
206+
template: "sudo_mode/reauthenticate",
207+
layout: "application",
208+
locals: {
209+
login:,
210+
additional_factors:,
211+
default_factor:,
212+
forwarded_params:
213+
},
214+
status: :unprocessable_entity
215+
)
216+
end
217+
218+
end

app/controllers/users_controller.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ def update
232232
@user = User.friendly.find(params[:id])
233233
authorize @user
234234

235+
@user.assign_attributes(user_params)
236+
237+
if @user.use_two_factor_authentication_changed?
238+
return unless enforce_sudo_mode # rubocop:disable Style/SoleNestedConditional
239+
end
240+
235241
if admin_signed_in?
236242
if @user.auditor? && params[:user][:running_balance_enabled].present?
237243
enable_running_balance = params[:user][:running_balance_enabled] == "1"
@@ -276,7 +282,7 @@ def update
276282
end
277283
end
278284

279-
if @user.update(user_params)
285+
if @user.save
280286
confetti! if !@user.seasonal_themes_enabled_before_last_save && @user.seasonal_themes_enabled? # confetti if the user enables seasonal themes
281287

282288
if @user.full_name_before_last_save.blank?

app/helpers/sessions_helper.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,23 @@ def sign_out_of_all_sessions(user = current_user)
158158
&.where&.not(id: current_session.id)
159159
&.update_all(signed_out_at: Time.now, expiration_at: Time.now)
160160
end
161+
162+
def sudo_mode?
163+
current_session&.sudo_mode?
164+
end
165+
166+
# Intercepts the request and renders a reauthentication form if the user does
167+
# not have sudo mode.
168+
#
169+
# It can either be used as a `before_action` callback or as part of an action
170+
# implementation if you only want to require sudo mode in specific cases. In
171+
# the latter scenario, you _MUST_ check the return value and only proceed if
172+
# it is `true`.
173+
#
174+
# @return [Boolean] whether sudo mode was obtained and the controller action can proceed
175+
def enforce_sudo_mode
176+
return true if sudo_mode?
177+
178+
SudoModeHandler.new(controller_instance: self).call
179+
end
161180
end

app/models/login.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ class Login < ApplicationRecord
3131

3232
belongs_to :user
3333
belongs_to :user_session, optional: true
34+
belongs_to(
35+
:initial_login,
36+
optional: true,
37+
class_name: "Login",
38+
inverse_of: nil
39+
)
40+
41+
scope(:initial, -> { where(initial_login_id: nil) })
3442
belongs_to :referral_program, class_name: "Referral::Program", optional: true
3543

3644
has_encrypted :browser_token
@@ -66,7 +74,7 @@ class Login < ApplicationRecord
6674
event :mark_complete do
6775
transitions from: :incomplete, to: :complete do
6876
guard do
69-
authentication_factors_count == (user.use_two_factor_authentication? ? 2 : 1)
77+
authentication_factors_count == required_authentication_factors_count
7078
end
7179
end
7280
end
@@ -119,4 +127,23 @@ def available_factors
119127
factors
120128
end
121129

130+
def reauthentication?
131+
initial_login_id.present?
132+
end
133+
134+
private
135+
136+
# The number of authentication factors required to consider this login
137+
# complete (based on the user's 2FA setting and whether this is a
138+
# reauthentication)
139+
#
140+
# @return [Integer]
141+
def required_authentication_factors_count
142+
if user.use_two_factor_authentication? && !reauthentication?
143+
2
144+
else
145+
1
146+
end
147+
end
148+
122149
end

app/models/user_session.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ class UserSession < ApplicationRecord
4343
belongs_to :user
4444
belongs_to :impersonated_by, class_name: "User", optional: true
4545
belongs_to :webauthn_credential, optional: true
46+
has_many(:logins)
47+
has_one(
48+
:initial_login,
49+
-> { initial },
50+
class_name: "Login",
51+
inverse_of: :user_session,
52+
)
4653

4754
include PublicActivity::Model
4855
tracked owner: proc{ |controller, record| record.impersonated_by || record.user }, recipient: proc { |controller, record| record.impersonated_by || record.user }, only: [:create]
@@ -83,6 +90,20 @@ def expired?
8390
expiration_at <= Time.now
8491
end
8592

93+
SUDO_MODE_TTL = 2.hours
94+
95+
# Determines whether the user can perform a sensitive action without
96+
# reauthenticating.
97+
#
98+
# @return [Boolean]
99+
def sudo_mode?
100+
return true unless Flipper.enabled?(:sudo_mode_2015_07_21, user)
101+
102+
return false if last_authenticated_at.nil?
103+
104+
last_authenticated_at >= SUDO_MODE_TTL.ago
105+
end
106+
86107
def clear_metadata!
87108
update!(
88109
device_info: nil,
@@ -99,4 +120,12 @@ def user_is_unlocked
99120
end
100121
end
101122

123+
# The last time the user went through a login flow. Used to determine whether
124+
# sensitive actions can be performed.
125+
#
126+
# @return [ActiveSupport::TimeWithZone, nil]
127+
def last_authenticated_at
128+
logins.complete.max_by(&:created_at)&.created_at
129+
end
130+
102131
end

0 commit comments

Comments
 (0)