parent
d0c2c52783
commit
e1066cd431
@ -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 |
Loading…
Reference in new issue