parent
d0c2c52783
commit
e1066cd431
32 changed files with 567 additions and 50 deletions
@ -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