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:
parent
d4043c9726
commit
50519f1bcd
18 changed files with 171 additions and 48 deletions
|
@ -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,
|
||||||
|
|
|
@ -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) => ({
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
16
server/api/helpers/utils/clear-http-only-token-cookie.js
Normal file
16
server/api/helpers/utils/clear-http-only-token-cookie.js
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
28
server/api/helpers/utils/set-http-only-token-cookie.js
Normal file
28
server/api/helpers/utils/set-http-only-token-cookie.js
Normal 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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
11
server/config/env/production.js
vendored
11
server/config/env/production.js
vendored
|
@ -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],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue