mirror of
https://github.com/mastodon/mastodon.git
synced 2026-02-21 08:39:07 +00:00
Add ability to require 2FA for specific roles (including Everybody) (#37701)
This commit is contained in:
parent
3e1127d27b
commit
dfe44bcaef
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user