mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
10
example.env
10
example.env
@@ -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
29
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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
7
src/types/sessions.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Session = {
|
||||
token: string;
|
||||
user: string;
|
||||
expires: Date;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
};
|
||||
Reference in New Issue
Block a user