mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-06 18:01:05 +00:00
Compare commits
38 Commits
adeae58b61
...
d4d5ae215b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d4d5ae215b | ||
![]() |
14cb5ff881 | ||
![]() |
bc952ebde9 | ||
![]() |
c1542643f5 | ||
![]() |
733587c4bf | ||
![]() |
e259a67151 | ||
![]() |
a5b2b564ab | ||
![]() |
6d82a3d2a5 | ||
![]() |
879fd3406e | ||
![]() |
50298a1ce1 | ||
![]() |
d114645e60 | ||
![]() |
8bd9d60cdf | ||
![]() |
cf1db1a025 | ||
![]() |
4ce99fcf55 | ||
![]() |
1f6f3bf01a | ||
![]() |
7af438159a | ||
![]() |
7e1eeff268 | ||
![]() |
2b98d29942 | ||
![]() |
6a40447759 | ||
![]() |
63d0efa0c2 | ||
![]() |
bdf102df4e | ||
![]() |
e5f4362b8f | ||
![]() |
26081d66fd | ||
![]() |
a8f5e3fa62 | ||
![]() |
f5c14b753a | ||
![]() |
1073956fbc | ||
![]() |
673d875a95 | ||
![]() |
7a957b1f49 | ||
![]() |
6fda7a9f56 | ||
![]() |
1c52aa76eb | ||
![]() |
df97c75d34 | ||
![]() |
659c695a5a | ||
![]() |
86e38dc4f6 | ||
![]() |
7b1f26eea2 | ||
![]() |
6cc6c36c44 | ||
![]() |
16b19f48cc | ||
![]() |
4e1d61ecd0 | ||
![]() |
218a9e70c5 |
|
@ -109,3 +109,6 @@ FETCH_REPLIES_MAX_SINGLE=500
|
||||||
|
|
||||||
# Max number of replies Collection pages to fetch - total
|
# Max number of replies Collection pages to fetch - total
|
||||||
FETCH_REPLIES_MAX_PAGES=500
|
FETCH_REPLIES_MAX_PAGES=500
|
||||||
|
|
||||||
|
# MFA Required for Users
|
||||||
|
REQUIRE_MULTI_FACTOR_AUTH=false
|
|
@ -14,6 +14,7 @@ class ApplicationController < ActionController::Base
|
||||||
include DatabaseHelper
|
include DatabaseHelper
|
||||||
include AuthorizedFetchHelper
|
include AuthorizedFetchHelper
|
||||||
include SelfDestructHelper
|
include SelfDestructHelper
|
||||||
|
include MfaForceConcern
|
||||||
|
|
||||||
helper_method :current_account
|
helper_method :current_account
|
||||||
helper_method :current_session
|
helper_method :current_session
|
||||||
|
|
|
@ -18,6 +18,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
|
|
||||||
skip_before_action :check_self_destruct!, only: [:edit, :update]
|
skip_before_action :check_self_destruct!, only: [:edit, :update]
|
||||||
skip_before_action :require_functional!, only: [:edit, :update]
|
skip_before_action :require_functional!, only: [:edit, :update]
|
||||||
|
skip_before_action :check_mfa_requirement, only: [:edit, :update]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
super(&:build_invite_request)
|
super(&:build_invite_request)
|
||||||
|
@ -100,12 +101,13 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_invite
|
def set_invite
|
||||||
@invite = begin
|
@invite = begin
|
||||||
invite = Invite.find_by(code: invite_code) if invite_code.present?
|
if invite_code.present?
|
||||||
invite if invite&.valid_for_use?
|
Invite.find_by(code: invite_code)
|
||||||
|
elsif params[:invite_code].present?
|
||||||
|
Invite.find_by(code: params[:invite_code])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -132,15 +134,13 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
def require_rules_acceptance!
|
def require_rules_acceptance!
|
||||||
return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token])
|
return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token])
|
||||||
|
|
||||||
@accept_token = session[:accept_token] = SecureRandom.hex
|
session[:accept_token] = SecureRandom.hex(16)
|
||||||
@invite_code = invite_code
|
redirect_to new_user_registration_path(accept: session[:accept_token])
|
||||||
|
|
||||||
set_locale { render :rules }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix
|
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix
|
||||||
if params[:action] == 'create'
|
if params[:action] == 'create'
|
||||||
false # Disable flash messages for sign-up
|
false
|
||||||
else
|
else
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
skip_before_action :require_no_authentication, only: [:create]
|
skip_before_action :require_no_authentication, only: [:create]
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
skip_before_action :update_user_sign_in
|
skip_before_action :update_user_sign_in
|
||||||
|
skip_before_action :check_mfa_requirement, only: [:destroy]
|
||||||
|
|
||||||
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
||||||
|
|
||||||
|
@ -199,12 +200,8 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
|
|
||||||
def respond_to_on_destroy
|
def respond_to_on_destroy
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.json do
|
format.any(*navigational_formats) { redirect_to after_sign_out_path_for(:user) }
|
||||||
render json: {
|
format.all { head 204 }
|
||||||
redirect_to: after_sign_out_path_for(resource_name),
|
|
||||||
}, status: 200
|
|
||||||
end
|
|
||||||
format.all { super }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,6 +8,7 @@ class Auth::SetupController < ApplicationController
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
skip_before_action :check_mfa_requirement
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
|
|
27
app/controllers/concerns/mfa_force_concern.rb
Normal file
27
app/controllers/concerns/mfa_force_concern.rb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module MfaForceConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
prepend_before_action :check_mfa_requirement, if: :user_signed_in?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_mfa_requirement
|
||||||
|
return unless mfa_force_enabled?
|
||||||
|
return if current_user.otp_enabled?
|
||||||
|
|
||||||
|
flash[:alert] = I18n.t('require_multi_factor_auth.required_message')
|
||||||
|
redirect_to settings_otp_authentication_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def mfa_force_enabled?
|
||||||
|
mfa_config[:force_enabled]
|
||||||
|
end
|
||||||
|
|
||||||
|
def mfa_config
|
||||||
|
@mfa_config ||= Rails.application.config_for(:mfa)
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,6 +6,7 @@ module Settings
|
||||||
include ChallengableConcern
|
include ChallengableConcern
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
skip_before_action :check_mfa_requirement
|
||||||
|
|
||||||
before_action :require_challenge!
|
before_action :require_challenge!
|
||||||
before_action :ensure_otp_secret
|
before_action :ensure_otp_secret
|
||||||
|
|
|
@ -6,6 +6,7 @@ module Settings
|
||||||
include ChallengableConcern
|
include ChallengableConcern
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
skip_before_action :check_mfa_requirement
|
||||||
|
|
||||||
before_action :verify_otp_not_enabled, only: [:show]
|
before_action :verify_otp_not_enabled, only: [:show]
|
||||||
before_action :require_challenge!, only: [:create]
|
before_action :require_challenge!, only: [:create]
|
||||||
|
|
|
@ -6,6 +6,7 @@ module Settings
|
||||||
|
|
||||||
skip_before_action :check_self_destruct!
|
skip_before_action :check_self_destruct!
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
skip_before_action :check_mfa_requirement
|
||||||
|
|
||||||
before_action :require_challenge!, only: :disable
|
before_action :require_challenge!, only: :disable
|
||||||
before_action :require_otp_enabled
|
before_action :require_otp_enabled
|
||||||
|
|
|
@ -8,6 +8,7 @@ const meta = {
|
||||||
component: Button,
|
component: Button,
|
||||||
args: {
|
args: {
|
||||||
secondary: false,
|
secondary: false,
|
||||||
|
plain: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
dangerous: false,
|
dangerous: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
@ -57,6 +58,14 @@ export const Secondary: Story = {
|
||||||
play: buttonTest,
|
play: buttonTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Plain: Story = {
|
||||||
|
args: {
|
||||||
|
plain: true,
|
||||||
|
children: 'Plain button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
export const Compact: Story = {
|
export const Compact: Story = {
|
||||||
args: {
|
args: {
|
||||||
compact: true,
|
compact: true,
|
||||||
|
@ -101,6 +110,14 @@ export const SecondaryDisabled: Story = {
|
||||||
play: disabledButtonTest,
|
play: disabledButtonTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PlainDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Plain.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
play: disabledButtonTest,
|
||||||
|
};
|
||||||
|
|
||||||
const loadingButtonTest: Story['play'] = async ({
|
const loadingButtonTest: Story['play'] = async ({
|
||||||
args,
|
args,
|
||||||
canvas,
|
canvas,
|
||||||
|
|
|
@ -9,6 +9,7 @@ interface BaseProps
|
||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
plain?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
|
||||||
disabled,
|
disabled,
|
||||||
block,
|
block,
|
||||||
secondary,
|
secondary,
|
||||||
|
plain,
|
||||||
compact,
|
compact,
|
||||||
dangerous,
|
dangerous,
|
||||||
loading,
|
loading,
|
||||||
|
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
|
||||||
<button
|
<button
|
||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
'button-secondary': secondary,
|
'button-secondary': secondary,
|
||||||
|
'button--plain': plain,
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
'button--dangerous': dangerous,
|
'button--dangerous': dangerous,
|
||||||
|
|
|
@ -201,6 +201,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.button--plain {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px;
|
||||||
|
|
||||||
|
// The button has no outline, so we use negative margin to
|
||||||
|
// visually align its label with its surroundings while maintaining
|
||||||
|
// a generous click target
|
||||||
|
margin-inline: -6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
color: lighten($highlight-text-color, 4%);
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-color: transparent;
|
||||||
|
color: $ui-button-disabled-color;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
color: $ui-button-disabled-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.button-tertiary {
|
&.button-tertiary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 17px;
|
padding: 6px 17px;
|
||||||
|
|
|
@ -232,6 +232,15 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
canQuote: {
|
canQuote: {
|
||||||
automaticApproval: approved_uris,
|
automaticApproval: approved_uris,
|
||||||
},
|
},
|
||||||
|
canReply: {
|
||||||
|
always: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
},
|
||||||
|
canLike: {
|
||||||
|
always: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
},
|
||||||
|
canAnnounce: {
|
||||||
|
always: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -2151,3 +2151,7 @@ en:
|
||||||
not_supported: This browser doesn't support security keys
|
not_supported: This browser doesn't support security keys
|
||||||
otp_required: To use security keys please enable two-factor authentication first.
|
otp_required: To use security keys please enable two-factor authentication first.
|
||||||
registered_on: Registered on %{date}
|
registered_on: Registered on %{date}
|
||||||
|
|
||||||
|
require_multi_factor_auth:
|
||||||
|
required_message: The administrator of this site has configured as mandatory that users enable two-factor authentication due to security policies. Please configure your two-factor authentication to continue using the platform.
|
||||||
|
security_policy: Security Policy Requirement
|
||||||
|
|
|
@ -2149,4 +2149,4 @@ es:
|
||||||
not_enabled: Aún no has activado WebAuthn
|
not_enabled: Aún no has activado WebAuthn
|
||||||
not_supported: Este navegador no soporta claves de seguridad
|
not_supported: Este navegador no soporta claves de seguridad
|
||||||
otp_required: Para usar claves de seguridad, por favor habilite primero la autenticación de doble factor.
|
otp_required: Para usar claves de seguridad, por favor habilite primero la autenticación de doble factor.
|
||||||
registered_on: Registrado el %{date}
|
registered_on: Registrado el %{date}
|
15
config/mfa.yml
Normal file
15
config/mfa.yml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# Multi-Factor Authentication configuration
|
||||||
|
default: &default
|
||||||
|
force_enabled: false
|
||||||
|
|
||||||
|
development:
|
||||||
|
<<: *default
|
||||||
|
force_enabled: <%= ENV.fetch('REQUIRE_MULTI_FACTOR_AUTH', 'false') == 'true' %>
|
||||||
|
|
||||||
|
test:
|
||||||
|
<<: *default
|
||||||
|
force_enabled: false
|
||||||
|
|
||||||
|
production:
|
||||||
|
<<: *default
|
||||||
|
force_enabled: <%= ENV.fetch('REQUIRE_MULTI_FACTOR_AUTH', 'false') == 'true' %>
|
111
spec/controllers/concerns/mfa_force_concern_spec.rb
Normal file
111
spec/controllers/concerns/mfa_force_concern_spec.rb
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe MfaForceConcern do
|
||||||
|
controller(ApplicationController) do
|
||||||
|
def index
|
||||||
|
render plain: 'OK'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
routes.draw { get 'index' => 'anonymous#index' }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'MFA force functionality' do
|
||||||
|
context 'when REQUIRE_MULTI_FACTOR_AUTH is enabled' do
|
||||||
|
before do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
sign_in user, scope: :user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user has MFA enabled' do
|
||||||
|
before do
|
||||||
|
user.update(otp_required_for_login: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access to normal pages' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user does not have MFA enabled' do
|
||||||
|
before do
|
||||||
|
user.update(otp_required_for_login: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects to MFA setup page' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
get :index
|
||||||
|
expect(response).to redirect_to(settings_otp_authentication_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows the required message' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
get :index
|
||||||
|
expect(flash[:alert]).to eq(I18n.t('require_multi_factor_auth.required_message'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when accessing MFA setup pages' do
|
||||||
|
it 'allows access to OTP authentication page' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
allow(controller.request).to receive(:path).and_return('/settings/otp_authentication')
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access to MFA confirmation page' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
allow(controller.request).to receive(:path).and_return('/settings/two_factor_authentication/confirmation')
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access to logout' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
allow(controller.request).to receive(:path).and_return('/auth/sign_out')
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when REQUIRE_MULTI_FACTOR_AUTH is disabled' do
|
||||||
|
before do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'false') do
|
||||||
|
sign_in user, scope: :user
|
||||||
|
user.update(otp_required_for_login: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access to normal pages' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'false') do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not signed in' do
|
||||||
|
it 'allows access to normal pages' do
|
||||||
|
ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -11957,8 +11957,8 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"sass@npm:^1.62.1":
|
"sass@npm:^1.62.1":
|
||||||
version: 1.91.0
|
version: 1.92.0
|
||||||
resolution: "sass@npm:1.91.0"
|
resolution: "sass@npm:1.92.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@parcel/watcher": "npm:^2.4.1"
|
"@parcel/watcher": "npm:^2.4.1"
|
||||||
chokidar: "npm:^4.0.0"
|
chokidar: "npm:^4.0.0"
|
||||||
|
@ -11969,7 +11969,7 @@ __metadata:
|
||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
sass: sass.js
|
sass: sass.js
|
||||||
checksum: 10c0/5be1c98f7a618cb5f90b62f63d2aa0f78f9bf369c93ec7cd9880752a26b0ead19aa63cc341e8a26ce6c74d080baa5705f1685dff52fe6a3f28a7828ae50182b6
|
checksum: 10c0/bdff9fa6988620e2a81962efdd016e3894d19934cfadc105cf41db767f59dd47afd8ff32840e612ef700cb67e19d9e83c108f1724eb8f0bef56c4877dbe6f14d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user