app: use a long-lived cookie for CSRF token
See the ADR document for rationale.
This commit is contained in:
parent
446c57ed63
commit
831672391e
5 changed files with 280 additions and 6 deletions
|
@ -0,0 +1,100 @@
|
|||
RSpec.describe ApplicationController::LongLivedAuthenticityToken, type: :controller do
|
||||
controller(ActionController::Base) do
|
||||
include ApplicationController::LongLivedAuthenticityToken
|
||||
end
|
||||
|
||||
describe '#real_csrf_token' do
|
||||
subject { controller.send(:real_csrf_token, session) }
|
||||
|
||||
context 'when the long-lived cookie has a token' do
|
||||
before do
|
||||
token = controller.send(:generate_csrf_token)
|
||||
|
||||
@controller.send(:cookies).signed[ApplicationController::LongLivedAuthenticityToken::COOKIE_NAME] = {
|
||||
value: token,
|
||||
expires: 1.year.from_now,
|
||||
httponly: true
|
||||
}
|
||||
|
||||
@decrypted_token = controller.send(:decode_csrf_token, token)
|
||||
end
|
||||
|
||||
it 'returns the decoded token' do
|
||||
expect(subject).to eq @decrypted_token
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the long-lived cookie is empty, but the session has a token' do
|
||||
before do
|
||||
token = controller.send(:generate_csrf_token)
|
||||
|
||||
session[:_csrf_token] = token
|
||||
|
||||
@decrypted_token = controller.send(:decode_csrf_token, token)
|
||||
end
|
||||
|
||||
it 'returns the decoded token' do
|
||||
expect(subject).to eq @decrypted_token
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no token is present' do
|
||||
it 'generates a new token' do
|
||||
expect(subject).to be_present
|
||||
end
|
||||
|
||||
it 'stores the new token in the long-lived cookie' do
|
||||
subject
|
||||
expect(controller.send(:cookies).signed[ApplicationController::LongLivedAuthenticityToken::COOKIE_NAME]).to be_present
|
||||
end
|
||||
|
||||
it 'stores the new token in the session' do
|
||||
subject
|
||||
expect(controller.session[:_csrf_token]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe "CSRF cleanup", type: :request do
|
||||
describe 'csrf_cleaner hook', :allow_forgery_protection do
|
||||
let(:user) { create(:user, password: password) }
|
||||
let(:password) { 'my-very-secure-password' }
|
||||
|
||||
it 'refreshes the long-lived cookie after authentication' do
|
||||
get new_user_session_path
|
||||
cookie_token = long_lived_cookie
|
||||
|
||||
# The token in the long-lived cookie doesn't change between requests
|
||||
# (This is not strictly needed, but ensures we read the signed cookie properly.)
|
||||
get new_user_session_path
|
||||
|
||||
expect(long_lived_cookie).to be_present
|
||||
expect(long_lived_cookie).to eq cookie_token
|
||||
|
||||
# The token in the long-lived cookie is refreshed after authentication
|
||||
post user_session_path,
|
||||
params: { user: { email: user.email, password: password } },
|
||||
headers: { 'HTTP_X_CSRF_TOKEN' => header_authenticity_token(response) }
|
||||
follow_redirect!
|
||||
follow_redirect! # After sign-in, we are redirected twice
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(long_lived_cookie).to be_present
|
||||
expect(long_lived_cookie).not_to eq cookie_token
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def header_authenticity_token(response)
|
||||
regex = /meta name="csrf-token" content="(?<token>.+)"/
|
||||
parts = response.body.match(regex)
|
||||
parts['token'] if parts
|
||||
end
|
||||
|
||||
def long_lived_cookie
|
||||
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
|
||||
jar.signed[ApplicationController::LongLivedAuthenticityToken::COOKIE_NAME.to_s]
|
||||
end
|
||||
end
|
|
@ -2,14 +2,34 @@ feature 'Protecting against request forgeries:', :allow_forgery_protection, :sho
|
|||
let(:user) { create(:user, password: password) }
|
||||
let(:password) { 'ThisIsTheUserPassword' }
|
||||
|
||||
scenario 'a form without a matching CSRF token is rejected' do
|
||||
before do
|
||||
visit new_user_session_path
|
||||
end
|
||||
|
||||
delete_session_cookie
|
||||
fill_sign_in_form
|
||||
context 'when the browser send a request after the session cookie expired' do
|
||||
before do
|
||||
delete_session_cookie
|
||||
end
|
||||
|
||||
click_on 'Se connecter'
|
||||
expect(page).to have_text('L’action demandée a été rejetée')
|
||||
context 'when the long-lived CSRF cookie is still present' do
|
||||
scenario 'the change is allowed' do
|
||||
fill_sign_in_form
|
||||
click_on 'Se connecter'
|
||||
expect(page).to have_content('Connecté')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the long-lived CSRF cookie is invalid or missing' do
|
||||
before do
|
||||
delete_long_lived_csrf_cookie
|
||||
end
|
||||
|
||||
scenario 'the user sees an error page' do
|
||||
fill_sign_in_form
|
||||
click_on 'Se connecter'
|
||||
expect(page).to have_text('L’action demandée a été rejetée')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -21,6 +41,16 @@ feature 'Protecting against request forgeries:', :allow_forgery_protection, :sho
|
|||
|
||||
def delete_session_cookie
|
||||
session_cookie_name = Rails.application.config.session_options[:key]
|
||||
page.driver.browser.set_cookie("#{session_cookie_name}=''")
|
||||
delete_cookie(session_cookie_name)
|
||||
end
|
||||
|
||||
def delete_long_lived_csrf_cookie
|
||||
csrf_cookie_name = ApplicationController::LongLivedAuthenticityToken::COOKIE_NAME
|
||||
delete_cookie(csrf_cookie_name)
|
||||
end
|
||||
|
||||
def delete_cookie(cookie_name)
|
||||
raise 'The cookie to be deleted can’t be nil' if cookie_name.nil?
|
||||
page.driver.browser.set_cookie("#{cookie_name}=''")
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue