1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 13:19:44 +02:00

feat: Additional httpOnly token for enhanced security in browsers

This commit is contained in:
Maksim Eltyshev 2024-09-01 09:31:04 +02:00
parent d4043c9726
commit 50519f1bcd
18 changed files with 171 additions and 48 deletions

View file

@ -1,15 +1,14 @@
import http from './http'; import http from './http';
import socket from './socket';
/* Actions */ /* 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) => 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) => const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', undefined, headers);
socket.delete('/access-tokens/me', undefined, headers);
export default { export default {
createAccessToken, createAccessToken,

View file

@ -5,7 +5,7 @@ import Config from '../constants/Config';
const http = {}; const http = {};
// TODO: add all methods // TODO: add all methods
['GET', 'POST'].forEach((method) => { ['GET', 'POST', 'DELETE'].forEach((method) => {
http[method.toLowerCase()] = (url, data, headers) => { http[method.toLowerCase()] = (url, data, headers) => {
const formData = const formData =
data && data &&
@ -19,6 +19,7 @@ const http = {};
method, method,
headers, headers,
body: formData, body: formData,
credentials: 'include',
}) })
.then((response) => .then((response) =>
response.json().then((body) => ({ response.json().then((body) => ({

View file

@ -1,11 +1,12 @@
const { BASE_URL } = window; const { BASE_URL } = window;
const BASE_PATH = BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1'); const BASE_PATH = BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1');
const SERVER_BASE_URL = const SERVER_BASE_URL =
process.env.REACT_APP_SERVER_BASE_URL || process.env.REACT_APP_SERVER_BASE_URL ||
(process.env.NODE_ENV === 'production' ? BASE_URL : 'http://localhost:1337'); (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 SERVER_HOST_NAME = SERVER_BASE_URL.replace(/^(.*\/\/[^/?#]*).*$/, '$1');
const ACCESS_TOKEN_KEY = 'accessToken'; const ACCESS_TOKEN_KEY = 'accessToken';

View file

@ -1,5 +1,6 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const validator = require('validator'); const validator = require('validator');
const { v4: uuid } = require('uuid');
const { getRemoteAddress } = require('../../../utils/remoteAddress'); const { getRemoteAddress } = require('../../../utils/remoteAddress');
@ -34,6 +35,10 @@ module.exports = {
type: 'string', type: 'string',
required: true, required: true,
}, },
withHttpOnlyToken: {
type: 'boolean',
defaultsTo: false,
},
}, },
exits: { exits: {
@ -81,15 +86,24 @@ module.exports = {
: Errors.INVALID_CREDENTIALS; : 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({ await Session.create({
accessToken, accessToken,
httpOnlyToken,
remoteAddress, remoteAddress,
userId: user.id, userId: user.id,
userAgent: this.req.headers['user-agent'], userAgent: this.req.headers['user-agent'],
}); });
if (httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.setHttpOnlyTokenCookie(httpOnlyToken, accessTokenPayload, this.res);
}
return { return {
item: accessToken, item: accessToken,
}; };

View file

@ -1,20 +1,22 @@
module.exports = { module.exports = {
async fn() { async fn() {
const { accessToken } = this.req; const { currentSession } = this.req;
await Session.updateOne({ await Session.updateOne({
accessToken, id: currentSession.id,
deletedAt: null, deletedAt: null,
}).set({ }).set({
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
}); });
if (this.req.isSocket) { sails.sockets.leaveAll(`@accessToken:${currentSession.accessToken}`);
sails.sockets.leaveAll(`@accessToken:${accessToken}`);
if (currentSession.httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.clearHttpOnlyTokenCookie(this.res);
} }
return { return {
item: accessToken, item: currentSession.accessToken,
}; };
}, },
}; };

View file

@ -1,3 +1,5 @@
const { v4: uuid } = require('uuid');
const { getRemoteAddress } = require('../../../utils/remoteAddress'); const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = { const Errors = {
@ -28,6 +30,10 @@ module.exports = {
type: 'string', type: 'string',
required: true, required: true,
}, },
withHttpOnlyToken: {
type: 'boolean',
defaultsTo: false,
},
}, },
exits: { exits: {
@ -62,15 +68,24 @@ module.exports = {
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE) .intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
.intercept('missingValues', () => Errors.MISSING_VALUES); .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({ await Session.create({
accessToken, accessToken,
httpOnlyToken,
remoteAddress, remoteAddress,
userId: user.id, userId: user.id,
userAgent: this.req.headers['user-agent'], userAgent: this.req.headers['user-agent'],
}); });
if (httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.setHttpOnlyTokenCookie(httpOnlyToken, accessTokenPayload, this.res);
}
return { return {
item: accessToken, item: accessToken,
}; };

View file

@ -48,7 +48,7 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
const { currentUser } = this.req; const { currentSession, currentUser } = this.req;
if (inputs.id === currentUser.id) { if (inputs.id === currentUser.id) {
if (!inputs.currentPassword) { if (!inputs.currentPassword) {
@ -89,10 +89,14 @@ module.exports = {
} }
if (user.id === currentUser.id) { 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({ await Session.create({
accessToken, accessToken,
httpOnlyToken: currentSession.httpOnlyToken,
userId: user.id, userId: user.id,
remoteAddress: getRemoteAddress(this.req), remoteAddress: getRemoteAddress(this.req),
userAgent: this.req.headers['user-agent'], userAgent: this.req.headers['user-agent'],

View file

@ -68,7 +68,7 @@ module.exports = {
Object.assign(values, { Object.assign(values, {
password: bcrypt.hashSync(values.password, 10), password: bcrypt.hashSync(values.password, 10),
passwordChangedAt: new Date().toISOString(), passwordChangedAt: new Date().toUTCString(), // FIXME: hack
}); });
} }

View file

@ -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,
});
},
};

View file

@ -16,18 +16,23 @@ module.exports = {
fn(inputs) { fn(inputs) {
const { issuedAt = new Date() } = inputs; const { issuedAt = new Date() } = inputs;
const iat = Math.floor(issuedAt / 1000);
return jwt.sign( const iat = Math.floor(issuedAt / 1000);
{ const exp = iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60;
const payload = {
iat, iat,
exp,
sub: inputs.subject, sub: inputs.subject,
exp: iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60, };
},
sails.config.session.secret, const token = jwt.sign(payload, sails.config.session.secret, {
{
keyid: uuid(), keyid: uuid(),
}, });
);
return {
token,
payload,
};
}, },
}; };

View file

@ -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',
});
},
};

View file

@ -9,10 +9,10 @@
module.exports = function defineCurrentUserHook(sails) { module.exports = function defineCurrentUserHook(sails) {
const TOKEN_PATTERN = /^Bearer /; const TOKEN_PATTERN = /^Bearer /;
const getUser = async (accessToken) => { const getSessionAndUser = async (accessToken, httpOnlyToken) => {
let payload; let payload;
try { try {
payload = sails.helpers.utils.verifyToken(accessToken); payload = sails.helpers.utils.verifyJwtToken(accessToken);
} catch (error) { } catch (error) {
return null; return null;
} }
@ -26,13 +26,20 @@ module.exports = function defineCurrentUserHook(sails) {
return null; return null;
} }
if (session.httpOnlyToken && httpOnlyToken !== session.httpOnlyToken) {
return null;
}
const user = await sails.helpers.users.getOne(payload.subject); const user = await sails.helpers.users.getOne(payload.subject);
if (user && user.passwordChangedAt > payload.issuedAt) { if (user && user.passwordChangedAt > payload.issuedAt) {
return null; return null;
} }
return user; return {
session,
user,
};
}; };
return { return {
@ -52,17 +59,21 @@ module.exports = function defineCurrentUserHook(sails) {
if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) { if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) {
const accessToken = authorizationHeader.replace(TOKEN_PATTERN, ''); 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, { Object.assign(req, {
accessToken, currentSession: session,
currentUser, currentUser: user,
}); });
if (req.isSocket) { if (req.isSocket) {
sails.sockets.join(req, `@accessToken:${accessToken}`); sails.sockets.join(req, `@accessToken:${session.accessToken}`);
sails.sockets.join(req, `@user:${currentUser.id}`); sails.sockets.join(req, `@user:${user.id}`);
} }
} }
} }
@ -72,15 +83,17 @@ module.exports = function defineCurrentUserHook(sails) {
}, },
'/attachments/*': { '/attachments/*': {
async fn(req, res, next) { async fn(req, res, next) {
const { accessToken } = req.cookies; const { accessToken, httpOnlyToken } = req.cookies;
if (accessToken) { if (accessToken) {
const currentUser = await getUser(accessToken); const sessionAndUser = await getSessionAndUser(accessToken, httpOnlyToken);
if (sessionAndUser) {
const { session, user } = sessionAndUser;
if (currentUser) {
Object.assign(req, { Object.assign(req, {
accessToken, currentSession: session,
currentUser, currentUser: user,
}); });
} }
} }

View file

@ -16,7 +16,7 @@ module.exports = function defineWatcherHook(sails) {
const accessToken = room.split(':')[1]; const accessToken = room.split(':')[1];
try { try {
sails.helpers.utils.verifyToken(accessToken); sails.helpers.utils.verifyJwtToken(accessToken);
} catch (error) { } catch (error) {
sails.sockets.broadcast(room, 'logout'); sails.sockets.broadcast(room, 'logout');
sails.sockets.leaveAll(room); sails.sockets.leaveAll(room);

View file

@ -16,6 +16,12 @@ module.exports = {
required: true, required: true,
columnName: 'access_token', columnName: 'access_token',
}, },
httpOnlyToken: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'http_only_token',
},
remoteAddress: { remoteAddress: {
type: 'string', type: 'string',
required: true, required: true,

View file

@ -8,9 +8,12 @@
* https://sailsjs.com/config/custom * https://sailsjs.com/config/custom
*/ */
const url = require('url');
const path = require('path'); const path = require('path');
const sails = require('sails'); const sails = require('sails');
const parsedBasedUrl = new url.URL(process.env.BASE_URL);
module.exports.custom = { module.exports.custom = {
/** /**
* *
@ -19,6 +22,8 @@ module.exports.custom = {
*/ */
baseUrl: process.env.BASE_URL, baseUrl: process.env.BASE_URL,
baseUrlPath: parsedBasedUrl.pathname,
baseUrlSecure: parsedBasedUrl.protocol === 'https:',
tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365, tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365,

View file

@ -23,6 +23,8 @@ const url = require('url');
const { customLogger } = require('../../utils/logger'); const { customLogger } = require('../../utils/logger');
const parsedBasedUrl = new url.URL(process.env.BASE_URL);
module.exports = { module.exports = {
/** /**
* *
@ -131,9 +133,10 @@ module.exports = {
*/ */
cors: { cors: {
// allowOrigins: [ allRoutes: false,
// 'https://example.com', allowOrigins: '*',
// ], allowRequestHeaders: 'content-type',
allowCredentials: false,
}, },
}, },
@ -218,7 +221,7 @@ module.exports = {
* *
*/ */
onlyAllowOrigins: [new url.URL(process.env.BASE_URL).origin], onlyAllowOrigins: [parsedBasedUrl.origin],
/** /**
* *

View file

@ -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');
});