Merge pull request #52 from directus/refresh-token

Refresh token
This commit is contained in:
Rijk van Zanten
2020-07-07 11:32:21 -04:00
committed by GitHub
8 changed files with 224 additions and 25 deletions

View File

@@ -32,14 +32,16 @@ STORAGE_DIGITALOCEAN_BUCKET="my-files"
STORAGE_DIGITALOCEAN_REGION="ams3"
####################################################################################################
# Auth
# Security
SECRET="abcdef"
ACCESS_TOKEN_EXPIRY_TIME="15m"
REFRESH_TOKEN_EXPIRY_TIME="7d"
ACCESS_TOKEN_TTL="15m"
REFRESH_TOKEN_TTL="7d"
REFRESH_TOKEN_COOKIE_SECURE="false"
REFRESH_TOKEN_COOKIE_SAME_SITE="lax"
####################################################################################################
# SSO (oAuth) Providers
# SSO (OAuth) Providers
OAUTH_PROVIDERS="github, facebook"

29
package-lock.json generated
View File

@@ -309,6 +309,15 @@
"@types/node": "*"
}
},
"@types/cookie-parser": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz",
"integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/express": {
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz",
@@ -378,6 +387,12 @@
"integrity": "sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==",
"dev": true
},
"@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==",
"dev": true
},
"@types/node": {
"version": "14.0.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.13.tgz",
@@ -1371,6 +1386,15 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
},
"cookie-parser": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
"integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
"requires": {
"cookie": "0.4.0",
"cookie-signature": "1.0.6"
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -4279,6 +4303,11 @@
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
"integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw=="
},
"nanoid": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.10.tgz",
"integrity": "sha512-iZFMXKeXWkxzlfmMfM91gw7YhN2sdJtixY+eZh9V6QWJWTOiurhpKhBMgr82pfzgSqglQgqYSCowEYsz8D++6w=="
},
"nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",

View File

