Merge pull request #21 from directus/auth

Initial auth implementation
This commit is contained in:
Rijk van Zanten
2020-06-24 12:40:51 -04:00
committed by GitHub
14 changed files with 1159 additions and 3 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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;

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
declare module 'grant' {
const grant: any;
export default grant;
}

View 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;
}

View 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
View 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;
}