mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
Add authenticate hook to implement custom auth checks against current request (#11942)
* Add "authenticate" filter hook that allows custom auth check * Start on test * Update Jest, restructure API tests, start implementing authenticate test * Move access token verify to util function * Ensure jest can show inline warnings on correct lines * Update is-directus-jwt to use jsonwebtoken decode + add tests * Remove unused package * Tweak and finish + test authenticate * Tweak test * Add authenticate filter to docs * Don't scan tests for codeql * No seriously, ignore tests
This commit is contained in:
@@ -30,6 +30,13 @@ module.exports = {
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.js'],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
plugins: ['jest'],
|
||||
},
|
||||
// Configuration for ts/vue files
|
||||
{
|
||||
files: ['*.ts', '*.vue'],
|
||||
|
||||
4
.github/codeql/codeql-config.yaml
vendored
Normal file
4
.github/codeql/codeql-config.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
paths-ignore:
|
||||
- '**/*.test.ts'
|
||||
- '**/*.test.js'
|
||||
- '**/node_modules'
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
contents: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
language:
|
||||
- javascript
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"cli": "cross-env NODE_ENV=development SERVE_APP=false ts-node --script-mode --transpile-only src/cli/run.ts",
|
||||
"test": "jest",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:watch": "jest --watchAll"
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
@@ -94,7 +94,6 @@
|
||||
"argon2": "^0.28.2",
|
||||
"async": "^3.2.0",
|
||||
"async-mutex": "^0.3.1",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "^0.24.0",
|
||||
"busboy": "^0.3.1",
|
||||
"camelcase": "^6.2.0",
|
||||
@@ -176,7 +175,6 @@
|
||||
"gitHead": "24621f3934dc77eb23441331040ed13c676ceffd",
|
||||
"devDependencies": {
|
||||
"@types/async": "3.2.10",
|
||||
"@types/atob": "2.1.2",
|
||||
"@types/body-parser": "1.19.2",
|
||||
"@types/busboy": "0.3.1",
|
||||
"@types/cookie-parser": "1.4.2",
|
||||
@@ -189,7 +187,7 @@
|
||||
"@types/flat": "5.0.2",
|
||||
"@types/fs-extra": "9.0.13",
|
||||
"@types/inquirer": "8.1.3",
|
||||
"@types/jest": "27.0.3",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/json2csv": "5.0.3",
|
||||
"@types/jsonwebtoken": "8.5.6",
|
||||
@@ -215,9 +213,9 @@
|
||||
"@types/wellknown": "0.5.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "7.0.3",
|
||||
"jest": "27.3.1",
|
||||
"jest": "27.5.1",
|
||||
"knex-mock-client": "1.6.1",
|
||||
"ts-jest": "27.0.7",
|
||||
"ts-jest": "27.1.3",
|
||||
"ts-node-dev": "1.1.8",
|
||||
"typescript": "4.5.2"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const cache = {
|
||||
get: jest.fn().mockResolvedValue(undefined),
|
||||
set: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
export const getCache = jest.fn().mockReturnValue({ cache });
|
||||
@@ -1,18 +1,20 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import { Accountability } from '@directus/shared/types';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { isEqual } from 'lodash';
|
||||
import getDatabase from '../database';
|
||||
import emitter from '../emitter';
|
||||
import env from '../env';
|
||||
import { InvalidCredentialsException } from '../exceptions';
|
||||
import { DirectusTokenPayload } from '../types';
|
||||
import asyncHandler from '../utils/async-handler';
|
||||
import { getIPFromReq } from '../utils/get-ip-from-req';
|
||||
import isDirectusJWT from '../utils/is-directus-jwt';
|
||||
import { verifyAccessJWT } from '../utils/jwt';
|
||||
|
||||
/**
|
||||
* Verify the passed JWT and assign the user ID and role to `req`
|
||||
*/
|
||||
const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
req.accountability = {
|
||||
export const handler = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const defaultAccountability: Accountability = {
|
||||
user: null,
|
||||
role: null,
|
||||
admin: false,
|
||||
@@ -21,21 +23,31 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
userAgent: req.get('user-agent'),
|
||||
};
|
||||
|
||||
const database = getDatabase();
|
||||
|
||||
const customAccountability = await emitter.emitFilter(
|
||||
'authenticate',
|
||||
defaultAccountability,
|
||||
{
|
||||
req,
|
||||
},
|
||||
{
|
||||
database,
|
||||
schema: null,
|
||||
accountability: null,
|
||||
}
|
||||
);
|
||||
|
||||
if (customAccountability && isEqual(customAccountability, defaultAccountability) === false) {
|
||||
req.accountability = customAccountability;
|
||||
return next();
|
||||
}
|
||||
|
||||
req.accountability = defaultAccountability;
|
||||
|
||||
if (req.token) {
|
||||
if (isDirectusJWT(req.token)) {
|
||||
let payload: DirectusTokenPayload;
|
||||
|
||||
try {
|
||||
payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as DirectusTokenPayload;
|
||||
} catch (err: any) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
throw new InvalidCredentialsException('Token expired.');
|
||||
} else if (err instanceof JsonWebTokenError) {
|
||||
throw new InvalidCredentialsException('Token invalid.');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const payload = verifyAccessJWT(req.token, env.SECRET);
|
||||
|
||||
req.accountability.share = payload.share;
|
||||
req.accountability.share_scope = payload.share_scope;
|
||||
@@ -44,8 +56,6 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
||||
req.accountability.app = payload.app_access === true || payload.app_access == 1;
|
||||
} else {
|
||||
const database = getDatabase();
|
||||
|
||||
// Try finding the user with the provided token
|
||||
const user = await database
|
||||
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
|
||||
@@ -69,6 +79,6 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
export default authenticate;
|
||||
export default asyncHandler(handler);
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
import atob from 'atob';
|
||||
import logger from '../logger';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Check if a given string conforms to the structure of a JWT
|
||||
* and whether it is issued by Directus.
|
||||
*/
|
||||
export default function isDirectusJWT(string: string): boolean {
|
||||
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: any) {
|
||||
logger.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the header and payload are valid JSON
|
||||
try {
|
||||
JSON.parse(atob(parts[0]));
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
if (payload.iss !== 'directus') return false;
|
||||
const payload = jwt.decode(string, { json: true });
|
||||
if (payload?.iss !== 'directus') return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
29
api/src/utils/jwt.ts
Normal file
29
api/src/utils/jwt.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import { DirectusTokenPayload } from '../types';
|
||||
import { InvalidTokenException, ServiceUnavailableException } from '../exceptions';
|
||||
|
||||
export function verifyAccessJWT(token: string, secret: string): DirectusTokenPayload {
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = jwt.verify(token, secret, {
|
||||
issuer: 'directus',
|
||||
}) as Record<string, any>;
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
throw new InvalidTokenException('Token expired.');
|
||||
} else if (err instanceof JsonWebTokenError) {
|
||||
throw new InvalidTokenException('Token invalid.');
|
||||
} else {
|
||||
throw new ServiceUnavailableException(`Couldn't verify token.`, { service: 'jwt' });
|
||||
}
|
||||
}
|
||||
|
||||
const { id, role, app_access, admin_access, share, share_scope } = payload;
|
||||
|
||||
if (role === undefined || app_access === undefined || admin_access === undefined) {
|
||||
throw new InvalidTokenException('Invalid token payload.');
|
||||
}
|
||||
|
||||
return { id, role, app_access, admin_access, share, share_scope };
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Command } from 'commander';
|
||||
import { Extension, HookConfig } from '@directus/shared/types';
|
||||
import { createCli } from '../../../src/cli/index';
|
||||
import { createCli } from '../../src/cli/index';
|
||||
|
||||
jest.mock('../../../src/env', () => ({
|
||||
...jest.requireActual('../../../src/env').default,
|
||||
jest.mock('../../src/env', () => ({
|
||||
...jest.requireActual('../../src/env').default,
|
||||
EXTENSIONS_PATH: '',
|
||||
SERVE_APP: false,
|
||||
DB_CLIENT: 'pg',
|
||||
@@ -1,6 +1,6 @@
|
||||
import knex, { Knex } from 'knex';
|
||||
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
|
||||
import run from '../../../../src/database/migrations/run';
|
||||
import run from '../../../src/database/migrations/run';
|
||||
|
||||
describe('run', () => {
|
||||
let db: jest.Mocked<Knex>;
|
||||
@@ -1,76 +0,0 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import checkCacheMiddleware from '../../../src/middleware/cache';
|
||||
|
||||
jest.mock('../../../src/cache');
|
||||
jest.mock('../../../src/env', () => ({
|
||||
CACHE_ENABLED: true,
|
||||
CACHE_NAMESPACE: 'test',
|
||||
CACHE_STORE: 'memory',
|
||||
CACHE_TTL: '5s',
|
||||
CACHE_CONTROL_S_MAXAGE: true,
|
||||
}));
|
||||
|
||||
const { cache } = jest.requireMock('../../../src/cache');
|
||||
const env = jest.requireMock('../../../src/env');
|
||||
|
||||
const handler = jest.fn((req, res) => res.json({ data: 'Uncached value' }));
|
||||
const setup = () => express().use(checkCacheMiddleware).all('/items/test', handler);
|
||||
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
describe('cache middleware', () => {
|
||||
test('should return the cached response for a request', async () => {
|
||||
cache.get.mockResolvedValueOnce({ data: 'Cached value' });
|
||||
cache.get.mockResolvedValueOnce(new Date().getTime() + 1000 * 60);
|
||||
|
||||
const res = await request(setup()).get('/items/test').send();
|
||||
|
||||
expect(res.body.data).toBe('Cached value');
|
||||
expect(res.headers['vary']).toBe('Origin, Cache-Control');
|
||||
expect(res.headers['cache-control']).toMatch(/public, max-age=\d+, s-maxage=\d+/);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should call the handler when there is no cached value', async () => {
|
||||
cache.get.mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await request(setup()).get('/items/test').send();
|
||||
|
||||
expect(res.body.data).toBe('Uncached value');
|
||||
expect(cache.get).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should not cache requests then the cache is disabled', async () => {
|
||||
env.CACHE_ENABLED = false;
|
||||
|
||||
const res = await request(setup()).get('/items/test').send();
|
||||
|
||||
expect(res.body.data).toBe('Uncached value');
|
||||
expect(cache.get).not.toHaveBeenCalled();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
env.CACHE_ENABLED = true;
|
||||
});
|
||||
|
||||
test('should not use cache when the "Cache-Control" header is set to "no-store"', async () => {
|
||||
const res = await request(setup()).get('/items/test').set('Cache-Control', 'no-store').send();
|
||||
|
||||
expect(res.body.data).toBe('Uncached value');
|
||||
expect(cache.get).not.toHaveBeenCalled();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should only cache get requests', async () => {
|
||||
const app = setup();
|
||||
|
||||
await request(app).post('/items/test').send();
|
||||
await request(app).put('/items/test').send();
|
||||
await request(app).patch('/items/test').send();
|
||||
await request(app).delete('/items/test').send();
|
||||
|
||||
expect(cache.get).not.toHaveBeenCalled();
|
||||
expect(handler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
217
api/tests/middleware/authenticate.test.ts
Normal file
217
api/tests/middleware/authenticate.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import getDatabase from '../../src/database';
|
||||
import emitter from '../../src/emitter';
|
||||
import env from '../../src/env';
|
||||
import { InvalidCredentialsException } from '../../src/exceptions';
|
||||
import { handler } from '../../src/middleware/authenticate';
|
||||
import '../../src/types/express.d.ts';
|
||||
|
||||
jest.mock('../../src/database');
|
||||
jest.mock('../../src/env', () => ({
|
||||
SECRET: 'test',
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('Short-circuits when authenticate filter is used', async () => {
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const res = {};
|
||||
const next = jest.fn();
|
||||
|
||||
const customAccountability = { admin: true };
|
||||
|
||||
jest.spyOn(emitter, 'emitFilter').mockResolvedValue(customAccountability);
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(req.accountability).toEqual(customAccountability);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Uses default public accountability when no token is given', async () => {
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
|
||||
};
|
||||
|
||||
const res = {};
|
||||
const next = jest.fn();
|
||||
|
||||
jest.spyOn(emitter, 'emitFilter').mockImplementation((_, payload) => payload);
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(req.accountability).toEqual({
|
||||
user: null,
|
||||
role: null,
|
||||
admin: false,
|
||||
app: false,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'fake-user-agent',
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Sets accountability to payload contents if valid token is passed', async () => {
|
||||
const userID = '3fac3c02-607f-4438-8d6e-6b8b25109b52';
|
||||
const roleID = '38269fc6-6eb6-475a-93cb-479d97f73039';
|
||||
const share = 'ca0ad005-f4ad-4bfe-b428-419ee8784790';
|
||||
const shareScope = {
|
||||
collection: 'articles',
|
||||
item: 15,
|
||||
};
|
||||
const appAccess = true;
|
||||
const adminAccess = false;
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: userID,
|
||||
role: roleID,
|
||||
app_access: appAccess,
|
||||
admin_access: adminAccess,
|
||||
share,
|
||||
share_scope: shareScope,
|
||||
},
|
||||
env.SECRET,
|
||||
{ issuer: 'directus' }
|
||||
);
|
||||
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
|
||||
token,
|
||||
};
|
||||
|
||||
const res = {};
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(req.accountability).toEqual({
|
||||
user: userID,
|
||||
role: roleID,
|
||||
app: appAccess,
|
||||
admin: adminAccess,
|
||||
share,
|
||||
share_scope: shareScope,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'fake-user-agent',
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test with 1/0 instead or true/false
|
||||
next.mockClear();
|
||||
|
||||
req.token = jwt.sign(
|
||||
{
|
||||
id: userID,
|
||||
role: roleID,
|
||||
app_access: 1,
|
||||
admin_access: 0,
|
||||
share,
|
||||
share_scope: shareScope,
|
||||
},
|
||||
env.SECRET,
|
||||
{ issuer: 'directus' }
|
||||
);
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(req.accountability).toEqual({
|
||||
user: userID,
|
||||
role: roleID,
|
||||
app: appAccess,
|
||||
admin: adminAccess,
|
||||
share,
|
||||
share_scope: shareScope,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'fake-user-agent',
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Throws InvalidCredentialsException when static token is used, but user does not exist', async () => {
|
||||
jest.mocked(getDatabase).mockReturnValue({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
first: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
|
||||
token: 'static-token',
|
||||
};
|
||||
|
||||
const res = {};
|
||||
const next = jest.fn();
|
||||
|
||||
expect(handler(req, res, next)).rejects.toEqual(new InvalidCredentialsException());
|
||||
expect(next).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Sets accountability to user information when static token is used', async () => {
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
|
||||
token: 'static-token',
|
||||
};
|
||||
|
||||
const res = {};
|
||||
const next = jest.fn();
|
||||
|
||||
const testUser = { id: 'test-id', role: 'test-role', admin_access: true, app_access: false };
|
||||
|
||||
const expectedAccountability = {
|
||||
user: testUser.id,
|
||||
role: testUser.role,
|
||||
app: testUser.app_access,
|
||||
admin: testUser.admin_access,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'fake-user-agent',
|
||||
};
|
||||
|
||||
jest.mocked(getDatabase).mockReturnValue({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
first: jest.fn().mockResolvedValue(testUser),
|
||||
});
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(req.accountability).toEqual(expectedAccountability);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test for 0 / 1 instead of false / true
|
||||
next.mockClear();
|
||||
testUser.admin_access = 1;
|
||||
testUser.app_access = 0;
|
||||
await handler(req, res, next);
|
||||
expect(req.accountability).toEqual(expectedAccountability);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test for "1" / "0" instead of true / false
|
||||
next.mockClear();
|
||||
testUser.admin_access = '0';
|
||||
testUser.app_access = '1';
|
||||
expectedAccountability.admin = false;
|
||||
expectedAccountability.app = true;
|
||||
await handler(req, res, next);
|
||||
expect(req.accountability).toEqual(expectedAccountability);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
67
api/tests/middleware/extract-token.test.ts
Normal file
67
api/tests/middleware/extract-token.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import extractToken from '../../src/middleware/extract-token';
|
||||
import '../../src/types/express.d.ts';
|
||||
|
||||
let mockRequest: Partial<Request & { token?: string }>;
|
||||
let mockResponse: Partial<Response>;
|
||||
const nextFunction: NextFunction = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {};
|
||||
mockResponse = {};
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Token from query', () => {
|
||||
mockRequest = {
|
||||
query: {
|
||||
access_token: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBe('test');
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Token from Authorization header (capitalized)', () => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
authorization: 'Bearer test',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBe('test');
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Token from Authorization header (lowercase)', () => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
authorization: 'bearer test',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBe('test');
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Ignore the token if authorization header is too many parts', () => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
authorization: 'bearer test what another one',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBeNull();
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Null if no token passed', () => {
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBeNull();
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import knex, { Knex } from 'knex';
|
||||
import { MockClient, Tracker, getTracker } from 'knex-mock-client';
|
||||
import { ItemsService } from '../../../src/services';
|
||||
import { sqlFieldFormatter, sqlFieldList } from '../../__test-utils__/items-utils';
|
||||
import { systemSchema, userSchema } from '../../__test-utils__/schemas';
|
||||
import { ItemsService } from '../../src/services';
|
||||
import { sqlFieldFormatter, sqlFieldList } from '../__test-utils__/items-utils';
|
||||
import { systemSchema, userSchema } from '../__test-utils__/schemas';
|
||||
|
||||
jest.mock('../../../src/database/index', () => {
|
||||
jest.mock('../../src/database/index', () => {
|
||||
return { getDatabaseClient: jest.fn().mockReturnValue('postgres') };
|
||||
});
|
||||
jest.requireMock('../../../src/database/index');
|
||||
jest.requireMock('../../src/database/index');
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
let db: jest.Mocked<Knex>;
|
||||
@@ -1,11 +1,11 @@
|
||||
import knex, { Knex } from 'knex';
|
||||
import { MockClient, Tracker, getTracker } from 'knex-mock-client';
|
||||
import { PayloadService } from '../../../src/services';
|
||||
import { PayloadService } from '../../src/services';
|
||||
|
||||
jest.mock('../../../src/database/index', () => {
|
||||
jest.mock('../../src/database/index', () => {
|
||||
return { getDatabaseClient: jest.fn().mockReturnValue('postgres') };
|
||||
});
|
||||
jest.requireMock('../../../src/database/index');
|
||||
jest.requireMock('../../src/database/index');
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
let db: jest.Mocked<Knex>;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import extractToken from '../../../src/middleware/extract-token';
|
||||
import '../../../src/types/express.d.ts';
|
||||
|
||||
describe('Middleware / Extract Token', () => {
|
||||
let mockRequest: Partial<Request & { token?: string }>;
|
||||
let mockResponse: Partial<Response>;
|
||||
const nextFunction: NextFunction = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {};
|
||||
mockResponse = {};
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Token from query', () => {
|
||||
mockRequest = {
|
||||
query: {
|
||||
access_token: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBe('test');
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Token from Authorization header (capitalized)', () => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
authorization: 'Bearer test',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBe('test');
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Token from Authorization header (lowercase)', () => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
authorization: 'bearer test',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBe('test');
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Ignore the token if authorization header is too many parts', () => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
authorization: 'bearer test what another one',
|
||||
},
|
||||
};
|
||||
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBeNull();
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Null if no token passed', () => {
|
||||
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
expect(mockRequest.token).toBeNull();
|
||||
expect(nextFunction).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request } from 'express';
|
||||
import { getCacheKey } from '../../../src/utils/get-cache-key';
|
||||
import { getCacheKey } from '../../src/utils/get-cache-key';
|
||||
|
||||
const restUrl = 'http://localhost/items/example';
|
||||
const graphQlUrl = 'http://localhost/graphql';
|
||||
25
api/tests/utils/is-directus-jwt.test.ts
Normal file
25
api/tests/utils/is-directus-jwt.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import isDirectusJWT from '../../src/utils/is-directus-jwt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
test('Returns false for non JWT string', () => {
|
||||
const result = isDirectusJWT('test');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('Returns false for JWTs with text payload', () => {
|
||||
const token = jwt.sign('plaintext', 'secret');
|
||||
const result = isDirectusJWT(token);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test(`Returns false if token issuer isn't "directus"`, () => {
|
||||
const token = jwt.sign({ payload: 'content' }, 'secret', { issuer: 'rijk' });
|
||||
const result = isDirectusJWT(token);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test(`Returns true if token is valid JWT and issuer is "directus"`, () => {
|
||||
const token = jwt.sign({ payload: 'content' }, 'secret', { issuer: 'directus' });
|
||||
const result = isDirectusJWT(token);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
37
api/tests/utils/jwt.test.ts
Normal file
37
api/tests/utils/jwt.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { verifyAccessJWT } from '../../src/utils/jwt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { InvalidTokenException, ServiceUnavailableException } from '../../src/exceptions';
|
||||
import { DirectusTokenPayload } from '../../src/types';
|
||||
|
||||
const payload: DirectusTokenPayload = { role: null, app_access: false, admin_access: false };
|
||||
const secret = 'test-secret';
|
||||
const options = { issuer: 'directus' };
|
||||
|
||||
test('Returns the payload of a correctly signed token', () => {
|
||||
const token = jwt.sign(payload, secret, options);
|
||||
const result = verifyAccessJWT(token, secret);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
const InvalidTokenCases = {
|
||||
'wrong issuer': jwt.sign(payload, secret, { issuer: 'wrong' }),
|
||||
'wrong secret': jwt.sign(payload, 'wrong-secret', options),
|
||||
expired: jwt.sign({ ...payload, exp: new Date().getTime() / 1000 - 500 }, secret, options),
|
||||
'string payload': jwt.sign('illegal payload', secret),
|
||||
'missing properties in token payload': jwt.sign({ role: null }, secret, options),
|
||||
};
|
||||
|
||||
Object.entries(InvalidTokenCases).forEach(([title, token]) =>
|
||||
test(`Throws InvalidTokenError - ${title}`, () => {
|
||||
expect(() => verifyAccessJWT(token, secret)).toThrow(InvalidTokenException);
|
||||
})
|
||||
);
|
||||
|
||||
test(`Throws ServiceUnavailableException for unexpected error from jsonwebtoken`, () => {
|
||||
jest.spyOn(jwt, 'verify').mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
const token = jwt.sign(payload, secret, options);
|
||||
expect(() => verifyAccessJWT(token, secret)).toThrow(ServiceUnavailableException);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mergePermission } from '../../../src/utils/merge-permissions';
|
||||
import { mergePermission } from '../../src/utils/merge-permissions';
|
||||
import { Permission, Filter } from '@directus/shared/types';
|
||||
|
||||
const fullFilter = {} as Filter;
|
||||
@@ -146,20 +146,21 @@ export default ({ schedule }) => {
|
||||
|
||||
### Filter Events
|
||||
|
||||
| Name | Payload | Meta |
|
||||
| ----------------------------- | -------------------- | ------------------------------------ |
|
||||
| `request.not_found` | `false` | `request`, `response` |
|
||||
| `request.error` | The request errors | -- |
|
||||
| `database.error` | The database error | `client` |
|
||||
| `auth.login` | The login payload | `status`, `user`, `provider` |
|
||||
| `auth.jwt` | The auth token | `status`, `user`, `provider`, `type` |
|
||||
| `(<collection>.)items.read` | The read item | `collection` |
|
||||
| `(<collection>.)items.create` | The new item | `collection` |
|
||||
| `(<collection>.)items.update` | The updated item | `keys`, `collection` |
|
||||
| `(<collection>.)items.delete` | The keys of the item | `collection` |
|
||||
| `<system-collection>.create` | The new item | `collection` |
|
||||
| `<system-collection>.update` | The updated item | `keys`, `collection` |
|
||||
| `<system-collection>.delete` | The keys of the item | `collection` |
|
||||
| Name | Payload | Meta |
|
||||
| ----------------------------- | ------------------------------- | ------------------------------------ |
|
||||
| `request.not_found` | `false` | `request`, `response` |
|
||||
| `request.error` | The request errors | -- |
|
||||
| `database.error` | The database error | `client` |
|
||||
| `auth.login` | The login payload | `status`, `user`, `provider` |
|
||||
| `auth.jwt` | The auth token | `status`, `user`, `provider`, `type` |
|
||||
| `authenticate` | The empty accountability object | `req` |
|
||||
| `(<collection>.)items.read` | The read item | `collection` |
|
||||
| `(<collection>.)items.create` | The new item | `collection` |
|
||||
| `(<collection>.)items.update` | The updated item | `keys`, `collection` |
|
||||
| `(<collection>.)items.delete` | The keys of the item | `collection` |
|
||||
| `<system-collection>.create` | The new item | `collection` |
|
||||
| `<system-collection>.update` | The updated item | `keys`, `collection` |
|
||||
| `<system-collection>.delete` | The keys of the item | `collection` |
|
||||
|
||||
::: tip System Collections
|
||||
|
||||
|
||||
@@ -16,4 +16,11 @@ module.exports = {
|
||||
'@directus/shared(.*)$': `${__dirname}/packages/shared/src/$1`,
|
||||
'@directus/specs(.*)$': `${__dirname}/packages/specs/$1`,
|
||||
},
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: {
|
||||
sourceMap: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
1718
package-lock.json
generated
1718
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@types/dockerode": "3.3.0",
|
||||
"@types/faker": "5.5.9",
|
||||
"@types/jest": "27.0.3",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/listr": "0.14.4",
|
||||
"@types/node": "16.11.9",
|
||||
"@types/supertest": "2.0.11",
|
||||
@@ -44,7 +44,7 @@
|
||||
"eslint-plugin-vue": "7.20.0",
|
||||
"faker": "5.5.3",
|
||||
"globby": "11.0.4",
|
||||
"jest": "27.3.1",
|
||||
"jest": "27.5.1",
|
||||
"knex": "0.95.15",
|
||||
"lerna": "4.0.0",
|
||||
"lint-staged": "11.2.6",
|
||||
@@ -65,7 +65,7 @@
|
||||
"stylelint-scss": "4.0.0",
|
||||
"supertest": "6.1.6",
|
||||
"tedious": "13.2.0",
|
||||
"ts-jest": "27.0.7",
|
||||
"ts-jest": "27.1.3",
|
||||
"ts-node": "10.4.0"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
|
||||
Reference in New Issue
Block a user