Skip to content
This repository was archived by the owner on Dec 21, 2023. It is now read-only.

Commit 72a7cfa

Browse files
authored
Add e-mail-based sign in challenge for users with disabled 2FA (mastodon#14013)
1 parent 8b6d97f commit 72a7cfa

File tree

14 files changed

+368
-51
lines changed

14 files changed

+368
-51
lines changed

app/controllers/auth/sessions_controller.rb

Lines changed: 5 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
88
skip_before_action :require_no_authentication, only: [:create]
99
skip_before_action :require_functional!
1010

11-
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
11+
include TwoFactorAuthenticationConcern
12+
include SignInTokenAuthenticationConcern
1213

1314
before_action :set_instance_presenter, only: [:new]
1415
before_action :set_body_classes
@@ -39,8 +40,8 @@ def destroy
3940
protected
4041

4142
def find_user
42-
if session[:otp_user_id]
43-
User.find(session[:otp_user_id])
43+
if session[:attempt_user_id]
44+
User.find(session[:attempt_user_id])
4445
else
4546
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
4647
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
@@ -49,7 +50,7 @@ def find_user
4950
end
5051

5152
def user_params
52-
params.require(:user).permit(:email, :password, :otp_attempt)
53+
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
5354
end
5455

5556
def after_sign_in_path_for(resource)
@@ -70,47 +71,6 @@ def after_sign_out_path_for(_resource_or_scope)
7071
super
7172
end
7273

73-
def two_factor_enabled?
74-
find_user&.otp_required_for_login?
75-
end
76-
77-
def valid_otp_attempt?(user)
78-
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
79-
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
80-
rescue OpenSSL::Cipher::CipherError
81-
false
82-
end
83-
84-
def authenticate_with_two_factor
85-
user = self.resource = find_user
86-
87-
if user_params[:otp_attempt].present? && session[:otp_user_id]
88-
authenticate_with_two_factor_via_otp(user)
89-
elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
90-
# If encrypted_password is blank, we got the user from LDAP or PAM,
91-
# so credentials are already valid
92-
93-
prompt_for_two_factor(user)
94-
end
95-
end
96-
97-
def authenticate_with_two_factor_via_otp(user)
98-
if valid_otp_attempt?(user)
99-
session.delete(:otp_user_id)
100-
remember_me(user)
101-
sign_in(user)
102-
else
103-
flash.now[:alert] = I18n.t('users.invalid_otp_token')
104-
prompt_for_two_factor(user)
105-
end
106-
end
107-
108-
def prompt_for_two_factor(user)
109-
session[:otp_user_id] = user.id
110-
@body_classes = 'lighter'
111-
render :two_factor
112-
end
113-
11474
def require_no_authentication
11575
super
11676
# Delete flash message that isn't entirely useful and may be confusing in
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module SignInTokenAuthenticationConcern
4+
extend ActiveSupport::Concern
5+
6+
included do
7+
prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
8+
end
9+
10+
def sign_in_token_required?
11+
find_user&.suspicious_sign_in?(request.remote_ip)
12+
end
13+
14+
def valid_sign_in_token_attempt?(user)
15+
Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
16+
end
17+
18+
def authenticate_with_sign_in_token
19+
user = self.resource = find_user
20+
21+
if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
22+
authenticate_with_sign_in_token_attempt(user)
23+
elsif user.present? && user.external_or_valid_password?(user_params[:password])
24+
prompt_for_sign_in_token(user)
25+
end
26+
end
27+
28+
def authenticate_with_sign_in_token_attempt(user)
29+
if valid_sign_in_token_attempt?(user)
30+
session.delete(:attempt_user_id)
31+
remember_me(user)
32+
sign_in(user)
33+
else
34+
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
35+
prompt_for_sign_in_token(user)
36+
end
37+
end
38+
39+
def prompt_for_sign_in_token(user)
40+
if user.sign_in_token_expired?
41+
user.generate_sign_in_token && user.save
42+
UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
43+
end
44+
45+
session[:attempt_user_id] = user.id
46+
@body_classes = 'lighter'
47+
render :sign_in_token
48+
end
49+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module TwoFactorAuthenticationConcern
4+
extend ActiveSupport::Concern
5+
6+
included do
7+
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
8+
end
9+
10+
def two_factor_enabled?
11+
find_user&.otp_required_for_login?
12+
end
13+
14+
def valid_otp_attempt?(user)
15+
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
16+
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
17+
rescue OpenSSL::Cipher::CipherError
18+
false
19+
end
20+
21+
def authenticate_with_two_factor
22+
user = self.resource = find_user
23+
24+
if user_params[:otp_attempt].present? && session[:attempt_user_id]
25+
authenticate_with_two_factor_attempt(user)
26+
elsif user.present? && user.external_or_valid_password?(user_params[:password])
27+
prompt_for_two_factor(user)
28+
end
29+
end
30+
31+
def authenticate_with_two_factor_attempt(user)
32+
if valid_otp_attempt?(user)
33+
session.delete(:attempt_user_id)
34+
remember_me(user)
35+
sign_in(user)
36+
else
37+
flash.now[:alert] = I18n.t('users.invalid_otp_token')
38+
prompt_for_two_factor(user)
39+
end
40+
end
41+
42+
def prompt_for_two_factor(user)
43+
session[:attempt_user_id] = user.id
44+
@body_classes = 'lighter'
45+
render :two_factor
46+
end
47+
end

app/mailers/user_mailer.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,21 @@ def warning(user, warning, status_ids = nil)
126126
reply_to: Setting.site_contact_email
127127
end
128128
end
129+
130+
def sign_in_token(user, remote_ip, user_agent, timestamp)
131+
@resource = user
132+
@instance = Rails.configuration.x.local_domain
133+
@remote_ip = remote_ip
134+
@user_agent = user_agent
135+
@detection = Browser.new(user_agent)
136+
@timestamp = timestamp.to_time.utc
137+
138+
return if @resource.disabled?
139+
140+
I18n.with_locale(@resource.locale || I18n.default_locale) do
141+
mail to: @resource.email,
142+
subject: I18n.t('user_mailer.sign_in_token.subject'),
143+
reply_to: Setting.site_contact_email
144+
end
145+
end
129146
end

app/models/user.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
# chosen_languages :string is an Array
3939
# created_by_application_id :bigint(8)
4040
# approved :boolean default(TRUE), not null
41+
# sign_in_token :string
42+
# sign_in_token_sent_at :datetime
4143
#
4244

4345
class User < ApplicationRecord
@@ -113,7 +115,7 @@ class User < ApplicationRecord
113115
:advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
114116
to: :settings, prefix: :setting, allow_nil: false
115117

116-
attr_reader :invite_code
118+
attr_reader :invite_code, :sign_in_token_attempt
117119
attr_writer :external
118120

119121
def confirmed?
@@ -167,6 +169,10 @@ def active_for_authentication?
167169
true
168170
end
169171

172+
def suspicious_sign_in?(ip)
173+
!otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip)
174+
end
175+
170176
def functional?
171177
confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil?
172178
end
@@ -269,6 +275,13 @@ def password_required?
269275
super
270276
end
271277

278+
def external_or_valid_password?(compare_password)
279+
# If encrypted_password is blank, we got the user from LDAP or PAM,
280+
# so credentials are already valid
281+
282+
encrypted_password.blank? || valid_password?(compare_password)
283+
end
284+
272285
def send_reset_password_instructions
273286
return false if encrypted_password.blank?
274287

@@ -304,6 +317,15 @@ def recent_ips
304317
end
305318
end
306319

320+
def sign_in_token_expired?
321+
sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago
322+
end
323+
324+
def generate_sign_in_token
325+
self.sign_in_token = Devise.friendly_token(6)
326+
self.sign_in_token_sent_at = Time.now.utc
327+
end
328+
307329
protected
308330

309331
def send_devise_notification(notification, *args)
@@ -320,6 +342,10 @@ def send_devise_notification(notification, *args)
320342

321343
private
322344

345+
def recent_ip?(ip)
346+
recent_ips.any? { |(_, recent_ip)| recent_ip == ip }
347+
end
348+
323349
def send_pending_devise_notifications
324350
pending_devise_notifications.each do |notification, args|
325351
render_and_send_devise_message(notification, *args)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
- content_for :page_title do
2+
= t('auth.login')
3+
4+
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
5+
%p.hint.otp-hint= t('users.suspicious_sign_in_confirmation')
6+
7+
.fields-group
8+
= f.input :sign_in_token_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.sign_in_token_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.sign_in_token_attempt'), :autocomplete => 'off' }, autofocus: true
9+
10+
.actions
11+
= f.button :button, t('auth.login'), type: :submit
12+
13+
- if Setting.site_contact_email.present?
14+
%p.hint.subtle-hint= t('users.generic_access_help_html', email: mail_to(Setting.site_contact_email, nil))
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
%table.email-table{ cellspacing: 0, cellpadding: 0 }
2+
%tbody
3+
%tr
4+
%td.email-body
5+
.email-container
6+
%table.content-section{ cellspacing: 0, cellpadding: 0 }
7+
%tbody
8+
%tr
9+
%td.content-cell.hero
10+
.email-row
11+
.col-6
12+
%table.column{ cellspacing: 0, cellpadding: 0 }
13+
%tbody
14+
%tr
15+
%td.column-cell.text-center.padded
16+
%table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
17+
%tbody
18+
%tr
19+
%td
20+
= image_tag full_pack_url('media/images/mailer/icon_email.png'), alt: ''
21+
22+
%h1= t 'user_mailer.sign_in_token.title'
23+
%p.lead= t 'user_mailer.sign_in_token.explanation'
24+
25+
%table.email-table{ cellspacing: 0, cellpadding: 0 }
26+
%tbody
27+
%tr
28+
%td.email-body
29+
.email-container
30+
%table.content-section{ cellspacing: 0, cellpadding: 0 }
31+
%tbody
32+
%tr
33+
%td.content-cell.content-start
34+
%table.column{ cellspacing: 0, cellpadding: 0 }
35+
%tbody
36+
%tr
37+
%td.column-cell.input-cell
38+
%table.input{ align: 'center', cellspacing: 0, cellpadding: 0 }
39+
%tbody
40+
%tr
41+
%td= @resource.sign_in_token
42+
43+
%table.email-table{ cellspacing: 0, cellpadding: 0 }
44+
%tbody
45+
%tr
46+
%td.email-body
47+
.email-container
48+
%table.content-section{ cellspacing: 0, cellpadding: 0 }
49+
%tbody
50+
%tr
51+
%td.content-cell
52+
.email-row
53+
.col-6
54+
%table.column{ cellspacing: 0, cellpadding: 0 }
55+
%tbody
56+
%tr
57+
%td.column-cell.text-center
58+
%p= t 'user_mailer.sign_in_token.details'
59+
%tr
60+
%td.column-cell.text-center
61+
%p
62+
%strong= "#{t('sessions.ip')}:"
63+
= @remote_ip
64+
%br/
65+
%strong= "#{t('sessions.browser')}:"
66+
%span{ title: @user_agent }= t 'sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")
67+
%br/
68+
= l(@timestamp)
69+
70+
%table.email-table{ cellspacing: 0, cellpadding: 0 }
71+
%tbody
72+
%tr
73+
%td.email-body
74+
.email-container
75+
%table.content-section{ cellspacing: 0, cellpadding: 0 }
76+
%tbody
77+
%tr
78+
%td.content-cell
79+
.email-row
80+
.col-6
81+
%table.column{ cellspacing: 0, cellpadding: 0 }
82+
%tbody
83+
%tr
84+
%td.column-cell.text-center
85+
%p= t 'user_mailer.sign_in_token.further_actions'
86+
87+
%table.email-table{ cellspacing: 0, cellpadding: 0 }
88+
%tbody
89+
%tr
90+
%td.email-body
91+
.email-container
92+
%table.content-section{ cellspacing: 0, cellpadding: 0 }
93+
%tbody
94+
%tr
95+
%td.content-cell
96+
%table.column{ cellspacing: 0, cellpadding: 0 }
97+
%tbody
98+
%tr
99+
%td.column-cell.button-cell
100+
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
101+
%tbody
102+
%tr
103+
%td.button-primary
104+
= link_to edit_user_registration_url do
105+
%span= t 'settings.account_settings'

0 commit comments

Comments
 (0)