Skip to content

Commit

Permalink
R2-2848: Enable Rails sessions for native Primero users, Adding csrf …
Browse files Browse the repository at this point in the history
…tokens for js fetch
  • Loading branch information
jtoliver-quoin committed Apr 17, 2024
1 parent ec0d3b7 commit 346a69f
Show file tree
Hide file tree
Showing 16 changed files with 48 additions and 117 deletions.
2 changes: 1 addition & 1 deletion app/auth/idp_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class IdpToken
attr_accessor :header, :payload, :identity_provider

class << self
def build(token_string)
def build(token_string = '')
idp_token = new
return idp_token unless token_string.present?

Expand Down
28 changes: 0 additions & 28 deletions app/controllers/api/v2/concerns/jwt_tokens.rb

This file was deleted.

6 changes: 1 addition & 5 deletions app/controllers/api/v2/password_reset_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ class Api::V2::PasswordResetController < Devise::PasswordsController
respond_to :json

include AuditLogActions
include Api::V2::Concerns::JwtTokens
include ErrorHandling

# Submit a request to reset a password over email.
Expand Down Expand Up @@ -41,10 +40,7 @@ def respond_with(user, _opts = {})
return errors(user) unless user.errors.empty?

json = { message: 'user.password_reset.success' }
if warden.user(resource_name) == user
token_to_cookie
json = json.merge(id: user.id, user_name: user.user_name, token: current_token)
end
json = json.merge(id: user.id, user_name: user.user_name) if warden.user(resource_name) == user
render json:
end

Expand Down
11 changes: 3 additions & 8 deletions app/controllers/api/v2/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# The endpoint used to authenticate a user when native authentication is enabled in Primero
class Api::V2::TokensController < Devise::SessionsController
include AuditLogActions
include Api::V2::Concerns::JwtTokens
include ErrorHandling
respond_to :json

Expand All @@ -16,13 +15,11 @@ class Api::V2::TokensController < Devise::SessionsController
# that Devise unfortunately still uses. We are overriding it to return a JSON object
# for the Devise session create method.
def respond_with(user, _opts = {})
token_to_cookie
render json: { id: user.id, user_name: user.user_name, token: current_token }
render json: { id: user.id, user_name: user.user_name }
end

# Overriding method called by Devise session destroy.
def respond_to_on_destroy
cookies.delete(:primero_token, domain: primero_host)
render json: {}
end

Expand All @@ -49,18 +46,16 @@ def create_native
end

def create_idp
token_to_cookie
idp_token = IdpToken.build(current_token)
idp_token = IdpToken.build
user = idp_token.valid? && idp_token.user
if user
render json: { id: user.id, user_name: user.user_name, token: current_token }
render json: { id: user.id, user_name: user.user_name }
else
fail_to_authorize!(auth_options)
end
end

def fail_to_authorize!(opts)
cookies.delete(:primero_token, domain: primero_host)
throw(:warden, opts)
end

Expand Down
12 changes: 11 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,15 @@

# Superclass for all non-API controllers
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception, prepend: true, unless: -> { request.format.json? }
before_action :set_csrf_cookie

protect_from_forgery with: :exception

def set_csrf_cookie
cookies['CSRF-TOKEN'] = {
value: form_authenticity_token,
domain: :all,
same_site: :strict
}
end
end
12 changes: 10 additions & 2 deletions app/javascript/components/logout/component.jsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
// Copyright (c) 2014 - 2023 UNICEF. All rights reserved.

import { useLayoutEffect } from "react";
import { useEffect, useLayoutEffect } from "react";
import { useDispatch } from "react-redux";
import { push } from "connected-react-router";

import usePushNotifications from "../push-notifications-toggle/use-push-notifications";
import { ROUTES } from "../../config";
import { setPendingUserLogin } from "../connectivity/action-creators";
import { useMemoizedSelector } from "../../libs";
import { getIsAuthenticated } from "../user";

import { NAME } from "./constants";

function Container() {
const { stopRefreshNotificationTimer } = usePushNotifications();
const dispatch = useDispatch();
const isAuthenticated = useMemoizedSelector(state => getIsAuthenticated(state));

useLayoutEffect(() => {
dispatch(setPendingUserLogin(false));
stopRefreshNotificationTimer();
dispatch(push(ROUTES.login));
}, []);

useEffect(() => {
if (!isAuthenticated) {
dispatch(push(ROUTES.login));
}
}, [isAuthenticated]);

return false;
}

Expand Down
5 changes: 4 additions & 1 deletion app/javascript/middleware/utils/fetch-params-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { DEFAULT_FETCH_OPTIONS } from "../constants";

import buildPath from "./build-path";
import getCSRFToken from "./get-csrf-token";
import getToken from "./get-token";

