Skip to content

Commit

Permalink
[SDPA-2689] Store authentication token more securely. (#406)
Browse files Browse the repository at this point in the history
* [SDPA-2689] Authenticated token fixes
* Token to be store in cookies
* Token removed from Vuex Store (as this will output to HTML)
* Authenticated state (true / false) to be stored in Vuex.

* [SDPA-2689] Split preview and authenticate functions into separate libs.

* [SDPA-2689] Move vuex store management into authenticate lib.

* [SDPA-2689] Extract and set cookie name to authenticatedContent.

* [SDPA-2689] Add authenticatedContent module enabled checks.

* [SDPA-2689] Add authenticated preview tests.

* [SDPA-2689] Fixed test. Added preview role to created used.

* [SDPA-2689] Lint fixes.

* [SDPA-2689] isModuleEnabled to return false if no config available.
  • Loading branch information
alan-cole authored and tim-yao committed Jul 4, 2019
1 parent ea87937 commit 8c57faa
Show file tree
Hide file tree
Showing 19 changed files with 328 additions and 112 deletions.
25 changes: 14 additions & 11 deletions packages/ripple-nuxt-tide/lib/core/middleware.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { metatagConverter, pathToClass } from './tide-helper'
import { isPreviewPath, isTokenExpired } from '../../modules/authenticated-content/lib/preview'
import { isTokenExpired, getToken, clearToken } from '../../modules/authenticated-content/lib/authenticate'
import { isPreviewPath } from '../../modules/authenticated-content/lib/preview'

// Fetch page data from Tide API by current path
export default async function (context, results) {
Expand All @@ -13,27 +14,29 @@ export default async function (context, results) {
const mapping = context.app.$tideMapping
let tideParams = {}

// Pass the protected content JWT from store so it can be added as a header
const { token: authToken } = context.store.state.tideAuthenticatedContent
if (authToken) {
// If token expired clear the persisted state
if (isTokenExpired(authToken)) {
context.store.dispatch('tideAuthenticatedContent/clearToken')
} else {
tideParams.auth_token = authToken
const authContentEnabled = context.app.$tide.isModuleEnabled('authenticatedContent')
let authToken = null
if (authContentEnabled) {
// Pass the protected content JWT from store so it can be added as a header
authToken = getToken()
if (authToken) {
// If token expired clear the persisted state
if (isTokenExpired(authToken)) {
clearToken(context.store)
}
}
}

try {
let response = null

if (isPreviewPath(context.route.path)) {
if (authContentEnabled && isPreviewPath(context.route.path)) {
if (!authToken) {
return context.redirect('/login?destination=' + context.req.url)
}
const { 2: type, 3: id, 4: rev } = context.route.path.split('/')
const section = context.route.query.section ? context.route.query.section : null
response = await context.app.$tide.getPreviewPage(type, id, rev, section, tideParams)
response = await context.app.$tide.getPreviewPage(type, id, rev, section, tideParams, authToken)
} else {
response = await context.app.$tide.getPageByPath(context.route.fullPath, tideParams)
}
Expand Down
52 changes: 28 additions & 24 deletions packages/ripple-nuxt-tide/lib/core/tide.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ import _ from 'lodash'
import * as helper from './tide-helper'
import * as pageTypes from './page-types'
import * as middleware from './middleware-helper'
import { isTokenExpired } from '../../modules/authenticated-content/lib/preview'
import { isTokenExpired } from '../../modules/authenticated-content/lib/authenticate'

const apiPrefix = '/api/v1/'

export const tide = (axios, site, config) => ({
get: async function (resource, params = {}, id = '') {
/**
* GET request to tide for resources.
* @param {String} resource Resource type e.g. <entity type>/<bundle>
* @param {Object} params Object to convert to QueryString. Passed in URL.
* @param {String} id Resource UUID
* @param {String} authToken Authentication token
*/
get: async function (resource, params = {}, id = '', authToken) {
const siteParam = 'site=' + site
const url = `${apiPrefix}${resource}${id ? `/${id}` : ''}?${siteParam}${Object.keys(params).length ? `&${qs.stringify(params, { indices: false })}` : ''}`
let headers = {}
Expand All @@ -20,25 +27,17 @@ export const tide = (axios, site, config) => ({
console.info(`Tide request url: ${url}`)
}

// Set Session cookie if is available in parameters
if (typeof params.session_name !== 'undefined' && typeof params.session_value !== 'undefined') {
_.merge(headers, { Cookie: params.session_name + '=' + params.session_value })
}

// Set 'X-CSRF-Token if token parameters is defined
if (typeof params.token !== 'undefined') {
_.merge(headers, { 'X-CSRF-Token': params.token })
}

// Set 'X-Authorization' header if auth_token present
if (params.auth_token) {
if (!isTokenExpired(params.auth_token)) {
_.merge(headers, { 'X-Authorization': `Bearer ${params.auth_token}` })
} else {
if (this.isModuleEnabled('authenticatedContent')) {
// Set 'X-Authorization' header if authToken present
if (authToken) {
if (!isTokenExpired(authToken)) {
_.merge(headers, { 'X-Authorization': `Bearer ${authToken}` })
} else {
delete config.headers['X-Authorization']
}
} else if (config.headers && config.headers['X-Authorization']) {
delete config.headers['X-Authorization']
}
} else if (config.headers && config.headers['X-Authorization']) {
delete config.headers['X-Authorization']
}

// If headers is not empty add to config request
Expand Down Expand Up @@ -91,8 +90,13 @@ export const tide = (axios, site, config) => ({
return sitesDomainMap
},

/**
* Check if a module is enabled.
* @param {String} checkForModule name of module
* @returns {Boolean}
*/
isModuleEnabled: function (checkForModule) {
return config.modules[checkForModule] === 1
return config && config.modules && config.modules[checkForModule] === 1
},

getSiteData: async function (tid = null) {
Expand Down Expand Up @@ -233,7 +237,7 @@ export const tide = (axios, site, config) => ({
return pageTypes.getTemplate(type)
},

getEntityByPathData: async function (pathData, query) {
getEntityByPathData: async function (pathData, query, authToken) {
const endpoint = `${pathData.entity_type}/${pathData.bundle}/${pathData.uuid}`

let include
Expand Down Expand Up @@ -289,7 +293,7 @@ export const tide = (axios, site, config) => ({
if (!_.isEmpty(query)) {
params = _.merge(query, params)
}
const entity = await this.get(endpoint, params)
const entity = await this.get(endpoint, params, '', authToken)
return entity
},

Expand All @@ -312,7 +316,7 @@ export const tide = (axios, site, config) => ({
return pageData
},

getPreviewPage: async function (contentType, uuid, revisionId, section, params) {
getPreviewPage: async function (contentType, uuid, revisionId, section, params, authToken) {
if (revisionId === 'latest') {
params.resourceVersion = 'rel:working-copy'
} else {
Expand All @@ -324,7 +328,7 @@ export const tide = (axios, site, config) => ({
bundle: contentType,
uuid: uuid
}
const entity = await this.getEntityByPathData(pathData, params)
const entity = await this.getEntityByPathData(pathData, params, authToken)
const pageData = jsonapiParse.parse(entity).data

// Append the site section to page data
Expand Down
43 changes: 29 additions & 14 deletions packages/ripple-nuxt-tide/lib/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ import { RplBaseLayout } from '@dpc-sdp/ripple-layout'
import RplSiteFooter from '@dpc-sdp/ripple-site-footer'
import RplSiteHeader from '@dpc-sdp/ripple-site-header'
import markupPlugins from '@dpc-sdp/ripple-nuxt-tide/lib/core/markup-plugins'
import { isPreviewPath, isTokenExpired } from '@dpc-sdp/ripple-nuxt-tide/modules/authenticated-content/lib/preview'
import { isTokenExpired, getToken, clearToken, isAuthenticated } from '@dpc-sdp/ripple-nuxt-tide/modules/authenticated-content/lib/authenticate'
import { isPreviewPath } from '@dpc-sdp/ripple-nuxt-tide/modules/authenticated-content/lib/preview'
export default {
components: {
Expand Down Expand Up @@ -84,21 +85,33 @@ export default {
return false
},
showLogout () {
return Boolean(this.$store.state.tideAuthenticatedContent.token)
if (this.$tide.isModuleEnabled('authenticatedContent')) {
return isAuthenticated(this.$store)
}
return false
},
preview () {
const token = this.$store.state.tideAuthenticatedContent.token
return isPreviewPath(this.$route.path) && token && !isTokenExpired(token)
if (this.$tide.isModuleEnabled('authenticatedContent')) {
if (isAuthenticated(this.$store)) {
const token = getToken()
return isPreviewPath(this.$route.path) && token && !isTokenExpired(token)
}
}
return false
}
},
methods: {
async logoutFunc () {
try {
await this.$tide.post(`user/logout?_format=json`)
this.$store.dispatch('tideAuthenticatedContent/clearToken')
this.$router.push({ path: '/' })
} catch (e) {
console.log(`Tide logout failed`)
if (this.$tide.isModuleEnabled('authenticatedContent')) {
try {
await this.$tide.post(`user/logout?_format=json`)
clearToken(this.$store)
this.$router.push({ path: '/' })
} catch (e) {
console.log(`Tide logout failed`)
}
} else {
console.warn(`Authentication module is disabled - unable to log out`)
}
},
searchFunc (searchInput) {
Expand Down Expand Up @@ -134,10 +147,12 @@ export default {
this.rplOptions.rplMarkup = {
plugins: markupPlugins
}
// If logged in and session has expired, logout the user
if (this.showLogout) {
if (isTokenExpired(this.$store.state.tideAuthenticatedContent.token)) {
this.logoutFunc()
if (this.$tide.isModuleEnabled('authenticatedContent')) {
// If logged in and session has expired, logout the user
if (this.showLogout) {
if (isTokenExpired(getToken())) {
this.logoutFunc()
}
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/ripple-nuxt-tide/lib/templates/plugin.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { tide, Mapping } from '@dpc-sdp/ripple-nuxt-tide/lib/core'
import { search } from '@dpc-sdp/ripple-nuxt-tide/modules/search/index.js'
import Cookies from "js-cookie";
import { serverSetToken } from '@dpc-sdp/ripple-nuxt-tide/modules/authenticated-content/lib/authenticate'

export default ({ env, app, req, res, store , route}, inject) => {
// We need to serialize functions, so use `serialize` instead of `JSON.stringify`.
Expand Down Expand Up @@ -101,6 +101,10 @@ export default ({ env, app, req, res, store , route}, inject) => {
if (config.modules.alert === 1) {
await store.dispatch('tideAlerts/init')
}
// Load authenticated content store.
if (config.modules.authenticatedContent === 1) {
serverSetToken(req.headers.cookie, store)
}
}
},
setCurrentUrl ({ commit }, fullPath) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<script>
import RplButton from '@dpc-sdp/ripple-button'
import RplForm from '@dpc-sdp/ripple-form'
import { clientSetToken, isAuthenticated } from '../lib/authenticate'
const FIELDS = {
password: {
Expand Down Expand Up @@ -80,7 +81,7 @@ export default {
},
data () {
return {
isAuthed: Boolean(this.$store.state.tideAuthenticatedContent.token),
isAuthed: isAuthenticated(this.$store),
selectedForm: 'login',
forms: {
login: {
Expand Down Expand Up @@ -178,7 +179,7 @@ export default {
try {
const response = await this.$tide.post(endpoint, data)
if (response.auth_token) {
this.$store.dispatch('tideAuthenticatedContent/setToken', response.auth_token)
clientSetToken(response.auth_token, this.$store)
if (this.$route.query.destination !== undefined) {
this.$router.push({ path: this.$route.query.destination })
} else if (this.$props.redirect !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import cookieparser from 'cookieparser'
import Cookie from 'js-cookie'

const authCookieName = 'authenticatedContent'
let serverToken = null

/**
* Decode a JWT token and test exipration date.
* @param {String} token JWT token
* @return {Boolean} is expired
*/
function isTokenExpired (token) {
if (token) {
const jwtDecode = require('jwt-decode')
const { exp } = jwtDecode(token)
// Token expiry timestamp is in a shorter format, match them for comparison
const now = parseInt(Date.now().toString().slice(0, exp.toString().length))
return exp < now
} else {
return true
}
}

/**
* Client / Server use.
* Get auth token.
* @return {String} auth token
*/
function getToken () {
if (process.client) {
return Cookie.get(authCookieName)
} else {
return serverToken
}
}

/**
* Client / Server use.
* Clear auth token.
* @param {Object} store vuex store object
*/
function clearToken (store) {
if (process.client) {
Cookie.remove(authCookieName)
} else {
serverToken = null
}
store.dispatch('tideAuthenticatedContent/setAuthenticated', false)
}

/**
* Client use only.
* Store auth token in cookies.
* @param {String} token JWT token
* @param {Object} store vuex store object
*/
function clientSetToken (token, store) {
Cookie.set(authCookieName, token)
store.dispatch('tideAuthenticatedContent/setAuthenticated', true)
}

/**
* Server use only.
* Store request header auth token to memory for page rendering.
* @param {Object} cookies Request header cookies
* @param {Object} store vuex store object
*/
function serverSetToken (cookies, store) {
let isAuth = false
if (cookies) {
const parsed = cookieparser.parse(cookies)
if (parsed[authCookieName]) {
if (!isTokenExpired(parsed[authCookieName])) {
serverToken = parsed[authCookieName]
isAuth = true
}
}
}
store.dispatch('tideAuthenticatedContent/setAuthenticated', isAuth)
}

/**
* Check if current user is authenticated.
* @param {Object} store vuex store object
* @return {Boolean} is user authenticated
*/
function isAuthenticated (store) {
return store.state.tideAuthenticatedContent.isAuthenticated
}

export { isTokenExpired }
export { getToken }
export { clearToken }
export { clientSetToken }
export { serverSetToken }
export { isAuthenticated }
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,5 @@ function isPreviewPath (path) {
return (path.indexOf('/preview/') === 0)
}

/**
* Decode a JWT token and test exipration date.
* @param {String} token JWT token
*/
function isTokenExpired (token) {
const jwtDecode = require('jwt-decode')
const { exp } = jwtDecode(token)
// Token expiry timestamp is in a shorter format, match them for comparison
const now = parseInt(Date.now().toString().slice(0, exp.toString().length))
return exp < now
}

export { isPreviewPath }
export { isTokenExpired }
export default isPreviewPath
Loading

0 comments on commit 8c57faa

Please sign in to comment.