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 def resource_params
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 end
end end

View File

@ -61,19 +61,25 @@ class ApplicationController < ActionController::Base
return if request.referer.blank? return if request.referer.blank?
redirect_uri = URI(request.referer) 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 stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port
store_location_for(:user, stored_url) store_location_for(:user, stored_url)
end end
def mfa_setup_path(path_params = {})
settings_two_factor_authentication_methods_path(path_params)
end
def require_functional! def require_functional!
return if current_user.functional? return if current_user.functional?
respond_to do |format| respond_to do |format|
format.any do 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 redirect_to edit_user_registration_path
else else
redirect_to auth_setup_path 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 render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved? elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403 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? elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403 render json: { error: 'Your login is currently disabled' }, status: 403
end end

View File

@ -42,7 +42,7 @@ module ChallengableConcern
end end
def render_challenge def render_challenge
render 'auth/challenges/new', layout: 'auth' render 'auth/challenges/new', layout: params[:oauth] ? 'modal' : 'auth'
end end
def challenge_passed? def challenge_passed?

View File

@ -24,4 +24,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
def truthy_param?(key) def truthy_param?(key)
ActiveModel::Type::Boolean.new.cast(params[key]) ActiveModel::Type::Boolean.new.cast(params[key])
end end
def mfa_setup_path
super({ oauth: true })
end
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 module TwoFactorAuthentication
class ConfirmationsController < BaseController class ConfirmationsController < BaseController
include ChallengableConcern include ChallengableConcern
include Devise::Controllers::StoreLocation
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_challenge! before_action :require_challenge!
before_action :ensure_otp_secret before_action :ensure_otp_secret
helper_method :return_to_app_url
def new def new
prepare_two_factor_form prepare_two_factor_form
end end
@ -37,6 +40,10 @@ module Settings
private private
def return_to_app_url
stored_location_for(:user)
end
def confirmation_params def confirmation_params
params.expect(form_two_factor_confirmation: [:otp_attempt]) params.expect(form_two_factor_confirmation: [:otp_attempt])
end end

View File

@ -17,7 +17,7 @@ module Settings
def create def create
session[:new_otp_secret] = User.generate_otp_secret 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 end
private private

View File

@ -22,7 +22,7 @@ module Settings
private private
def require_otp_enabled 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 end
end end

View File

@ -226,7 +226,11 @@ class User < ApplicationRecord
end end
def functional_or_moved? 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 end
def unconfirmed_or_pending? def unconfirmed_or_pending?

View File

@ -5,11 +5,12 @@
# Table name: user_roles # Table name: user_roles
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# name :string default(""), not null
# color :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 # 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 # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #
@ -160,7 +161,7 @@ class UserRole < ApplicationRecord
@computed_permissions ||= begin @computed_permissions ||= begin
permissions = self.class.everyone.permissions | self.permissions permissions = self.class.everyone.permissions | self.permissions
if permissions & FLAGS[:administrator] == FLAGS[:administrator] if administrator?
Flags::ALL Flags::ALL
else else
permissions permissions
@ -172,6 +173,10 @@ class UserRole < ApplicationRecord
name name
end end
def administrator?
permissions & FLAGS[:administrator] == FLAGS[:administrator]
end
private private
def in_permissions?(privilege) def in_permissions?(privilege)
@ -189,6 +194,7 @@ class UserRole < ApplicationRecord
errors.add(:permissions_as_keys, :own_role) if permissions_changed? errors.add(:permissions_as_keys, :own_role) if permissions_changed?
errors.add(:position, :own_role) if position_changed? errors.add(:position, :own_role) if position_changed?
errors.add(:require_2fa, :own_role) if require_2fa_changed? && !administrator?
end end
def validate_permissions_elevation def validate_permissions_elevation

View File

@ -25,6 +25,11 @@
= form.input :highlighted, = form.input :highlighted,
wrapper: :with_label wrapper: :with_label
- if current_user.role.administrator? || current_user.role != form.object
.fields-group
= form.input :require_2fa,
wrapper: :with_label
%hr.spacer/ %hr.spacer/
- unless current_user.role == form.object - unless current_user.role == form.object

View File

