From 50519f1bcdf9c94b91e21d4f58ebae4965f06b2d Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Sun, 1 Sep 2024 09:31:04 +0200 Subject: [PATCH] feat: Additional httpOnly token for enhanced security in browsers --- client/src/api/access-tokens.js | 9 ++-- client/src/api/http.js | 3 +- client/src/constants/Config.js | 3 +- .../api/controllers/access-tokens/create.js | 16 +++++++- .../api/controllers/access-tokens/delete.js | 12 +++--- .../access-tokens/exchange-using-oidc.js | 17 +++++++- .../api/controllers/users/update-password.js | 8 +++- server/api/helpers/users/update-one.js | 2 +- .../utils/clear-http-only-token-cookie.js | 16 ++++++++ .../{create-token.js => create-jwt-token.js} | 29 +++++++------ .../utils/set-http-only-token-cookie.js | 28 +++++++++++++ .../{verify-token.js => verify-jwt-token.js} | 0 server/api/hooks/current-user/index.js | 41 ++++++++++++------- server/api/hooks/watcher/index.js | 2 +- server/api/models/Session.js | 6 +++ server/config/custom.js | 5 +++ server/config/env/production.js | 11 +++-- ...token_for_enhanced_security_in_browsers.js | 11 +++++ 18 files changed, 171 insertions(+), 48 deletions(-) create mode 100644 server/api/helpers/utils/clear-http-only-token-cookie.js rename server/api/helpers/utils/{create-token.js => create-jwt-token.js} (53%) create mode 100644 server/api/helpers/utils/set-http-only-token-cookie.js rename server/api/helpers/utils/{verify-token.js => verify-jwt-token.js} (100%) create mode 100644 server/db/migrations/20240831195806_additional_http_only_token_for_enhanced_security_in_browsers.js diff --git a/client/src/api/access-tokens.js b/client/src/api/access-tokens.js index 8c57500f..322438c6 100755 --- a/client/src/api/access-tokens.js +++ b/client/src/api/access-tokens.js @@ -1,15 +1,14 @@ import http from './http'; -import socket from './socket'; /* Actions */ -const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); +const createAccessToken = (data, headers) => + http.post('/access-tokens?withHttpOnlyToken=true', data, headers); const exchangeForAccessTokenUsingOidc = (data, headers) => - http.post('/access-tokens/exchange-using-oidc', data, headers); + http.post('/access-tokens/exchange-using-oidc?withHttpOnlyToken=true', data, headers); -const deleteCurrentAccessToken = (headers) => - socket.delete('/access-tokens/me', undefined, headers); +const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', undefined, headers); export default { createAccessToken, diff --git a/client/src/api/http.js b/client/src/api/http.js index 2119de2b..93fd7604 100755 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -5,7 +5,7 @@ import Config from '../constants/Config'; const http = {}; // TODO: add all methods -['GET', 'POST'].forEach((method) => { +['GET', 'POST', 'DELETE'].forEach((method) => { http[method.toLowerCase()] = (url, data, headers) => { const formData = data && @@ -19,6 +19,7 @@ const http = {}; method, headers, body: formData, + credentials: 'include', }) .then((response) => response.json().then((body) => ({ diff --git a/client/src/constants/Config.js b/client/src/constants/Config.js index f9640e71..50e92e32 100755 --- a/client/src/constants/Config.js +++ b/client/src/constants/Config.js @@ -1,11 +1,12 @@ const { BASE_URL } = window; + const BASE_PATH = BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1'); const SERVER_BASE_URL = process.env.REACT_APP_SERVER_BASE_URL || (process.env.NODE_ENV === 'production' ? BASE_URL : 'http://localhost:1337'); -const SERVER_BASE_PATH = SERVER_BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1'); +const SERVER_BASE_PATH = SERVER_BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1'); const SERVER_HOST_NAME = SERVER_BASE_URL.replace(/^(.*\/\/[^/?#]*).*$/, '$1'); const ACCESS_TOKEN_KEY = 'accessToken'; diff --git a/server/api/controllers/access-tokens/create.js b/server/api/controllers/access-tokens/create.js index 8dc2faed..05a835c5 100755 --- a/server/api/controllers/access-tokens/create.js +++ b/server/api/controllers/access-tokens/create.js @@ -1,5 +1,6 @@ const bcrypt = require('bcrypt'); const validator = require('validator'); +const { v4: uuid } = require('uuid'); const { getRemoteAddress } = require('../../../utils/remoteAddress'); @@ -34,6 +35,10 @@ module.exports = { type: 'string', required: true, }, + withHttpOnlyToken: { + type: 'boolean', + defaultsTo: false, + }, }, exits: { @@ -81,15 +86,24 @@ module.exports = { : Errors.INVALID_CREDENTIALS; } - const accessToken = sails.helpers.utils.createToken(user.id); + const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken( + user.id, + ); + + const httpOnlyToken = inputs.withHttpOnlyToken ? uuid() : null; await Session.create({ accessToken, + httpOnlyToken, remoteAddress, userId: user.id, userAgent: this.req.headers['user-agent'], }); + if (httpOnlyToken && !this.req.isSocket) { + sails.helpers.utils.setHttpOnlyTokenCookie(httpOnlyToken, accessTokenPayload, this.res); + } + return { item: accessToken, }; diff --git a/server/api/controllers/access-tokens/delete.js b/server/api/controllers/access-tokens/delete.js index b252a0c0..43c94841 100644 --- a/server/api/controllers/access-tokens/delete.js +++ b/server/api/controllers/access-tokens/delete.js @@ -1,20 +1,22 @@ module.exports = { async fn() { - const { accessToken } = this.req; + const { currentSession } = this.req; await Session.updateOne({ - accessToken, + id: currentSession.id, deletedAt: null, }).set({ deletedAt: new Date().toISOString(), }); - if (this.req.isSocket) { - sails.sockets.leaveAll(`@accessToken:${accessToken}`); + sails.sockets.leaveAll(`@accessToken:${currentSession.accessToken}`); + + if (currentSession.httpOnlyToken && !this.req.isSocket) { + sails.helpers.utils.clearHttpOnlyTokenCookie(this.res); } return { - item: accessToken, + item: currentSession.accessToken, }; }, }; diff --git a/server/api/controllers/access-tokens/exchange-using-oidc.js b/server/api/controllers/access-tokens/exchange-using-oidc.js index 874d1d70..689f6138 100644 --- a/server/api/controllers/access-tokens/exchange-using-oidc.js +++ b/server/api/controllers/access-tokens/exchange-using-oidc.js @@ -1,3 +1,5 @@ +const { v4: uuid } = require('uuid'); + const { getRemoteAddress } = require('../../../utils/remoteAddress'); const Errors = { @@ -28,6 +30,10 @@ module.exports = { type: 'string', required: true, }, + withHttpOnlyToken: { + type: 'boolean', + defaultsTo: false, + }, }, exits: { @@ -62,15 +68,24 @@ module.exports = { .intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE) .intercept('missingValues', () => Errors.MISSING_VALUES); - const accessToken = sails.helpers.utils.createToken(user.id); + const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken( + user.id, + ); + + const httpOnlyToken = inputs.withHttpOnlyToken ? uuid() : null; await Session.create({ accessToken, + httpOnlyToken, remoteAddress, userId: user.id, userAgent: this.req.headers['user-agent'], }); + if (httpOnlyToken && !this.req.isSocket) { + sails.helpers.utils.setHttpOnlyTokenCookie(httpOnlyToken, accessTokenPayload, this.res); + } + return { item: accessToken, }; diff --git a/server/api/controllers/users/update-password.js b/server/api/controllers/users/update-password.js index 01466811..275018de 100644 --- a/server/api/controllers/users/update-password.js +++ b/server/api/controllers/users/update-password.js @@ -48,7 +48,7 @@ module.exports = { }, async fn(inputs) { - const { currentUser } = this.req; + const { currentSession, currentUser } = this.req; if (inputs.id === currentUser.id) { if (!inputs.currentPassword) { @@ -89,10 +89,14 @@ module.exports = { } if (user.id === currentUser.id) { - const accessToken = sails.helpers.utils.createToken(user.id, user.passwordUpdatedAt); + const { token: accessToken } = sails.helpers.utils.createJwtToken( + user.id, + user.passwordUpdatedAt, + ); await Session.create({ accessToken, + httpOnlyToken: currentSession.httpOnlyToken, userId: user.id, remoteAddress: getRemoteAddress(this.req), userAgent: this.req.headers['user-agent'], diff --git a/server/api/helpers/users/update-one.js b/server/api/helpers/users/update-one.js index 4d2b3910..325807b2 100644 --- a/server/api/helpers/users/update-one.js +++ b/server/api/helpers/users/update-one.js @@ -68,7 +68,7 @@ module.exports = { Object.assign(values, { password: bcrypt.hashSync(values.password, 10), - passwordChangedAt: new Date().toISOString(), + passwordChangedAt: new Date().toUTCString(), // FIXME: hack }); } diff --git a/server/api/helpers/utils/clear-http-only-token-cookie.js b/server/api/helpers/utils/clear-http-only-token-cookie.js new file mode 100644 index 00000000..8dec5e69 --- /dev/null +++ b/server/api/helpers/utils/clear-http-only-token-cookie.js @@ -0,0 +1,16 @@ +module.exports = { + sync: true, + + inputs: { + response: { + type: 'ref', + required: true, + }, + }, + + fn(inputs) { + inputs.response.clearCookie('httpOnlyToken', { + path: sails.config.custom.baseUrlPath, + }); + }, +}; diff --git a/server/api/helpers/utils/create-token.js b/server/api/helpers/utils/create-jwt-token.js similarity index 53% rename from server/api/helpers/utils/create-token.js rename to server/api/helpers/utils/create-jwt-token.js index b0db071a..8fbe1a09 100644 --- a/server/api/helpers/utils/create-token.js +++ b/server/api/helpers/utils/create-jwt-token.js @@ -16,18 +16,23 @@ module.exports = { fn(inputs) { const { issuedAt = new Date() } = inputs; - const iat = Math.floor(issuedAt / 1000); - return jwt.sign( - { - iat, - sub: inputs.subject, - exp: iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60, - }, - sails.config.session.secret, - { - keyid: uuid(), - }, - ); + const iat = Math.floor(issuedAt / 1000); + const exp = iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60; + + const payload = { + iat, + exp, + sub: inputs.subject, + }; + + const token = jwt.sign(payload, sails.config.session.secret, { + keyid: uuid(), + }); + + return { + token, + payload, + }; }, }; diff --git a/server/api/helpers/utils/set-http-only-token-cookie.js b/server/api/helpers/utils/set-http-only-token-cookie.js new file mode 100644 index 00000000..49f51cef --- /dev/null +++ b/server/api/helpers/utils/set-http-only-token-cookie.js @@ -0,0 +1,28 @@ +module.exports = { + sync: true, + + inputs: { + value: { + type: 'string', + required: true, + }, + accessTokenPayload: { + type: 'json', + required: true, + }, + response: { + type: 'ref', + required: true, + }, + }, + + fn(inputs) { + inputs.response.cookie('httpOnlyToken', inputs.value, { + expires: new Date(inputs.accessTokenPayload.exp * 1000), + path: sails.config.custom.baseUrlPath, + secure: sails.config.custom.baseUrlSecure, + httpOnly: true, + sameSite: 'strict', + }); + }, +}; diff --git a/server/api/helpers/utils/verify-token.js b/server/api/helpers/utils/verify-jwt-token.js similarity index 100% rename from server/api/helpers/utils/verify-token.js rename to server/api/helpers/utils/verify-jwt-token.js diff --git a/server/api/hooks/current-user/index.js b/server/api/hooks/current-user/index.js index 1d3214f6..465fc691 100644 --- a/server/api/hooks/current-user/index.js +++ b/server/api/hooks/current-user/index.js @@ -9,10 +9,10 @@ module.exports = function defineCurrentUserHook(sails) { const TOKEN_PATTERN = /^Bearer /; - const getUser = async (accessToken) => { + const getSessionAndUser = async (accessToken, httpOnlyToken) => { let payload; try { - payload = sails.helpers.utils.verifyToken(accessToken); + payload = sails.helpers.utils.verifyJwtToken(accessToken); } catch (error) { return null; } @@ -26,13 +26,20 @@ module.exports = function defineCurrentUserHook(sails) { return null; } + if (session.httpOnlyToken && httpOnlyToken !== session.httpOnlyToken) { + return null; + } + const user = await sails.helpers.users.getOne(payload.subject); if (user && user.passwordChangedAt > payload.issuedAt) { return null; } - return user; + return { + session, + user, + }; }; return { @@ -52,17 +59,21 @@ module.exports = function defineCurrentUserHook(sails) { if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) { const accessToken = authorizationHeader.replace(TOKEN_PATTERN, ''); - const currentUser = await getUser(accessToken); + const { httpOnlyToken } = req.cookies; + + const sessionAndUser = await getSessionAndUser(accessToken, httpOnlyToken); + + if (sessionAndUser) { + const { session, user } = sessionAndUser; - if (currentUser) { Object.assign(req, { - accessToken, - currentUser, + currentSession: session, + currentUser: user, }); if (req.isSocket) { - sails.sockets.join(req, `@accessToken:${accessToken}`); - sails.sockets.join(req, `@user:${currentUser.id}`); + sails.sockets.join(req, `@accessToken:${session.accessToken}`); + sails.sockets.join(req, `@user:${user.id}`); } } } @@ -72,15 +83,17 @@ module.exports = function defineCurrentUserHook(sails) { }, '/attachments/*': { async fn(req, res, next) { - const { accessToken } = req.cookies; + const { accessToken, httpOnlyToken } = req.cookies; if (accessToken) { - const currentUser = await getUser(accessToken); + const sessionAndUser = await getSessionAndUser(accessToken, httpOnlyToken); + + if (sessionAndUser) { + const { session, user } = sessionAndUser; - if (currentUser) { Object.assign(req, { - accessToken, - currentUser, + currentSession: session, + currentUser: user, }); } } diff --git a/server/api/hooks/watcher/index.js b/server/api/hooks/watcher/index.js index 835b1e8d..14a21156 100644 --- a/server/api/hooks/watcher/index.js +++ b/server/api/hooks/watcher/index.js @@ -16,7 +16,7 @@ module.exports = function defineWatcherHook(sails) { const accessToken = room.split(':')[1]; try { - sails.helpers.utils.verifyToken(accessToken); + sails.helpers.utils.verifyJwtToken(accessToken); } catch (error) { sails.sockets.broadcast(room, 'logout'); sails.sockets.leaveAll(room); diff --git a/server/api/models/Session.js b/server/api/models/Session.js index 61a9df40..76acccc8 100755 --- a/server/api/models/Session.js +++ b/server/api/models/Session.js @@ -16,6 +16,12 @@ module.exports = { required: true, columnName: 'access_token', }, + httpOnlyToken: { + type: 'string', + isNotEmptyString: true, + allowNull: true, + columnName: 'http_only_token', + }, remoteAddress: { type: 'string', required: true, diff --git a/server/config/custom.js b/server/config/custom.js index d8d2fcdb..8047b73d 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -8,9 +8,12 @@ * https://sailsjs.com/config/custom */ +const url = require('url'); const path = require('path'); const sails = require('sails'); +const parsedBasedUrl = new url.URL(process.env.BASE_URL); + module.exports.custom = { /** * @@ -19,6 +22,8 @@ module.exports.custom = { */ baseUrl: process.env.BASE_URL, + baseUrlPath: parsedBasedUrl.pathname, + baseUrlSecure: parsedBasedUrl.protocol === 'https:', tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365, diff --git a/server/config/env/production.js b/server/config/env/production.js index bed4f31f..b4f9802a 100644 --- a/server/config/env/production.js +++ b/server/config/env/production.js @@ -23,6 +23,8 @@ const url = require('url'); const { customLogger } = require('../../utils/logger'); +const parsedBasedUrl = new url.URL(process.env.BASE_URL); + module.exports = { /** * @@ -131,9 +133,10 @@ module.exports = { */ cors: { - // allowOrigins: [ - // 'https://example.com', - // ], + allRoutes: false, + allowOrigins: '*', + allowRequestHeaders: 'content-type', + allowCredentials: false, }, }, @@ -218,7 +221,7 @@ module.exports = { * */ - onlyAllowOrigins: [new url.URL(process.env.BASE_URL).origin], + onlyAllowOrigins: [parsedBasedUrl.origin], /** * diff --git a/server/db/migrations/20240831195806_additional_http_only_token_for_enhanced_security_in_browsers.js b/server/db/migrations/20240831195806_additional_http_only_token_for_enhanced_security_in_browsers.js new file mode 100644 index 00000000..c0b38108 --- /dev/null +++ b/server/db/migrations/20240831195806_additional_http_only_token_for_enhanced_security_in_browsers.js @@ -0,0 +1,11 @@ +module.exports.up = async (knex) => + knex.schema.table('session', (table) => { + /* Columns */ + + table.text('http_only_token'); + }); + +module.exports.down = (knex) => + knex.schema.table('session', (table) => { + table.dropColumn('http_only_token'); + });