Fix #3961master^2
@@ -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 | ||||
@@ -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 | ||||
@@ -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 { | |||||
color: $darker-text-color; | |||||
body, | |||||
.admin-wrapper .content { | |||||
.muted-hint { | |||||
color: $darker-text-color; | |||||
a { | |||||
color: $highlight-text-color; | |||||
a { | |||||
color: $highlight-text-color; | |||||
} | |||||
} | } | ||||
} | |||||
.positive-hint { | |||||
color: $valid-value-color; | |||||
font-weight: 500; | |||||
} | |||||
.positive-hint { | |||||
color: $valid-value-color; | |||||
font-weight: 500; | |||||
} | |||||
.negative-hint { | |||||
color: $error-value-color; | |||||
font-weight: 500; | |||||
} | |||||
.negative-hint { | |||||
color: $error-value-color; | |||||
font-weight: 500; | |||||
} | |||||
.neutral-hint { | |||||
color: $dark-text-color; | |||||
font-weight: 500; | |||||
} | |||||
.neutral-hint { | |||||
color: $dark-text-color; | |||||
font-weight: 500; | |||||
} | |||||
.warning-hint { | |||||
color: $gold-star; | |||||
font-weight: 500; | |||||
.warning-hint { | |||||
color: $gold-star; | |||||
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 | ||||
@@ -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 | ||||
@@ -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 | |||||
= fa_icon 'check' | |||||
= ' ' | |||||
= t 'two_factor_authentication.enabled' | |||||
%p.hint | |||||
%span.positive-hint | |||||
= fa_icon 'check' | |||||
= ' ' | |||||
= 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') | |||||
%p.muted-hint | |||||
= t('two_factor_authentication.lost_recovery_codes') | |||||
= link_to t('two_factor_authentication.generate_recovery_codes'), | |||||
settings_two_factor_authentication_recovery_codes_path, | |||||
data: { method: :post } | |||||
%h3= t('two_factor_authentication.recovery_codes') | |||||
%p.muted-hint= t('two_factor_authentication.lost_recovery_codes') | |||||
%hr.spacer/ | |||||
.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'), | |||||
settings_two_factor_authentication_path, | |||||
data: { method: :post }, | |||||
class: 'block-button' | |||||
%hr.spacer/ | |||||
= link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button' |
@@ -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') |
@@ -0,0 +1,7 @@ | |||||
<%= t 'devise.mailer.two_factor_disabled.title' %> | |||||
=== | |||||
<%= t 'devise.mailer.two_factor_disabled.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{ 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') |
@@ -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 | ||||
@@ -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 | ||||
@@ -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 | ||||