@ -21,9 +21,13 @@
.announcements-list__item__meta .announcements-list__item__meta
- if role.everyone? - if role.everyone?
= t('admin.roles.everyone_full_description_html') = t('admin.roles.everyone_full_description_html')
= t('admin.roles.requires_2fa') if role.require_2fa?
- else - else
= link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id) = 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) %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 .announcements-list__item__actions
= table_link_to 'edit', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role) = 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(' ') %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
.fields-group .fields-group
= hidden_field_tag :oauth, params[:oauth]
= f.input :otp_attempt, = f.input :otp_attempt,
hint: t('otp_authentication.code_hint'), hint: t('otp_authentication.code_hint'),
input_html: { autocomplete: 'off' }, input_html: { autocomplete: 'off' },

View File

@ -1,9 +1,12 @@
- content_for :page_title do - content_for :page_title do
= t('settings.two_factor_authentication') = t('settings.two_factor_authentication')
- if current_user.role.require_2fa?
.flash-message= t('two_factor_authentication.role_requirement', domain: site_hostname)
.simple_form .simple_form
%p.hint= t('otp_authentication.description_html') %p.hint= t('otp_authentication.description_html')
%hr.spacer/ %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| - @recovery_codes.each do |code|
%li< %li<
%samp= code %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') = t('settings.two_factor_authentication')
- content_for :heading_actions do - 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 %p.hint
%span.positive-hint %span.positive-hint

View File

@ -802,6 +802,7 @@ en:
view_devops_description: Allows users to access Sidekiq and pgHero dashboards view_devops_description: Allows users to access Sidekiq and pgHero dashboards
view_feeds: View live and topic feeds view_feeds: View live and topic feeds
view_feeds_description: Allows users to access the live and topic feeds regardless of server settings view_feeds_description: Allows users to access the live and topic feeds regardless of server settings
requires_2fa: Requires two-factor authentication
title: Roles title: Roles
rules: rules:
add_new: Add rule add_new: Add rule
@ -2047,6 +2048,8 @@ en:
recovery_codes: Backup recovery codes recovery_codes: Backup recovery codes
recovery_codes_regenerated: Recovery codes successfully regenerated 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. 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 webauthn: Security keys
user_mailer: user_mailer:
announcement_published: 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 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... 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 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: username_block:
allow_with_approval: Instead of preventing sign-up outright, matching sign-ups will require your approval 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 comparison: Please be mindful of the Scunthorpe Problem when blocking partial matches
@ -387,6 +388,7 @@ en:
name: Name name: Name
permissions_as_keys: Permissions permissions_as_keys: Permissions
position: Priority position: Priority
require_2fa: Require two-factor authentication
username_block: username_block:
allow_with_approval: Allow registrations with approval allow_with_approval: Allow registrations with approval
comparison: Method of comparison 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" 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.boolean "highlighted", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "require_2fa", default: false, null: false
end end
create_table "username_blocks", force: :cascade do |t| 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(: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(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 end
end end

View File

@ -13,16 +13,19 @@ RSpec.describe 'Log in' do
before do before do
as_a_registered_user as_a_registered_user
visit new_user_session_path
end end
it 'A valid email and password user is able to log in' do it 'A valid email and password user is able to log in' do
visit new_user_session_path
fill_in_auth_details(email, password) fill_in_auth_details(email, password)
expect(subject).to have_css('div.app-holder') expect(subject).to have_css('div.app-holder')
end end
it 'A invalid email and password user is not able to log in' do 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') fill_in_auth_details('invalid_email', 'invalid_password')
expect(subject).to have_css('.flash-message', text: /#{failure_message_invalid}/i) 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 } let(:confirmed_at) { nil }
it 'A unconfirmed user is able to log in' do it 'A unconfirmed user is able to log in' do
visit new_user_session_path
fill_in_auth_details(email, password) fill_in_auth_details(email, password)
expect(subject).to have_css('.title', text: I18n.t('auth.setup.title')) expect(subject).to have_css('.title', text: I18n.t('auth.setup.title'))
end end
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 def failure_message_invalid
keys = User.authentication_keys.map { |key| User.human_attribute_name(key) } keys = User.authentication_keys.map { |key| User.human_attribute_name(key) }
I18n.t('devise.failure.invalid', authentication_keys: keys.join('support.array.words_connector')) 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 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
end end