@@ -30,12 +30,14 @@
"devDependencies": {
"@types/atob": "^2.1.2",
"@types/busboy": "^0.2.3",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-pino-logger": "^4.0.2",
"@types/express-session": "^1.17.0",
"@types/hapi__joi": "^17.1.2",
"@types/jsonwebtoken": "^8.5.0",
"@types/lodash": "^4.14.156",
"@types/ms": "^0.7.31",
"@types/nodemailer": "^6.4.0",
"@types/pino": "^6.3.0",
"@types/sharp": "^0.25.0",
@@ -73,6 +75,7 @@
"body-parser": "^1.19.0",
"busboy": "^0.3.1",
"camelcase": "^6.0.0",
"cookie-parser": "^1.4.5",
"dotenv": "^8.2.0",
"exif-reader": "^1.0.3",
"express": "^4.17.1",
@@ -86,8 +89,10 @@
"knex": "^0.21.1",
"liquidjs": "^9.12.0",
"lodash": "^4.17.15",
"ms": "^2.1.2",
"mssql": "^6.2.0",
"mysql": "^2.18.1",
"nanoid": "^3.1.10",
"nodemailer": "^6.4.10",
"oracledb": "^4.2.0",
"pg": "^8.2.1",

View File

@@ -1,8 +1,9 @@
import { RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
import jwt, { TokenExpiredError } from 'jsonwebtoken';
import isJWT from '../utils/is-jwt';
import database from '../database';
import asyncHandler from 'express-async-handler';
import { InvalidCredentialsException } from '../exceptions';
/**
* Verify the passed JWT and assign the user ID and role to `req`
@@ -11,12 +12,24 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
if (!req.token) return next();
if (isJWT(req.token)) {
const payload = jwt.verify(req.token, process.env.SECRET) as { id: string };
let payload: { id: string };
try {
payload = jwt.verify(req.token, process.env.SECRET) as { id: string };
} catch (err) {
if (err instanceof TokenExpiredError) {
throw new InvalidCredentialsException('Token expired.');
} else {
throw err;
}
}
const user = await database
.select('role')
.from('directus_users')
.where({ id: payload.id })
.first();
/** @TODO verify user status */
req.user = payload.id;
req.role = user.role;

View File

@@ -8,6 +8,8 @@ import getGrantConfig from '../utils/get-grant-config';
import getEmailFromProfile from '../utils/get-email-from-profile';
import { InvalidPayloadException } from '../exceptions/invalid-payload';
import * as ActivityService from '../services/activity';
import ms from 'ms';
import cookieParser from 'cookie-parser';
const router = Router();
@@ -25,20 +27,97 @@ router.post(
const { email, password } = req.body;
const { token, id } = await AuthService.authenticate(email, password);
const mode = req.body.mode || 'json';
const ip = req.ip;
const userAgent = req.get('user-agent');
const {
accessToken,
refreshToken,
expires,
id,
refreshTokenExpiration,
} = await AuthService.authenticate({
ip,
userAgent,
email,
password,
});
ActivityService.createActivity({
action: ActivityService.Action.AUTHENTICATE,
collection: 'directus_users',
item: id,
ip: req.ip,
user_agent: req.get('user-agent'),
ip: ip,
user_agent: userAgent,
action_by: id,
});
return res.status(200).json({
data: { token },
});
const payload = {
data: { access_token: accessToken, expires },
} as Record<string, Record<string, any>>;
if (mode === 'json') {
payload.data.refresh_token = refreshToken;
}
if (mode === 'cookie') {
res.cookie('directus_refresh_token', refreshToken, {
httpOnly: true,
expires: refreshTokenExpiration,
maxAge: ms(process.env.REFRESH_TOKEN_TTL) / 1000,
secure: process.env.REFRESH_TOKEN_COOKIE_SECURE === 'true' ? true : false,
sameSite:
(process.env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') ||
'lax',
});
}
return res.status(200).json(payload);
})
);
router.post(
'/refresh',
cookieParser(),
asyncHandler(async (req, res) => {
const currentRefreshToken = req.body.refresh_token || req.cookies.directus_refresh_token;
if (!currentRefreshToken)
throw new InvalidPayloadException(
`"refresh_token" is required in either the JSON payload or Cookie`
);
const mode: 'json' | 'cookie' = req.body.mode || req.body.refresh_token ? 'json' : 'cookie';
const {
accessToken,
refreshToken,
expires,
refreshTokenExpiration,
} = await AuthService.refresh(currentRefreshToken);
const payload = {
data: { access_token: accessToken, expires },
} as Record<string, Record<string, any>>;
if (mode === 'json') {
payload.data.refresh_token = refreshToken;
}
if (mode === 'cookie') {
res.cookie('directus_refresh_token', refreshToken, {
httpOnly: true,
expires: refreshTokenExpiration,
maxAge: ms(process.env.REFRESH_TOKEN_TTL) / 1000,
secure: process.env.REFRESH_TOKEN_COOKIE_SECURE === 'true' ? true : false,
sameSite:
(process.env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') ||
'lax',
});
}
return res.status(200).json(payload);
})
);
@@ -49,12 +128,15 @@ router.use(
router.use(grant.express()(getGrantConfig()));
/**
* @todo allow json / cookie mode in SSO
*/
router.get(
'/sso/:provider/callback',
asyncHandler(async (req, res) => {
const email = getEmailFromProfile(req.params.provider, req.session.grant.response.profile);
const { token, id } = await AuthService.authenticate(email);
const { accessToken, refreshToken, expires, id } = await AuthService.authenticate(email);
ActivityService.createActivity({
action: ActivityService.Action.AUTHENTICATE,
@@ -66,7 +148,7 @@ router.get(
});
return res.status(200).json({
data: { token },
data: { access_token: accessToken, refresh_token: refreshToken, expires },
});
})
);

View File

@@ -1,26 +1,37 @@
import database from '../database';
import jwt from 'jsonwebtoken';
import argon2 from 'argon2';
import { nanoid } from 'nanoid';
import ms from 'ms';
import { InvalidCredentialsException } from '../exceptions';
import { Session } from '../types/sessions';
export const authenticate = async (email: string, password?: string) => {
type AuthenticateOptions = {
email: string;
password?: string;
ip?: string;
userAgent?: string;
};
/**
* Retrieve the tokens for a given user email.
*
* Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
* to handle password existence checks elsewhere
*/
export const authenticate = async ({ email, password, ip, userAgent }: AuthenticateOptions) => {
const user = await database
.select('id', 'password', 'role')
.from('directus_users')
.where({ email })
.first();
/** @todo check for status */
if (!user) {
throw new InvalidCredentialsException();
}
/**
* @NOTE
* This undefined check is on purpose so we can login through SSO without having to rely on
* password. However, this check might be a little tricky, as we don't want this login with just
* email to leak anywhere else.. We might have to make a dedicated "copy" of this function to
* signal the difference
*/
if (password !== undefined && (await argon2.verify(user.password, password)) === false) {
throw new InvalidCredentialsException();
}
@@ -34,9 +45,53 @@ export const authenticate = async (email: string, password?: string) => {
* Sign token with combination of server secret + user password hash
* That way, old tokens are immediately invalidated whenever the user changes their password
*/
const token = jwt.sign(payload, process.env.SECRET, {
expiresIn: process.env.ACCESS_TOKEN_EXPIRY_TIME,
const accessToken = jwt.sign(payload, process.env.SECRET, {
expiresIn: process.env.ACCESS_TOKEN_TTL,
});
return { token, id: user.id };
const refreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + ms(process.env.REFRESH_TOKEN_TTL));
await database('directus_sessions').insert({
token: refreshToken,
user: user.id,
expires: refreshTokenExpiration,
ip,
user_agent: userAgent,
});
return {
accessToken,
refreshToken,
expires: ms(process.env.ACCESS_TOKEN_TTL) / 1000,
id: user.id,
refreshTokenExpiration,
};
};
export const refresh = async (refreshToken: string) => {
if (!refreshToken) {
throw new InvalidCredentialsException();
}
const record = await database
.select<Session & { email: string }>('directus_sessions.*', 'directus_users.email')
.from('directus_sessions')
.where({ 'directus_sessions.token': refreshToken })
.leftJoin('directus_users', 'directus_sessions.user', 'directus_users.id')
.first();
/** @todo
* Check if it's worth checking for ip address and/or user agent. We could make this a little
* more secure by requiring the refresh token to be used from the same device / location as the
* auth session was created in the first place
*/
if (!record || !record.email || record.expires < new Date()) {
throw new InvalidCredentialsException();
}
await database.delete().from('directus_sessions').where({ token: refreshToken });
return await authenticate({ email: record.email, ip: record.ip, userAgent: record.user_agent });
};

View File

@@ -21,6 +21,12 @@ export const readUser = async (pk: string | number, query?: Query) => {
};
export const updateUser = async (pk: string | number, data: Record<string, any>, query?: Query) => {
/**
* @todo
* Remove "other" refresh token sessions when changing password to enforce "logout everywhere" on password change
*
* Maybe make this an option?
*/
return await ItemsService.updateItem('directus_users', pk, data, query);
};

7
src/types/sessions.ts Normal file
View File

@@ -0,0 +1,7 @@
export type Session = {
token: string;
user: string;
expires: Date;
ip: string | null;
user_agent: string | null;
};