const fetchParamsBuilder = async (api, options, controller) => {
Expand All @@ -17,7 +18,9 @@ const fetchParamsBuilder = async (api, options, controller) => {

const token = await getToken();

const headers = {};
const headers = {
"X-CSRF-Token": getCSRFToken()
};

if (token) {
headers.Authorization = `Bearer ${token}`;
Expand Down
5 changes: 4 additions & 1 deletion app/javascript/middleware/utils/fetch-single-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import processAttachments from "./process-attachments";
import { deleteFromQueue, messageQueueFailed, messageQueueSkip, messageQueueSuccess } from "./queue";
import handleSuccess from "./handle-success";
import FetchError from "./fetch-error";
import getCSRFToken from "./get-csrf-token";

const fetchSinglePayload = async (action, store, options) => {
const controller = new AbortController();
Expand Down Expand Up @@ -68,7 +69,9 @@ const fetchSinglePayload = async (action, store, options) => {

const token = await getToken();

const headers = {};
const headers = {
"X-CSRF-Token": getCSRFToken()
};

if (token) {
headers.Authorization = `Bearer ${token}`;
Expand Down
5 changes: 5 additions & 0 deletions app/javascript/middleware/utils/get-csrf-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function getCSRFToken() {
return document.cookie.split("=")?.[1];
}

export default getCSRFToken;
22 changes: 0 additions & 22 deletions app/middleware/jwt_token_setter.rb

This file was deleted.

3 changes: 1 addition & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ class User < ApplicationRecord

delegate :can?, :cannot?, to: :ability

devise :database_authenticatable, :timeoutable, :recoverable, :lockable,
:jwt_authenticatable, jwt_revocation_strategy: self
devise :database_authenticatable, :timeoutable, :recoverable, :lockable

self.unique_id_attribute = 'user_name'

Expand Down
15 changes: 1 addition & 14 deletions config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
# Notice that if you are skipping storage for all authentication paths, you
# may want to disable generating routes to Devise's sessions controller by
# passing skip: :sessions to `devise_for` in your config/routes.rb
config.skip_session_storage = %i[http_auth params_auth]
config.skip_session_storage = %i[http_auth]

# By default, Devise cleans up the CSRF token on authentication to
# avoid CSRF token fixation attacks. This means that, when using AJAX
Expand Down Expand Up @@ -307,18 +307,5 @@
# When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true

# ===> Configuration for :jwt_authenticatable (devise-jwt)
config.jwt do |jwt|
jwt.secret = ENV.fetch('DEVISE_JWT_SECRET_KEY', nil)
jwt.dispatch_requests = [
['POST', %r{^/api/v2/tokens$}],
['POST', %r{^/api/v2/users/password-reset$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/api/v2/tokens$}]
]
jwt.expiration_time = 1.hour.to_i
end
end
# rubocop:enable Metrics/BlockLength
2 changes: 0 additions & 2 deletions config/initializers/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

# Copyright (c) 2014 - 2023 UNICEF. All rights reserved.

require "#{Rails.root}/app/middleware/jwt_token_setter.rb"
require "#{Rails.root}/app/middleware/www_authenticate.rb"
require "#{Rails.root}/app/middleware/log_silencer.rb"

Rails.application.config.middleware.insert_before(Warden::JWTAuth::Middleware, JwtTokenSetter)
Rails.application.config.middleware.insert_before(Warden::Manager, WwwAuthenticate)
if Rails.application.config.x.idp.use_identity_provider
Rails.application.config.middleware.delete(Warden::JWTAuth::Middleware)
Expand Down
2 changes: 1 addition & 1 deletion config/initializers/session_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

# Copyright (c) 2014 - 2023 UNICEF. All rights reserved.

Rails.application.config.session_store :disabled
Rails.application.config.session_store :cookie_store, expire_after: 1.day
5 changes: 0 additions & 5 deletions spec/requests/api/v2/password_reset_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@

describe 'POST /api/v2/users/password-reset' do
let(:reset_password_token) { user.send(:set_reset_password_token) }
let(:authorization_token) { response.headers['Authorization'].split(' ')[1] }
let(:token_cookie) { response.cookies['primero_token'] }
let(:json) { JSON.parse(response.body) }

context 'with valid token' do
Expand All @@ -109,11 +107,8 @@
end

it 'logs the user in' do
expect(authorization_token).to be_present
expect(token_cookie).to be_present
expect(json['id']).to eq(user.id)
expect(json['user_name']).to eq(user.user_name)
expect(json['token']).to eq(token_cookie)
end
end

Expand Down
30 changes: 6 additions & 24 deletions spec/requests/api/v2/tokens_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,14 @@
end

describe 'POST /api/v2/tokens' do
let(:authorization_token) { response.headers['Authorization'].split(' ')[1] }
let(:json) { JSON.parse(response.body) }
let(:jwt_header) { decode_jwt(authorization_token) }
let(:token_cookie) { response.cookies['primero_token'] }

it 'generates a new JWT token for valid credentials' do
it 'returns users with valid credentials' do
post '/api/v2/tokens', params: @params

expect(response).to have_http_status(200)
expect(authorization_token).to be_present
expect(json['id']).to be_present
expect(json['user_name']).to be_present
expect(json['token']).to be_present
expect(json['token']).to eq(authorization_token)
expect(jwt_header['sub']).to be_present
end

it 'sets the JWT token as an HTTP-only, domain bound cookie' do
post '/api/v2/tokens', params: @params

expect(response).to have_http_status(200)
expect(token_cookie).to be_present
expect(token_cookie).to eq(json['token'])
end

it 'returns nothing for invalid credentials' do
Expand Down Expand Up @@ -93,9 +78,9 @@

it 'returns a 401 when got JWT exception' do
headers = {
'HTTP_AUTHORIZATION' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'\
'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.'\
'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
'HTTP_AUTHORIZATION' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' \
'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.' \
'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
}

post('/api/v2/tokens', headers:)
Expand Down Expand Up @@ -137,11 +122,8 @@
end

describe 'DELETE /api/v2/tokens' do
it 'revokes the current token' do
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
auth_headers = Devise::JWT::TestHelpers.auth_headers(headers, @user)

delete '/api/v2/tokens', headers: auth_headers
it 'revokes the user session' do
delete '/api/v2/tokens'

# delete url
expect(response).to have_http_status(200)
Expand Down

0 comments on commit 346a69f

Please sign in to comment.