mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
feat: Additional httpOnly token for enhanced security in browsers
This commit is contained in:
parent
4176a62f1a
commit
9699fbe76a
18 changed files with 171 additions and 48 deletions
|
@ -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,
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
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) {
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
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) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
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 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],
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
|
@ -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