Add ability to require 2FA for specific roles (including Everybody) (#37701)

This commit is contained in:
Claire authored and GitHub committed 2026-02-11 14:34:09 +00:00
1 parent 3e1127d27b
commit dfe44bcaef
23 files changed
+184 -15

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ module Admin
def resource_params
params
.expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []])
.expect(user_role: [:name, :color, :highlighted, :position, :require_2fa, permissions_as_keys: []])
end
end
end
+10 -2
View File
@@ -61,19 +61,25 @@ class ApplicationController < ActionController::Base
return if request.referer.blank?
redirect_uri = URI(request.referer)
return if redirect_uri.path.start_with?('/auth')
return if redirect_uri.path.start_with?('/auth', '/settings/two_factor_authentication', '/settings/otp_authentication')
stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port
store_location_for(:user, stored_url)
end
def mfa_setup_path(path_params = {})
settings_two_factor_authentication_methods_path(path_params)
end
def require_functional!
return if current_user.functional?
respond_to do |format|
format.any do
if current_user.confirmed?
if current_user.missing_2fa?
redirect_to mfa_setup_path
elsif current_user.confirmed?
redirect_to edit_user_registration_path
else
redirect_to auth_setup_path
@@ -85,6 +91,8 @@ class ApplicationController < ActionController::Base
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403
elsif current_user.missing_2fa?
render json: { error: 'Your account requires two-factor authentication' }, status: 403
elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403
end
@@ -42,7 +42,7 @@ module ChallengableConcern
end
def render_challenge
render 'auth/challenges/new', layout: 'auth'
render 'auth/challenges/new', layout: params[:oauth] ? 'modal' : 'auth'
end
def challenge_passed?
@@ -24,4 +24,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
def truthy_param?(key)
ActiveModel::Type::Boolean.new.cast(params[key])
end
def mfa_setup_path
super({ oauth: true })
end
end
@@ -0,0 +1,9 @@
# frozen_string_literal: true
module Settings
module TwoFactorAuthentication
class BaseController < ::Settings::BaseController
layout -> { truthy_param?(:oauth) ? 'modal' : 'admin' }
end
end
end
@@ -4,12 +4,15 @@ module Settings
module TwoFactorAuthentication
class ConfirmationsController < BaseController
include ChallengableConcern
include Devise::Controllers::StoreLocation
skip_before_action :require_functional!
before_action :require_challenge!
before_action :ensure_otp_secret
helper_method :return_to_app_url
def new
prepare_two_factor_form
end
@@ -37,6 +40,10 @@ module Settings
private
def return_to_app_url
stored_location_for(:user)
end
def confirmation_params
params.expect(form_two_factor_confirmation: [:otp_attempt])
end
@@ -17,7 +17,7 @@ module Settings
def create
session[:new_otp_secret] = User.generate_otp_secret
redirect_to new_settings_two_factor_authentication_confirmation_path
redirect_to new_settings_two_factor_authentication_confirmation_path(params.permit(:oauth))
end
private
@@ -22,7 +22,7 @@ module Settings
private
def require_otp_enabled
redirect_to settings_otp_authentication_path unless current_user.otp_enabled?
redirect_to settings_otp_authentication_path(params.permit(:oauth)) unless current_user.otp_enabled?
end
end
end
+5 -1
View File
@@ -226,7 +226,11 @@ class User < ApplicationRecord
end
def functional_or_moved?
confirmed? && approved? && !disabled? && !account.unavailable? && !account.memorial?
confirmed? && approved? && !disabled? && !account.unavailable? && !account.memorial? && !missing_2fa?
end
def missing_2fa?
!two_factor_enabled? && role.require_2fa?
end
def unconfirmed_or_pending?
+10 -4
View File
@@ -5,11 +5,12 @@
# Table name: user_roles
#
# id :bigint(8) not null, primary key
# name :string default(""), not null
# color :string default(""), not null
# position :integer default(0), not null
# permissions :bigint(8) default(0), not null
# highlighted :boolean default(FALSE), not null
# name :string default(""), not null
# permissions :bigint(8) default(0), not null
# position :integer default(0), not null
# require_2fa :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
@@ -160,7 +161,7 @@ class UserRole < ApplicationRecord
@computed_permissions ||= begin
permissions = self.class.everyone.permissions | self.permissions
if permissions & FLAGS[:administrator] == FLAGS[:administrator]
if administrator?
Flags::ALL
else
permissions
@@ -172,6 +173,10 @@ class UserRole < ApplicationRecord
name
end
def administrator?
permissions & FLAGS[:administrator] == FLAGS[:administrator]
end
private
def in_permissions?(privilege)
@@ -189,6 +194,7 @@ class UserRole < ApplicationRecord
errors.add(:permissions_as_keys, :own_role) if permissions_changed?
errors.add(:position, :own_role) if position_changed?
errors.add(:require_2fa, :own_role) if require_2fa_changed? && !administrator?
end
def validate_permissions_elevation
+5
View File
@@ -25,6 +25,11 @@
= form.input :highlighted,
wrapper: :with_label
- if current_user.role.administrator? || current_user.role != form.object
.fields-group
= form.input :require_2fa,
wrapper: :with_label
%hr.spacer/
- unless current_user.role == form.object
+4
View File
@@ -21,9 +21,13 @@
.announcements-list__item__meta
- if role.everyone?
= t('admin.roles.everyone_full_description_html')
= t('admin.roles.requires_2fa') if role.require_2fa?
- else
= link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
·
%abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
- if role.require_2fa?
·
= t('admin.roles.requires_2fa')
.announcements-list__item__actions
= table_link_to 'edit', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)
@@ -12,6 +12,8 @@
%samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
.fields-group
= hidden_field_tag :oauth, params[:oauth]
= f.input :otp_attempt,
hint: t('otp_authentication.code_hint'),
input_html: { autocomplete: 'off' },
@@ -1,9 +1,12 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
- if current_user.role.require_2fa?
.flash-message= t('two_factor_authentication.role_requirement', domain: site_hostname)
.simple_form
%p.hint= t('otp_authentication.description_html')
%hr.spacer/
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'button button--block'
= link_to t('otp_authentication.setup'), settings_otp_authentication_path(params.permit(:oauth)), data: { method: :post }, class: 'button button--block'
@@ -7,3 +7,9 @@
- @recovery_codes.each do |code|
%li<
%samp= code
- if params[:oauth]
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.resume_app_authorization'), return_to_app_url, class: 'button button--block'
@@ -2,7 +2,10 @@
= t('settings.two_factor_authentication')
- content_for :heading_actions do
= link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post
= link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post unless current_user.role.require_2fa?
- if current_user.role.require_2fa?
.flash-message= t('two_factor_authentication.role_requirement', domain: site_hostname)
%p.hint
%span.positive-hint