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 4176a62f1a
commit 9699fbe76a
18 changed files with 171 additions and 48 deletions

View file

@ -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,

View file

@ -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) => ({

View file

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

View file

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

View file

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

View file

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

View file

@ -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'],

View file

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

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) {
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,
exp,
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(),
},
);
});
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) {
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,
});
}
}

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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],
/**
*

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