Fix #3961pull/4/head
@ -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 |
@ -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 |
@ -0,0 +1,8 @@ | |||
# frozen_string_literal: true | |||
class Form::Challenge | |||
include ActiveModel::Model | |||
attr_accessor :current_password, :current_username, | |||
:return_to | |||
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' |
@ -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 %> |
@ -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 |
@ -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 |