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

This commit is contained in:
Claire 2026-02-11 15:34:09 +01:00 committed by GitHub
parent 3e1127d27b
commit dfe44bcaef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 184 additions and 15 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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?

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

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

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)

View File

@ -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' },

View File

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

View File

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

View File

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

View File

@ -802,6 +802,7 @@ en:
view_devops_description: Allows users to access Sidekiq and pgHero dashboards
view_feeds: View live and topic feeds
view_feeds_description: Allows users to access the live and topic feeds regardless of server settings
requires_2fa: Requires two-factor authentication
title: Roles
rules:
add_new: Add rule
@ -2047,6 +2048,8 @@ en:
recovery_codes: Backup recovery codes
recovery_codes_regenerated: Recovery codes successfully regenerated
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents.
resume_app_authorization: Resume application authorization
role_requirement: "%{domain} requires you to set up Two-Factor Authentication before you can use Mastodon."
webauthn: Security keys
user_mailer:
announcement_published:

View File

@ -164,6 +164,7 @@ en:
name: Public name of the role, if role is set to be displayed as a badge
permissions_as_keys: Users with this role will have access to...
position: Higher role decides conflict resolution in certain situations. Certain actions can only be performed on roles with a lower priority
require_2fa: Users with this role will be required to set up two-factor authentication to use Mastodon
username_block:
allow_with_approval: Instead of preventing sign-up outright, matching sign-ups will require your approval
comparison: Please be mindful of the Scunthorpe Problem when blocking partial matches
@ -387,6 +388,7 @@ en:
name: Name
permissions_as_keys: Permissions
position: Priority
require_2fa: Require two-factor authentication
username_block:
allow_with_approval: Allow registrations with approval
comparison: Method of comparison

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddRequire2FaToUserRoles < ActiveRecord::Migration[8.0]
def change
add_column :user_roles, :require_2fa, :boolean, null: false, default: false
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_02_09_143308) do
ActiveRecord::Schema[8.0].define(version: 2026_02_11_132603) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -1285,6 +1285,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_09_143308) do
t.boolean "highlighted", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "require_2fa", default: false, null: false
end
create_table "username_blocks", force: :cascade do |t|

View File

@ -47,6 +47,17 @@ RSpec.describe UserRole do
it { is_expected.to_not allow_value(100).for(:permissions).against(:permissions_as_keys).with_message(:own_role) }
it { is_expected.to_not allow_value(100).for(:position).with_message(:own_role) }
it { is_expected.to_not allow_value(true).for(:require_2fa).with_message(:own_role) }
end
context 'when current_account is changing their own role and is an admin' do
subject { Fabricate(:user_role, permissions: UserRole::FLAGS[:administrator]) }
let(:account) { Fabricate :account, user: Fabricate(:user, role: subject) }
it { is_expected.to_not allow_value(100).for(:permissions).against(:permissions_as_keys).with_message(:own_role) }
it { is_expected.to_not allow_value(100).for(:position).with_message(:own_role) }
it { is_expected.to allow_value(true).for(:require_2fa) }
end
end
end

View File

@ -13,16 +13,19 @@ RSpec.describe 'Log in' do
before do
as_a_registered_user
visit new_user_session_path
end
it 'A valid email and password user is able to log in' do
visit new_user_session_path
fill_in_auth_details(email, password)
expect(subject).to have_css('div.app-holder')
end
it 'A invalid email and password user is not able to log in' do
visit new_user_session_path
fill_in_auth_details('invalid_email', 'invalid_password')
expect(subject).to have_css('.flash-message', text: /#{failure_message_invalid}/i)
@ -32,12 +35,53 @@ RSpec.describe 'Log in' do
let(:confirmed_at) { nil }
it 'A unconfirmed user is able to log in' do
visit new_user_session_path
fill_in_auth_details(email, password)
expect(subject).to have_css('.title', text: I18n.t('auth.setup.title'))
end
end
context 'when the user role requires 2FA' do
before do
bob.role.update!(require_2fa: true)
end
context 'when the user has not configured 2FA' do
it 'they are redirected to 2FA setup' do
visit new_user_session_path
fill_in_auth_details(email, password)
expect(subject).to have_no_css('div.app-holder')
expect(subject).to have_title(I18n.t('settings.two_factor_authentication'))
end
end
context 'when the user has configured 2FA' do
before do
bob.update!(otp_required_for_login: true, otp_secret: User.generate_otp_secret)
end
it 'they are able to log in' do
visit new_user_session_path
fill_in_auth_details(email, password)
fill_in_otp_details(bob.current_otp)
expect(subject).to have_css('div.app-holder')
end
end
end
private
def fill_in_otp_details(value)
fill_in 'user_otp_attempt', with: value
click_on I18n.t('auth.login')
end
def failure_message_invalid
keys = User.authentication_keys.map { |key| User.human_attribute_name(key) }
I18n.t('devise.failure.invalid', authentication_keys: keys.join('support.array.words_connector'))

View File

@ -121,6 +121,46 @@ RSpec.describe 'Using OAuth from an external app' do
end
end
end
context 'when the user has yet to enable TOTP' do
let(:new_otp_secret) { ROTP::Base32.random(User.otp_secret_length) }
before do
allow(User).to receive(:generate_otp_secret).and_return(new_otp_secret)
user.role.update!(require_2fa: true)
end
it 'when accepting the authorization request' do
subject
# It presents the user with the 2FA setup page
expect(page).to have_content(I18n.t('two_factor_authentication.role_requirement', domain: local_domain_uri.host))
click_on I18n.t('otp_authentication.setup')
# Fill in challenge form
fill_in 'form_challenge_current_password', with: user.password
click_on I18n.t('challenge.confirm')
# It presents the user with the TOTP confirmation screen
expect(page).to have_title(I18n.t('settings.two_factor_authentication'))
fill_in 'form_two_factor_confirmation_otp_attempt', with: ROTP::TOTP.new(new_otp_secret).at(Time.now.utc)
click_on I18n.t('otp_authentication.enable')
# It presents the user with recovery codes
click_on I18n.t('two_factor_authentication.resume_app_authorization')
# It presents the user with an authorization page
expect(page).to have_content(oauth_authorize_text)
# It grants the app access to the account
expect { click_on oauth_authorize_text }
.to change { user_has_grant_with_client_app? }.to(true)
# Upon authorizing, it redirects to the apps' callback URL
expect(page).to redirect_to_callback_url
end
end
end
end