mirror of
https://github.com/directus/directus.git
synced 2026-01-29 19:38:07 -05:00
28
example.env
28
example.env
@@ -1,7 +1,12 @@
|
||||
####################################################################################################
|
||||
# General
|
||||
PORT=3000
|
||||
|
||||
PORT=3000
|
||||
PUBLIC_URL="http://localhost:3000"
|
||||
|
||||
####################################################################################################
|
||||
# Database
|
||||
|
||||
DB_CLIENT="pg"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT=5432
|
||||
@@ -9,10 +14,31 @@ DB_NAME="directus"
|
||||
DB_USER="postgres"
|
||||
DB_PASSWORD="psql1234"
|
||||
|
||||
####################################################################################################
|
||||
# Auth
|
||||
|
||||
SECRET="abcdef"
|
||||
ACCESS_TOKEN_EXPIRY_TIME="15m"
|
||||
REFRESH_TOKEN_EXPIRY_TIME="7d"
|
||||
|
||||
####################################################################################################
|
||||
# SSO (oAuth) Providers
|
||||
|
||||
OAUTH_PROVIDERS="github,facebook"
|
||||
|
||||
OAUTH_GITHUB_KEY="abcdef"
|
||||
OAUTH_GITHUB_SECRET="ghijkl"
|
||||
OAUTH_FACEBOOK_KEY="abcdef"
|
||||
OAUTH_FACEBOOK_SECRET="ghijkl"
|
||||
|
||||
####################################################################################################
|
||||
# Extensions
|
||||
|
||||
EXTENSIONS_PATH="./extensions"
|
||||
|
||||
####################################################################################################
|
||||
# Email
|
||||
|
||||
EMAIL_TRANSPORT="sendmail"
|
||||
|
||||
## Email (Sendmail Transport)
|
||||
|
||||
781
package-lock.json
generated
781
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -28,10 +28,16 @@
|
||||
},
|
||||
"homepage": "https://github.com/directus/api-node#readme",
|
||||
"devDependencies": {
|
||||
"@types/atob": "^2.1.2",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/hapi__joi": "^17.1.2",
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/lodash": "^4.14.156",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/pino": "^6.3.0",
|
||||
"copyfiles": "^2.3.0",
|
||||
"eslint": "^7.3.1",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"husky": "^4.2.5",
|
||||
"lint-staged": "^10.2.10",
|
||||
@@ -53,13 +59,19 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/joi": "^17.1.1",
|
||||
"atob": "^2.1.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-async-handler": "^1.1.4",
|
||||
"express-session": "^1.17.1",
|
||||
"get-port": "^5.1.1",
|
||||
"grant": "^5.2.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"knex": "^0.21.1",
|
||||
"liquidjs": "^9.12.0",
|
||||
"lodash": "^4.17.15",
|
||||
"mssql": "^6.2.0",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.4.10",
|
||||
|
||||
@@ -4,9 +4,13 @@ dotenv.config();
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
|
||||
import { errorHandler } from './error';
|
||||
import { errorHandler, ErrorCode } from './error';
|
||||
|
||||
import extractToken from './middleware/extract-token';
|
||||
import authenticate from './middleware/authenticate';
|
||||
|
||||
import activityRouter from './routes/activity';
|
||||
import authRouter from './routes/auth';
|
||||
import collectionPresetsRouter from './routes/collection-presets';
|
||||
import extensionsRouter from './routes/extensions';
|
||||
import filesRouter from './routes/files';
|
||||
@@ -35,7 +39,10 @@ import notFoundHandler from './routes/not-found';
|
||||
const app = express()
|
||||
.disable('x-powered-by')
|
||||
.use(bodyParser.json())
|
||||
.use(extractToken)
|
||||
.use(authenticate)
|
||||
.use('/activity', activityRouter)
|
||||
.use('/auth', authRouter)
|
||||
.use('/collection_presets', collectionPresetsRouter)
|
||||
.use('/extensions', extensionsRouter)
|
||||
.use('/files', filesRouter)
|
||||
|
||||
53
src/error.ts
53
src/error.ts
@@ -1,4 +1,6 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { ValidationError } from '@hapi/joi';
|
||||
import { TokenExpiredError } from 'jsonwebtoken';
|
||||
import logger from './logger';
|
||||
|
||||
export enum ErrorCode {
|
||||
@@ -8,6 +10,11 @@ export enum ErrorCode {
|
||||
ENOENT = 'ENOENT',
|
||||
EXTENSION_ILLEGAL_TYPE = 'EXTENSION_ILLEGAL_TYPE',
|
||||
INVALID_QUERY = 'INVALID_QUERY',
|
||||
INVALID_USER_CREDENTIALS = 'INVALID_USER_CREDENTIALS',
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
FAILED_VALIDATION = 'FAILED_VALIDATION',
|
||||
MALFORMED_JSON = 'MALFORMED_JSON',
|
||||
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
||||
}
|
||||
|
||||
enum HTTPStatus {
|
||||
@@ -17,9 +24,19 @@ enum HTTPStatus {
|
||||
ENOENT = 501,
|
||||
EXTENSION_ILLEGAL_TYPE = 400,
|
||||
INVALID_QUERY = 400,
|
||||
INVALID_USER_CREDENTIALS = 401,
|
||||
USER_NOT_FOUND = 401,
|
||||
FAILED_VALIDATION = 422,
|
||||
MALFORMED_JSON = 400,
|
||||
TOKEN_EXPIRED = 401,
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error: APIError | Error, req, res, next) => {
|
||||
export const errorHandler: ErrorRequestHandler = (
|
||||
error: APIError | ValidationError | Error,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
let response: any = {};
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -33,6 +50,40 @@ export const errorHandler: ErrorRequestHandler = (error: APIError | Error, req,
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
} else if (error instanceof ValidationError) {
|
||||
logger.debug(error);
|
||||
|
||||
res.status(HTTPStatus.FAILED_VALIDATION);
|
||||
|
||||
response = {
|
||||
error: {
|
||||
code: ErrorCode.FAILED_VALIDATION,
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
} else if (error instanceof TokenExpiredError) {
|
||||
logger.debug(error);
|
||||
res.status(HTTPStatus.TOKEN_EXPIRED);
|
||||
response = {
|
||||
error: {
|
||||
code: ErrorCode.TOKEN_EXPIRED,
|
||||
message: 'The provided token is expired.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Syntax errors are most likely thrown by Body Parser when misaligned JSON is sent to the API
|
||||
else if (error instanceof SyntaxError && 'entity.parse.failed') {
|
||||
logger.debug(error);
|
||||
|
||||
res.status(HTTPStatus.MALFORMED_JSON);
|
||||
|
||||
response = {
|
||||
error: {
|
||||
code: ErrorCode.MALFORMED_JSON,
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
logger.error(error);
|
||||
|
||||
|
||||
32
src/middleware/authenticate.ts
Normal file
32
src/middleware/authenticate.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import isJWT from '../utils/is-jwt';
|
||||
import database from '../database';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
|
||||
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 };
|
||||
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;
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @TODO
|
||||
* Implement static tokens
|
||||
*
|
||||
* We'll silently ignore wrong tokens. This makes sure we prevent brute-forcing static tokens
|
||||
*/
|
||||
return next();
|
||||
});
|
||||
|
||||
export default authenticate;
|
||||
39
src/middleware/extract-token.ts
Normal file
39
src/middleware/extract-token.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Extract access token from:
|
||||
*
|
||||
* Authorization: Bearer
|
||||
* access_token query parameter
|
||||
*
|
||||
* and store in req.token
|
||||
*/
|
||||
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
const extractToken: RequestHandler = (req, res, next) => {
|
||||
let token: string | null = null;
|
||||
|
||||
if (req.query && req.query.access_token) {
|
||||
token = req.query.access_token as string;
|
||||
}
|
||||
|
||||
if (req.headers && req.headers.authorization) {
|
||||
const parts = req.headers.authorization.split(' ');
|
||||
|
||||
if (parts.length === 2 && parts[0] === 'Bearer') {
|
||||
token = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @TODO
|
||||
* Look into RFC6750 compliance:
|
||||
* In order to be fully compliant with RFC6750, we have to throw a 400 error when you have the
|
||||
* token in more than 1 place afaik. We also might have to support "access_token" as a post body
|
||||
* key
|
||||
*/
|
||||
|
||||
req.token = token;
|
||||
next();
|
||||
};
|
||||
|
||||
export default extractToken;
|
||||
54
src/routes/auth.ts
Normal file
54
src/routes/auth.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Router } from 'express';
|
||||
import session from 'express-session';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import Joi from '@hapi/joi';
|
||||
import * as AuthService from '../services/auth';
|
||||
import grant from 'grant';
|
||||
import getGrantConfig from '../utils/get-grant-config';
|
||||
import getEmailFromProfile from '../utils/get-email-from-profile';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().required(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/authenticate',
|
||||
asyncHandler(async (req, res) => {
|
||||
await loginSchema.validateAsync(req.body);
|
||||
const { email, password } = req.body;
|
||||
|
||||
/**
|
||||
* @TODO
|
||||
* Make sure to validate the payload. AuthService.authenticate's password is optional which
|
||||
* means there's a possible problem when req.body.password is undefined
|
||||
*/
|
||||
|
||||
const token = await AuthService.authenticate(email, password);
|
||||
|
||||
return res.status(200).json({
|
||||
data: { token },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.use('/sso', session({ secret: process.env.SECRET, saveUninitialized: true, resave: false }));
|
||||
|
||||
router.use(grant.express()(getGrantConfig()));
|
||||
|
||||
router.get(
|
||||
'/sso/:provider/callback',
|
||||
asyncHandler(async (req, res) => {
|
||||
const email = getEmailFromProfile(req.params.provider, req.session.grant.response.profile);
|
||||
|
||||
const token = await AuthService.authenticate(email);
|
||||
|
||||
return res.status(200).json({
|
||||
data: { token },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
42
src/services/auth.ts
Normal file
42
src/services/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import database from '../database';
|
||||
import APIError, { ErrorCode } from '../error';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export const authenticate = async (email: string, password?: string) => {
|
||||
const user = await database
|
||||
.select('id', 'password', 'role')
|
||||
.from('directus_users')
|
||||
.where({ email })
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new APIError(ErrorCode.INVALID_USER_CREDENTIALS, 'Invalid user credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 && password !== user.password) {
|
||||
/** @TODO implement password hash checking */
|
||||
throw new APIError(ErrorCode.INVALID_USER_CREDENTIALS, 'Invalid user credentials');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: user.id,
|
||||
};
|
||||
|
||||
/**
|
||||
* @TODO
|
||||
* 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,
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
11
src/types/express.d.ts
vendored
Normal file
11
src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Custom properties on the req object in express
|
||||
*/
|
||||
|
||||
declare namespace Express {
|
||||
export interface Request {
|
||||
token?: string;
|
||||
user?: string;
|
||||
role?: string;
|
||||
}
|
||||
}
|
||||
4
src/types/grant.d.ts
vendored
Normal file
4
src/types/grant.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'grant' {
|
||||
const grant: any;
|
||||
export default grant;
|
||||
}
|
||||
28
src/utils/get-email-from-profile.ts
Normal file
28
src/utils/get-email-from-profile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { get } from 'lodash';
|
||||
|
||||
// The path in JSON to fetch the email address from the profile.
|
||||
// Note: a lot of services use `email` as the path. We fall back to that as default, so no need to
|
||||
// map it here
|
||||
const profileMap = {};
|
||||
|
||||
/**
|
||||
* Extract the email address from a given user profile coming from a providers API
|
||||
*
|
||||
* Falls back to OAUTH_<PROVIDER>_PROFILE_EMAIL if we don't have it preconfigured yet, and defaults
|
||||
* to `email` if nothing is set
|
||||
*
|
||||
* This is used in the SSO flow to extract the users
|
||||
*/
|
||||
export default function getEmailFromProfile(provider: string, profile: Record<string, any>) {
|
||||
const path =
|
||||
profileMap[provider] ||
|
||||
process.env[`OAUTH_${provider.toUpperCase()}_PROFILE_EMAIL`] ||
|
||||
'email';
|
||||
const email = get(profile, path);
|
||||
|
||||
if (!email) {
|
||||
throw new Error("Couldn't extract email address from SSO provider response");
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
38
src/utils/get-grant-config.ts
Normal file
38
src/utils/get-grant-config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Reads the environment variables to construct the configuration object required by Grant
|
||||
*/
|
||||
export default function getGrantConfig() {
|
||||
const enabledProviders = process.env.OAUTH_PROVIDERS.split(',').map((provider) =>
|
||||
provider.trim()
|
||||
);
|
||||
|
||||
const config: any = {
|
||||
defaults: {
|
||||
origin: process.env.PUBLIC_URL,
|
||||
transport: 'session',
|
||||
prefix: '/auth/sso',
|
||||
response: ['tokens', 'profile'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('OAUTH') === false) continue;
|
||||
|
||||
const parts = key.split('_');
|
||||
const provider = parts[1].toLowerCase();
|
||||
|
||||
if (enabledProviders.includes(provider) === false) continue;
|
||||
|
||||
// OAUTH <PROVIDER> SETTING = VALUE
|
||||
parts.splice(0, 2);
|
||||
|
||||
const configKey = parts.join('_').toLowerCase();
|
||||
|
||||
config[provider] = {
|
||||
...(config[provider] || {}),
|
||||
[configKey]: value,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
31
src/utils/is-jwt.ts
Normal file
31
src/utils/is-jwt.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import atob from 'atob';
|
||||
|
||||
/**
|
||||
* Check if a given string conforms to the structure of a JWT.
|
||||
*/
|
||||
export default function isJWT(string: string) {
|
||||
const parts = string.split('.');
|
||||
|
||||
// JWTs have the structure header.payload.signature
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
// Check if all segments are base64 encoded
|
||||
try {
|
||||
atob(parts[0]);
|
||||
atob(parts[1]);
|
||||
atob(parts[2]);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the header and payload are valid JSON
|
||||
try {
|
||||
JSON.parse(atob(parts[0]));
|
||||
JSON.parse(atob(parts[1]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user