mirror of
https://git.bsd.gay/fef/nyastodon.git
synced 2024-12-24 19:23:41 +01:00
Add password challenge to 2FA settings, e-mail notifications (#11878)
Fix #3961
This commit is contained in:
parent
d0c2c52783
commit
e1066cd431
32 changed files with 567 additions and 50 deletions
|
@ -8,6 +8,7 @@ module Admin
|
||||||
authorize @user, :disable_2fa?
|
authorize @user, :disable_2fa?
|
||||||
@user.disable_two_factor!
|
@user.disable_two_factor!
|
||||||
log_action :disable_2fa, @user
|
log_action :disable_2fa, @user
|
||||||
|
UserMailer.two_factor_disabled(@user).deliver_later!
|
||||||
redirect_to admin_accounts_path
|
redirect_to admin_accounts_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
22
app/controllers/auth/challenges_controller.rb
Normal file
22
app/controllers/auth/challenges_controller.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Auth::ChallengesController < ApplicationController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
|
layout 'auth'
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
|
def create
|
||||||
|
if challenge_passed?
|
||||||
|
session[:challenge_passed_at] = Time.now.utc
|
||||||
|
redirect_to challenge_params[:return_to]
|
||||||
|
else
|
||||||
|
@challenge = Form::Challenge.new(return_to: challenge_params[:return_to])
|
||||||
|
flash.now[:alert] = I18n.t('challenge.invalid_password')
|
||||||
|
render_challenge
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -42,6 +42,7 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
def destroy
|
def destroy
|
||||||
tmp_stored_location = stored_location_for(:user)
|
tmp_stored_location = stored_location_for(:user)
|
||||||
super
|
super
|
||||||
|
session.delete(:challenge_passed_at)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
store_location_for(:user, tmp_stored_location) if continue_after?
|
store_location_for(:user, tmp_stored_location) if continue_after?
|
||||||
end
|
end
|
||||||
|
|
65
app/controllers/concerns/challengable_concern.rb
Normal file
65
app/controllers/concerns/challengable_concern.rb
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# This concern is inspired by "sudo mode" on GitHub. It
|
||||||
|
# is a way to re-authenticate a user before allowing them
|
||||||
|
# to see or perform an action.
|
||||||
|
#
|
||||||
|
# Add `before_action :require_challenge!` to actions you
|
||||||
|
# want to protect.
|
||||||
|
#
|
||||||
|
# The user will be shown a page to enter the challenge (which
|
||||||
|
# is either the password, or just the username when no
|
||||||
|
# password exists). Upon passing, there is a grace period
|
||||||
|
# during which no challenge will be asked from the user.
|
||||||
|
#
|
||||||
|
# Accessing challenge-protected resources during the grace
|
||||||
|
# period will refresh the grace period.
|
||||||
|
module ChallengableConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
CHALLENGE_TIMEOUT = 1.hour.freeze
|
||||||
|
|
||||||
|
def require_challenge!
|
||||||
|
return if skip_challenge?
|
||||||
|
|
||||||
|
if challenge_passed_recently?
|
||||||
|
session[:challenge_passed_at] = Time.now.utc
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@challenge = Form::Challenge.new(return_to: request.url)
|
||||||
|
|
||||||
|
if params.key?(:form_challenge)
|
||||||
|
if challenge_passed?
|
||||||
|
session[:challenge_passed_at] = Time.now.utc
|
||||||
|
return
|
||||||
|
else
|
||||||
|
flash.now[:alert] = I18n.t('challenge.invalid_password')
|
||||||
|
render_challenge
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render_challenge
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_challenge
|
||||||
|
@body_classes = 'lighter'
|
||||||
|
render template: 'auth/challenges/new', layout: 'auth'
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_passed?
|
||||||
|
current_user.valid_password?(challenge_params[:current_password])
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_challenge?
|
||||||
|
current_user.encrypted_password.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_passed_recently?
|
||||||
|
session[:challenge_passed_at].present? && session[:challenge_passed_at] >= CHALLENGE_TIMEOUT.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_params
|
||||||
|
params.require(:form_challenge).permit(:current_password, :return_to)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,9 +3,12 @@
|
||||||
module Settings
|
module Settings
|
||||||
module TwoFactorAuthentication
|
module TwoFactorAuthentication
|
||||||
class ConfirmationsController < BaseController
|
class ConfirmationsController < BaseController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_challenge!
|
||||||
before_action :ensure_otp_secret
|
before_action :ensure_otp_secret
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
@ -22,6 +25,8 @@ module Settings
|
||||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
|
||||||
|
UserMailer.two_factor_enabled(current_user).deliver_later!
|
||||||
|
|
||||||
render 'settings/two_factor_authentication/recovery_codes/index'
|
render 'settings/two_factor_authentication/recovery_codes/index'
|
||||||
else
|
else
|
||||||
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
||||||
|
|
|
@ -3,16 +3,22 @@
|
||||||
module Settings
|
module Settings
|
||||||
module TwoFactorAuthentication
|
module TwoFactorAuthentication
|
||||||
class RecoveryCodesController < BaseController
|
class RecoveryCodesController < BaseController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_challenge!, on: :create
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
|
||||||
|
UserMailer.two_factor_recovery_codes_changed(current_user).deliver_later!
|
||||||
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
|
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
|
||||||
|
|
||||||
render :index
|
render :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,10 +2,13 @@
|
||||||
|
|
||||||
module Settings
|
module Settings
|
||||||
class TwoFactorAuthenticationsController < BaseController
|
class TwoFactorAuthenticationsController < BaseController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :verify_otp_required, only: [:create]
|
before_action :verify_otp_required, only: [:create]
|
||||||
|
before_action :require_challenge!, only: [:create]
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
|
@ -23,6 +26,7 @@ module Settings
|
||||||
if acceptable_code?
|
if acceptable_code?
|
||||||
current_user.otp_required_for_login = false
|
current_user.otp_required_for_login = false
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
UserMailer.two_factor_disabled(current_user).deliver_later!
|
||||||
redirect_to settings_two_factor_authentication_path
|
redirect_to settings_two_factor_authentication_path
|
||||||
else
|
else
|
||||||
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
||||||
|
|
|
@ -233,32 +233,35 @@ hr.spacer {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted-hint {
|
body,
|
||||||
color: $darker-text-color;
|
.admin-wrapper .content {
|
||||||
|
.muted-hint {
|
||||||
|
color: $darker-text-color;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: $highlight-text-color;
|
color: $highlight-text-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.positive-hint {
|
.positive-hint {
|
||||||
color: $valid-value-color;
|
color: $valid-value-color;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.negative-hint {
|
.negative-hint {
|
||||||
color: $error-value-color;
|
color: $error-value-color;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neutral-hint {
|
.neutral-hint {
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-hint {
|
.warning-hint {
|
||||||
color: $gold-star;
|
color: $gold-star;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|
|
@ -254,6 +254,10 @@ code {
|
||||||
&-6 {
|
&-6 {
|
||||||
max-width: 50%;
|
max-width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 27px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields-group:last-child,
|
.fields-group:last-child,
|
||||||
|
|
|
@ -57,6 +57,39 @@ class UserMailer < Devise::Mailer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def two_factor_enabled(user, **)
|
||||||
|
@resource = user
|
||||||
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
||||||
|
return if @resource.disabled?
|
||||||
|
|
||||||
|
I18n.with_locale(@resource.locale || I18n.default_locale) do
|
||||||
|
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def two_factor_disabled(user, **)
|
||||||
|
@resource = user
|
||||||
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
||||||
|
return if @resource.disabled?
|
||||||
|
|
||||||
|
I18n.with_locale(@resource.locale || I18n.default_locale) do
|
||||||
|
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def two_factor_recovery_codes_changed(user, **)
|
||||||
|
@resource = user
|
||||||
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
||||||
|
return if @resource.disabled?
|
||||||
|
|
||||||
|
I18n.with_locale(@resource.locale || I18n.default_locale) do
|
||||||
|
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def welcome(user)
|
def welcome(user)
|
||||||
@resource = user
|
@resource = user
|
||||||
@instance = Rails.configuration.x.local_domain
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
8
app/models/form/challenge.rb
Normal file
8
app/models/form/challenge.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Form::Challenge
|
||||||
|
include ActiveModel::Model
|
||||||
|
|
||||||
|
attr_accessor :current_password, :current_username,
|
||||||
|
:return_to
|
||||||
|
end
|
|
@ -264,17 +264,20 @@ class User < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def password_required?
|
def password_required?
|
||||||
return false if Devise.pam_authentication || Devise.ldap_authentication
|
return false if external?
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_reset_password_instructions
|
def send_reset_password_instructions
|
||||||
return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication)
|
return false if encrypted_password.blank?
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_password!(new_password, new_password_confirmation)
|
def reset_password!(new_password, new_password_confirmation)
|
||||||
return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication)
|
return false if encrypted_password.blank?
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
15
app/views/auth/challenges/new.html.haml
Normal file
15
app/views/auth/challenges/new.html.haml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('challenge.prompt')
|
||||||
|
|
||||||
|
= simple_form_for @challenge, url: request.get? ? auth_challenge_path : '' do |f|
|
||||||
|
= f.input :return_to, as: :hidden
|
||||||
|
|
||||||
|
.field-group
|
||||||
|
= f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off', :autofocus => true }, label: t('challenge.prompt'), required: true
|
||||||
|
|
||||||
|
.actions
|
||||||
|
= f.button :button, t('challenge.confirm'), type: :submit
|
||||||
|
|
||||||
|
%p.hint.subtle-hint= t('challenge.hint_html')
|
||||||
|
|
||||||
|
.form-footer= render 'auth/shared/links'
|
|
@ -11,7 +11,7 @@
|
||||||
- if controller_name != 'passwords' && controller_name != 'registrations'
|
- if controller_name != 'passwords' && controller_name != 'registrations'
|
||||||
%li= link_to t('auth.forgot_password'), new_user_password_path
|
%li= link_to t('auth.forgot_password'), new_user_password_path
|
||||||
|
|
||||||
- if controller_name != 'confirmations'
|
- if controller_name != 'confirmations' && (!user_signed_in? || !current_user.confirmed? || current_user.unconfirmed_email.present?)
|
||||||
%li= link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
|
%li= link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
|
||||||
|
|
||||||
- if user_signed_in? && controller_name != 'setup'
|
- if user_signed_in? && controller_name != 'setup'
|
||||||
|
|
|
@ -2,33 +2,35 @@
|
||||||
= t('settings.two_factor_authentication')
|
= t('settings.two_factor_authentication')
|
||||||
|
|
||||||
- if current_user.otp_required_for_login
|
- if current_user.otp_required_for_login
|
||||||
%p.positive-hint
|
%p.hint
|
||||||
= fa_icon 'check'
|
%span.positive-hint
|
||||||
= ' '
|
= fa_icon 'check'
|
||||||
= t 'two_factor_authentication.enabled'
|
= ' '
|
||||||
|
= t 'two_factor_authentication.enabled'
|
||||||
|
|
||||||
%hr/
|
%hr.spacer/
|
||||||
|
|
||||||
= simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
|
= simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
|
||||||
= f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
|
.fields-group
|
||||||
|
= f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= f.button :button, t('two_factor_authentication.disable'), type: :submit
|
= f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative'
|
||||||
|
|
||||||
%hr/
|
%hr.spacer/
|
||||||
|
|
||||||
%h6= t('two_factor_authentication.recovery_codes')
|
%h3= t('two_factor_authentication.recovery_codes')
|
||||||
%p.muted-hint
|
%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
|
||||||
= t('two_factor_authentication.lost_recovery_codes')
|
|
||||||
= link_to t('two_factor_authentication.generate_recovery_codes'),
|
%hr.spacer/
|
||||||
settings_two_factor_authentication_recovery_codes_path,
|
|
||||||
data: { method: :post }
|
.simple_form
|
||||||
|
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
|
||||||
|
|
||||||
- else
|
- else
|
||||||
.simple_form
|
.simple_form
|
||||||
%p.hint= t('two_factor_authentication.description_html')
|
%p.hint= t('two_factor_authentication.description_html')
|
||||||
|
|
||||||
= link_to t('two_factor_authentication.setup'),
|
%hr.spacer/
|
||||||
settings_two_factor_authentication_path,
|
|
||||||
data: { method: :post },
|
= link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button'
|
||||||
class: 'block-button'
|
|
||||||
|
|
43
app/views/user_mailer/two_factor_disabled.html.haml
Normal file
43
app/views/user_mailer/two_factor_disabled.html.haml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.email-body
|
||||||
|
.email-container
|
||||||
|
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.content-cell.hero
|
||||||
|
.email-row
|
||||||
|
.col-6
|
||||||
|
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.column-cell.text-center.padded
|
||||||
|
%table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td
|
||||||
|
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
|
||||||
|
|
||||||
|
%h1= t 'devise.mailer.two_factor_disabled.title'
|
||||||
|
%p.lead= t 'devise.mailer.two_factor_disabled.explanation'
|
||||||
|
|
||||||
|
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.email-body
|
||||||
|
.email-container
|
||||||
|
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.content-cell.content-start
|
||||||
|
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.column-cell.button-cell
|
||||||
|
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.button-primary
|
||||||
|
= link_to edit_user_registration_url do
|
||||||
|
%span= t('settings.account_settings')
|
7
app/views/user_mailer/two_factor_disabled.text.erb
Normal file
7
app/views/user_mailer/two_factor_disabled.text.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<%= t 'devise.mailer.two_factor_disabled.title' %>
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
<%= t 'devise.mailer.two_factor_disabled.explanation' %>
|
||||||
|
|
||||||
|
=> <%= edit_user_registration_url %>
|
43
app/views/user_mailer/two_factor_enabled.html.haml
Normal file
43
app/views/user_mailer/two_factor_enabled.html.haml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.email-body
|
||||||
|
.email-container
|
||||||
|
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.content-cell.hero
|
||||||
|
.email-row
|
||||||
|
.col-6
|
||||||
|
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.column-cell.text-center.padded
|
||||||
|
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td
|
||||||
|
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
|
||||||
|
|
||||||
|
%h1= t 'devise.mailer.two_factor_enabled.title'
|
||||||
|
%p.lead= t 'devise.mailer.two_factor_enabled.explanation'
|
||||||
|
|
||||||
|
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.email-body
|
||||||
|
.email-container
|
||||||
|
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.content-cell.content-start
|
||||||
|
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.column-cell.button-cell
|
||||||
|
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.button-primary
|
||||||
|
= link_to edit_user_registration_url do
|
||||||
|
%span= t('settings.account_settings')
|
7
app/views/user_mailer/two_factor_enabled.text.erb
Normal file
7
app/views/user_mailer/two_factor_enabled.text.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<%= t 'devise.mailer.two_factor_enabled.title' %>
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
<%= t 'devise.mailer.two_factor_enabled.explanation' %>
|
||||||
|
|
||||||
|
=> <%= edit_user_registration_url %>
|
|
@ -0,0 +1,43 @@
|
||||||
|
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.email-body
|
||||||
|
.email-container
|
||||||
|
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.content-cell.hero
|
||||||
|
.email-row
|
||||||
|
.col-6
|
||||||
|
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.column-cell.text-center.padded
|
||||||
|
%table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td
|
||||||
|
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
|
||||||
|
|
||||||
|
%h1= t 'devise.mailer.two_factor_recovery_codes_changed.title'
|
||||||
|
%p.lead= t 'devise.mailer.two_factor_recovery_codes_changed.explanation'
|
||||||
|
|
||||||
|
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.email-body
|
||||||
|
.email-container
|
||||||
|
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.content-cell.content-start
|
||||||
|
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.column-cell.button-cell
|
||||||
|
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%td.button-primary
|
||||||
|
= link_to edit_user_registration_url do
|
||||||
|
%span= t('settings.account_settings')
|
|
@ -0,0 +1,7 @@
|
||||||
|
<%= t 'devise.mailer.two_factor_recovery_codes_changed.title' %>
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
<%= t 'devise.mailer.two_factor_recovery_codes_changed.explanation' %>
|
||||||
|
|
||||||
|
=> <%= edit_user_registration_url %>
|
|
@ -46,6 +46,18 @@ en:
|
||||||
extra: If you didn't request this, please ignore this email. Your password won't change until you access the link above and create a new one.
|
extra: If you didn't request this, please ignore this email. Your password won't change until you access the link above and create a new one.
|
||||||
subject: 'Mastodon: Reset password instructions'
|
subject: 'Mastodon: Reset password instructions'
|
||||||
title: Password reset
|
title: Password reset
|
||||||
|
two_factor_disabled:
|
||||||
|
explanation: Two-factor authentication for your account has been disabled. Login is now possible using only e-mail address and password.
|
||||||
|
subject: 'Mastodon: Two-factor authentication disabled'
|
||||||
|
title: 2FA disabled
|
||||||
|
two_factor_enabled:
|
||||||
|
explanation: Two-factor authentication has been enabled for your account. A token generated by the paired TOTP app will be required for login.
|
||||||
|
subject: 'Mastodon: Two-factor authentication enabled'
|
||||||
|
title: 2FA enabled
|
||||||
|
two_factor_recovery_codes_changed:
|
||||||
|
explanation: The previous recovery codes have been invalidated and new ones generated.
|
||||||
|
subject: 'Mastodon: Two-factor recovery codes re-generated'
|
||||||
|
title: 2FA recovery codes changed
|
||||||
unlock_instructions:
|
unlock_instructions:
|
||||||
subject: 'Mastodon: Unlock instructions'
|
subject: 'Mastodon: Unlock instructions'
|
||||||
omniauth_callbacks:
|
omniauth_callbacks:
|
||||||
|
|
|
@ -621,6 +621,11 @@ en:
|
||||||
return: Show the user's profile
|
return: Show the user's profile
|
||||||
web: Go to web
|
web: Go to web
|
||||||
title: Follow %{acct}
|
title: Follow %{acct}
|
||||||
|
challenge:
|
||||||
|
confirm: Continue
|
||||||
|
hint_html: "<strong>Tip:</strong> We won't ask you for your password again for the next hour."
|
||||||
|
invalid_password: Invalid password
|
||||||
|
prompt: Confirm password to continue
|
||||||
datetime:
|
datetime:
|
||||||
distance_in_words:
|
distance_in_words:
|
||||||
about_x_hours: "%{count}h"
|
about_x_hours: "%{count}h"
|
||||||
|
|
|
@ -43,6 +43,8 @@ en:
|
||||||
domain: This domain will be able to fetch data from this server and incoming data from it will be processed and stored
|
domain: This domain will be able to fetch data from this server and incoming data from it will be processed and stored
|
||||||
featured_tag:
|
featured_tag:
|
||||||
name: 'You might want to use one of these:'
|
name: 'You might want to use one of these:'
|
||||||
|
form_challenge:
|
||||||
|
current_password: You are entering a secure area
|
||||||
imports:
|
imports:
|
||||||
data: CSV file exported from another Mastodon server
|
data: CSV file exported from another Mastodon server
|
||||||
invite_request:
|
invite_request:
|
||||||
|
|
|
@ -41,6 +41,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
namespace :auth do
|
namespace :auth do
|
||||||
resource :setup, only: [:show, :update], controller: :setup
|
resource :setup, only: [:show, :update], controller: :setup
|
||||||
|
resource :challenge, only: [:create], controller: :challenges
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
46
spec/controllers/auth/challenges_controller_spec.rb
Normal file
46
spec/controllers/auth/challenges_controller_spec.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Auth::ChallengesController, type: :controller do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:password) { 'foobar12345' }
|
||||||
|
let(:user) { Fabricate(:user, password: password) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #create' do
|
||||||
|
let(:return_to) { edit_user_registration_path }
|
||||||
|
|
||||||
|
context 'with correct password' do
|
||||||
|
before { post :create, params: { form_challenge: { return_to: return_to, current_password: password } } }
|
||||||
|
|
||||||
|
it 'redirects back' do
|
||||||
|
expect(response).to redirect_to(return_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets session' do
|
||||||
|
expect(session[:challenge_passed_at]).to_not be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with incorrect password' do
|
||||||
|
before { post :create, params: { form_challenge: { return_to: return_to, current_password: 'hhfggjjd562' } } }
|
||||||
|
|
||||||
|
it 'renders challenge' do
|
||||||
|
expect(response).to render_template('auth/challenges/new')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'displays error' do
|
||||||
|
expect(response.body).to include 'Invalid password'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not set session' do
|
||||||
|
expect(session[:challenge_passed_at]).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -80,7 +80,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
||||||
let(:user) do
|
let(:user) do
|
||||||
account = Fabricate.build(:account, username: 'pam_user1')
|
account = Fabricate.build(:account, username: 'pam_user1')
|
||||||
account.save!(validate: false)
|
account.save!(validate: false)
|
||||||
user = Fabricate(:user, email: 'pam@example.com', password: nil, account: account)
|
user = Fabricate(:user, email: 'pam@example.com', password: nil, account: account, external: true)
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
114
spec/controllers/concerns/challengable_concern_spec.rb
Normal file
114
spec/controllers/concerns/challengable_concern_spec.rb
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ChallengableConcern, type: :controller do
|
||||||
|
controller(ApplicationController) do
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
|
before_action :require_challenge!
|
||||||
|
|
||||||
|
def foo
|
||||||
|
render plain: 'foo'
|
||||||
|
end
|
||||||
|
|
||||||
|
def bar
|
||||||
|
render plain: 'bar'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
routes.draw do
|
||||||
|
get 'foo' => 'anonymous#foo'
|
||||||
|
post 'bar' => 'anonymous#bar'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a no-password user' do
|
||||||
|
let(:user) { Fabricate(:user, external: true, password: nil) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for GET requests' do
|
||||||
|
before { get :foo }
|
||||||
|
|
||||||
|
it 'does not ask for password' do
|
||||||
|
expect(response.body).to eq 'foo'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for POST requests' do
|
||||||
|
before { post :bar }
|
||||||
|
|
||||||
|
it 'does not ask for password' do
|
||||||
|
expect(response.body).to eq 'bar'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with recent challenge in session' do
|
||||||
|
let(:password) { 'foobar12345' }
|
||||||
|
let(:user) { Fabricate(:user, password: password) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for GET requests' do
|
||||||
|
before { get :foo, session: { challenge_passed_at: Time.now.utc } }
|
||||||
|
|
||||||
|
it 'does not ask for password' do
|
||||||
|
expect(response.body).to eq 'foo'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for POST requests' do
|
||||||
|
before { post :bar, session: { challenge_passed_at: Time.now.utc } }
|
||||||
|
|
||||||
|
it 'does not ask for password' do
|
||||||
|
expect(response.body).to eq 'bar'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a password user' do
|
||||||
|
let(:password) { 'foobar12345' }
|
||||||
|
let(:user) { Fabricate(:user, password: password) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for GET requests' do
|
||||||
|
before { get :foo }
|
||||||
|
|
||||||
|
it 'renders challenge' do
|
||||||
|
expect(response).to render_template('auth/challenges/new')
|
||||||
|
end
|
||||||
|
|
||||||
|
# See Auth::ChallengesControllerSpec
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for POST requests' do
|
||||||
|
before { post :bar }
|
||||||
|
|
||||||
|
it 'renders challenge' do
|
||||||
|
expect(response).to render_template('auth/challenges/new')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts correct password' do
|
||||||
|
post :bar, params: { form_challenge: { current_password: password } }
|
||||||
|
expect(response.body).to eq 'bar'
|
||||||
|
expect(session[:challenge_passed_at]).to_not be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects wrong password' do
|
||||||
|
post :bar, params: { form_challenge: { current_password: 'dddfff888123' } }
|
||||||
|
expect(response.body).to render_template('auth/challenges/new')
|
||||||
|
expect(session[:challenge_passed_at]).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -24,7 +24,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
|
||||||
context 'when signed in' do
|
context 'when signed in' do
|
||||||
subject do
|
subject do
|
||||||
sign_in user, scope: :user
|
sign_in user, scope: :user
|
||||||
get :new
|
get :new, session: { challenge_passed_at: Time.now.utc }
|
||||||
end
|
end
|
||||||
|
|
||||||
include_examples 'renders :new'
|
include_examples 'renders :new'
|
||||||
|
@ -37,7 +37,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
|
||||||
|
|
||||||
it 'redirects if user do not have otp_secret' do
|
it 'redirects if user do not have otp_secret' do
|
||||||
sign_in user_without_otp_secret, scope: :user
|
sign_in user_without_otp_secret, scope: :user
|
||||||
get :new
|
get :new, session: { challenge_passed_at: Time.now.utc }
|
||||||
expect(response).to redirect_to('/settings/two_factor_authentication')
|
expect(response).to redirect_to('/settings/two_factor_authentication')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -50,7 +50,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
|
||||||
|
|
||||||
describe 'when form_two_factor_confirmation parameter is not provided' do
|
describe 'when form_two_factor_confirmation parameter is not provided' do
|
||||||
it 'raises ActionController::ParameterMissing' do
|
it 'raises ActionController::ParameterMissing' do
|
||||||
post :create, params: {}
|
post :create, params: {}, session: { challenge_passed_at: Time.now.utc }
|
||||||
expect(response).to have_http_status(400)
|
expect(response).to have_http_status(400)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -68,7 +68,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }
|
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc }
|
||||||
|
|
||||||
expect(assigns(:recovery_codes)).to eq otp_backup_codes
|
expect(assigns(:recovery_codes)).to eq otp_backup_codes
|
||||||
expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled'
|
expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled'
|
||||||
|
@ -85,7 +85,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }
|
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders the new view' do
|
it 'renders the new view' do
|
||||||
|
|
|
@ -15,7 +15,7 @@ describe Settings::TwoFactorAuthentication::RecoveryCodesController do
|
||||||
end
|
end
|
||||||
|
|
||||||
sign_in user, scope: :user
|
sign_in user, scope: :user
|
||||||
post :create
|
post :create, session: { challenge_passed_at: Time.now.utc }
|
||||||
|
|
||||||
expect(assigns(:recovery_codes)).to eq otp_backup_codes
|
expect(assigns(:recovery_codes)).to eq otp_backup_codes
|
||||||
expect(flash[:notice]).to eq 'Recovery codes successfully regenerated'
|
expect(flash[:notice]).to eq 'Recovery codes successfully regenerated'
|
||||||
|
|
|
@ -58,7 +58,7 @@ describe Settings::TwoFactorAuthenticationsController do
|
||||||
describe 'when creation succeeds' do
|
describe 'when creation succeeds' do
|
||||||
it 'updates user secret' do
|
it 'updates user secret' do
|
||||||
before = user.otp_secret
|
before = user.otp_secret
|
||||||
post :create
|
post :create, session: { challenge_passed_at: Time.now.utc }
|
||||||
|
|
||||||
expect(user.reload.otp_secret).not_to eq(before)
|
expect(user.reload.otp_secret).not_to eq(before)
|
||||||
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
|
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
|
||||||
|
|
|
@ -18,6 +18,21 @@ class UserMailerPreview < ActionMailer::Preview
|
||||||
UserMailer.password_change(User.first)
|
UserMailer.password_change(User.first)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/two_factor_disabled
|
||||||
|
def two_factor_disabled
|
||||||
|
UserMailer.two_factor_disabled(User.first)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/two_factor_enabled
|
||||||
|
def two_factor_enabled
|
||||||
|
UserMailer.two_factor_enabled(User.first)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/two_factor_recovery_codes_changed
|
||||||
|
def two_factor_recovery_codes_changed
|
||||||
|
UserMailer.two_factor_recovery_codes_changed(User.first)
|
||||||
|
end
|
||||||
|
|
||||||
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/reconfirmation_instructions
|
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/reconfirmation_instructions
|
||||||
def reconfirmation_instructions
|
def reconfirmation_instructions
|
||||||
user = User.first
|
user = User.first
|
||||||
|
|
Loading…
Reference in a new issue