feat: support token blacklist (#2168)

* feat: support token blacklist, Close T-799

* feat: clean

* fix: possible token does not exist

* fix: update

* feat: update

* feat: add node-cron to delete expired token

* fix: findOrCreate not work and add test case

* test: add token-blacklist tests

* feat: add test cases for blacklist in authManager

* test: update better

* fix: should hidden token field

* test: clean

* test: clean

* fix: should stop cron in afterStop

* refactor: move delete expired token in token blacklist service

* feat: remove plugin disable/enable logic

* fix: clean

* test: revert

* fix: cron typo
This commit is contained in:
Dunqing 2023-07-05 21:57:57 +08:00 committed by GitHub
parent 207ad61c63
commit 25a3a8affa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 357 additions and 30 deletions

View File

@ -1,6 +1,7 @@
import { Context } from '@nocobase/actions';
import { Auth, AuthManager } from '@nocobase/auth';
import { Model } from '@nocobase/database';
import Database, { Model } from '@nocobase/database';
import { MockServer, mockServer } from '@nocobase/test';
class MockStorer {
elements: Map<string, any> = new Map();
@ -47,4 +48,69 @@ describe('auth-manager', () => {
const authenticator = await authManager.get('basic-test', {} as Context);
expect(authenticator).toBeInstanceOf(BasicAuth);
});
describe('middleware', () => {
let app: MockServer;
let db: Database;
let agent;
beforeEach(async () => {
app = mockServer({
registerActions: true,
acl: true,
plugins: ['users', 'auth', 'acl'],
});
// app.plugin(ApiKeysPlugin);
await app.loadAndInstall({ clean: true });
db = app.db;
agent = app.agent();
});
afterEach(async () => {
await db.close();
});
describe('blacklist', () => {
const hasFn = jest.fn();
const addFn = jest.fn();
beforeEach(async () => {
await agent.login(1);
app.authManager.setTokenBlacklistService({
has: hasFn,
add: addFn,
});
});
afterEach(() => {
hasFn.mockReset();
addFn.mockReset();
});
it('basic', async () => {
const res = await agent.resource('auth').check();
const token = res.request.header['Authorization'].replace('Bearer ', '');
expect(res.status).toBe(200);
expect(hasFn).toHaveBeenCalledWith(token);
});
it('signOut should add token to blacklist', async () => {
// signOut will add token
const res = await agent.resource('auth').signOut();
const token = res.request.header['Authorization'].replace('Bearer ', '');
expect(addFn).toHaveBeenCalledWith({
token,
// Date or String is ok
expiration: expect.any(String),
});
});
it('should throw 401 when token in blacklist', async () => {
hasFn.mockImplementation(() => true);
const res = await agent.resource('auth').check();
expect(res.status).toBe(401);
expect(res.text).toContain('token is not available');
});
});
});
});

View File

@ -3,6 +3,7 @@ import { Model } from '@nocobase/database';
import { Registry } from '@nocobase/utils';
import { Auth, AuthExtend } from './auth';
import { JwtOptions, JwtService } from './base/jwt-service';
import { ITokenBlacklistService } from './base/token-blacklist-service';
type Storer = {
get: (name: string) => Promise<Model>;
@ -34,6 +35,10 @@ export class AuthManager {
this.storer = storer;
}
setTokenBlacklistService(service: ITokenBlacklistService) {
this.jwt.blacklist = service;
}
/**
* registerTypes
* @description Add a new authenticate type and the corresponding authenticator.
@ -81,6 +86,11 @@ export class AuthManager {
*/
middleware() {
return async (ctx: Context & { auth: Auth }, next: Next) => {
const token = ctx.getBearerToken();
if (token && (await ctx.app.authManager.jwt.blacklist.has(token))) {
return ctx.throw(401, ctx.t('token is not available'));
}
const name = ctx.get(this.options.authKey) || this.options.default;
let authenticator: Auth;
try {

View File

@ -81,4 +81,12 @@ export class BaseAuth extends Auth {
token,
};
}
async signOut(): Promise<any> {
const token = this.ctx.getBearerToken();
if (!token) {
return;
}
return await this.jwt.block(token);
}
}

View File

@ -1,4 +1,5 @@
import jwt, { SignOptions } from 'jsonwebtoken';
import { ITokenBlacklistService } from './token-blacklist-service';
export interface JwtOptions {
secret: string;
@ -20,6 +21,8 @@ export class JwtService {
};
}
public blacklist: ITokenBlacklistService;
private expiresIn() {
return this.options.expiresIn;
}
@ -43,4 +46,19 @@ export class JwtService {
});
});
}
/**
* @description Block a token so that this token can no longer be used
*/
async block(token: string) {
if (!this.blacklist) {
return null;
}
const { exp } = await this.decode(token);
return this.blacklist.add({
token,
expiration: new Date(exp * 1000).toString(),
});
}
}

View File

@ -0,0 +1,4 @@
export interface ITokenBlacklistService {
has(token: string): Promise<boolean>;
add(values: { token: string; expiration: string | Date }): Promise<any>;
}

View File

@ -1,4 +1,5 @@
export * from './auth-manager';
export * from './auth';
export * from './base/auth';
export * from './actions';
export * from './auth';
export * from './auth-manager';
export * from './base/auth';
export * from './base/token-blacklist-service';

View File

@ -99,6 +99,9 @@ export class MockServer extends Application {
userId: typeof userOrId === 'number' ? userOrId : userOrId?.id,
},
process.env.APP_KEY,
{
expiresIn: '1d',
},
),
{ type: 'bearer' },
)

View File

@ -78,5 +78,10 @@ export default {
],
},
},
{
name: 'token',
type: 'string',
hidden: true,
},
],
} as CollectionOptions;

View File

@ -15,7 +15,6 @@ describe('actions', () => {
plugins: ['users', 'auth', 'api-keys', 'acl'],
});
// app.plugin(ApiKeysPlugin);
await app.loadAndInstall({ clean: true });
db = app.db;
repo = db.getRepository('apiKeys');
@ -151,7 +150,7 @@ describe('actions', () => {
expect(res.body.data.length).toBe(1);
const data = res.body.data[0];
await resource.destroy({
id: data.id,
filterByTk: data.id,
});
expect((await resource.list()).body.data.length).toBe(0);
});
@ -162,10 +161,21 @@ describe('actions', () => {
const data = res.body.data[0];
await agent.login(testUser);
await resource.destroy({
id: data.id,
filterByTk: data.id,
});
await agent.login(user);
expect((await resource.list()).body.data.length).toBe(1);
});
it('The token should not work after removing the api key', async () => {
const res = await resource.list();
expect(res.body.data.length).toBe(1);
const data = res.body.data[0];
await resource.destroy({
filterByTk: data.id,
});
const response = await agent.set('Authorization', `Bearer ${result.token}`).resource('auth').check();
expect(response.status).toBe(401);
});
});
});

View File

@ -18,15 +18,32 @@ export async function create(ctx: Context, next: Next) {
throw ctx.throw(400, ctx.t('Role not found'));
}
const token = ctx.app.authManager.jwt.sign(
{ userId: ctx.auth.user.id, roleName: role.name },
{ expiresIn: values.expiresIn },
);
ctx.action.mergeParams({
values: {
token,
},
});
return actions.create(ctx, async () => {
const token = ctx.app.authManager.jwt.sign(
{ userId: ctx.auth.user.id, roleName: role.name },
{ expiresIn: values.expiresIn },
);
ctx.body = {
token,
};
await next();
});
}
export async function destroy(ctx: Context, next: Next) {
const repo = ctx.db.getRepository(ctx.action.resourceName);
const { filterByTk } = ctx.action.params;
const data = await repo.findById(filterByTk);
const token = data?.get('token');
if (token) {
await ctx.app.authManager.jwt.block(token);
}
return actions.destroy(ctx, next);
}

View File

@ -1,7 +1,7 @@
import { Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { NAMESPACE } from '../constants';
import { create } from './actions/api-keys';
import { create, destroy } from './actions/api-keys';
import { enUS, zhCN } from './locale';
export interface ApiKeysPluginConfig {
@ -22,6 +22,7 @@ export default class ApiKeysPlugin extends Plugin<ApiKeysPluginConfig> {
name: this.resourceName,
actions: {
create,
destroy,
},
only: ['list', 'create', 'destroy'],
});

View File

@ -3,11 +3,15 @@
"version": "0.10.0-alpha.5",
"main": "lib/server/index.js",
"devDependencies": {
"@nocobase/test": "0.10.0-alpha.5",
"@types/cron": "^2.0.1"
},
"dependencies": {
"@nocobase/actions": "0.10.0-alpha.5",
"@nocobase/client": "0.10.0-alpha.5",
"@nocobase/database": "0.10.0-alpha.5",
"@nocobase/server": "0.10.0-alpha.5",
"@nocobase/test": "0.10.0-alpha.5"
"cron": "^2.3.1"
},
"displayName": "Authentication",
"displayName.zh-CN": "用户认证",

View File

@ -0,0 +1,73 @@
import Database, { Repository } from '@nocobase/database';
import { MockServer, mockServer } from '@nocobase/test';
import { TokenBlacklistService } from '../token-blacklist';
describe('token-blacklist', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let tokenBlacklist: TokenBlacklistService;
beforeAll(async () => {
app = mockServer({
plugins: ['auth'],
});
await app.loadAndInstall({ clean: true });
db = app.db;
repo = db.getRepository('tokenBlacklist');
tokenBlacklist = new TokenBlacklistService(app.getPlugin('auth'));
});
afterAll(async () => {
await db.close();
});
afterEach(async () => {
await repo.destroy({
truncate: true,
});
});
it('add and has correctly', async () => {
await tokenBlacklist.add({
token: 'test',
expiration: new Date(),
});
await tokenBlacklist.add({
token: 'test1',
expiration: new Date(),
});
expect(tokenBlacklist.has('test')).toBeTruthy();
expect(tokenBlacklist.has('test1')).toBeTruthy();
});
it('add same token correctly', async () => {
await tokenBlacklist.add({
token: 'test',
expiration: new Date(),
});
await tokenBlacklist.add({
token: 'test',
expiration: new Date(),
});
expect(tokenBlacklist.has('test')).toBeTruthy();
});
it('delete expired token correctly', async () => {
await tokenBlacklist.add({
token: 'should be deleted',
expiration: new Date('2020-01-01'),
});
await tokenBlacklist.add({
token: 'should not be deleted',
expiration: new Date('2100-01-01'),
});
await tokenBlacklist.deleteByExpiration();
expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy();
expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy();
});
});

View File

@ -0,0 +1,19 @@
import { CollectionOptions } from '@nocobase/client';
export default {
namespace: 'auth.token-black',
duplicator: 'optional',
name: 'tokenBlacklist',
model: 'TokenBlacklistModel',
fields: [
{
type: 'string',
name: 'token',
index: true,
},
{
type: 'date',
name: 'expiration',
},
],
} as CollectionOptions;

View File

@ -1,17 +1,16 @@
import { Model } from '@nocobase/database';
import { InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { BasicAuth } from './basic-auth';
import { presetAuthType, presetAuthenticator } from '../preset';
import { namespace, presetAuthenticator, presetAuthType } from '../preset';
import authActions from './actions/auth';
import authenticatorsActions from './actions/authenticators';
import { BasicAuth } from './basic-auth';
import { enUS, zhCN } from './locale';
import { namespace } from '../preset';
import { AuthModel } from './model/authenticator';
import { Model } from '@nocobase/database';
import { TokenBlacklistService } from './token-blacklist';
export class AuthPlugin extends Plugin {
afterAdd() {}
async beforeLoad() {
this.app.i18n.addResources('zh-CN', namespace, zhCN);
this.app.i18n.addResources('en-US', namespace, enUS);
@ -40,6 +39,12 @@ export class AuthPlugin extends Plugin {
return authenticator || authenticators[0];
},
});
if (!this.app.authManager.jwt.blacklist) {
// If blacklist service is not set, should configure default blacklist service
this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this));
}
this.app.authManager.registerTypes(presetAuthType, {
auth: BasicAuth,
});
@ -81,11 +86,6 @@ export class AuthPlugin extends Plugin {
},
});
}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}

View File

@ -0,0 +1,66 @@
import { ITokenBlacklistService } from '@nocobase/auth';
import { Repository } from '@nocobase/database';
import { CronJob } from 'cron';
import AuthPlugin from './plugin';
export class TokenBlacklistService implements ITokenBlacklistService {
repo: Repository;
cronJob: CronJob;
constructor(protected plugin: AuthPlugin) {
this.repo = plugin.db.getRepository('tokenBlacklist');
this.cronJob = this.createCronJob();
}
get app() {
return this.plugin.app;
}
createCronJob() {
const cronJob = new CronJob(
// every day at 03:00
'0 3 * * *', //
async () => {
this.app.logger.info(`${this.plugin.name}: Start delete expired blacklist token`);
await this.deleteByExpiration();
this.app.logger.info(`${this.plugin.name}: End delete expired blacklist token`);
},
null,
);
this.app.once('beforeStart', () => {
cronJob.start();
});
this.app.once('beforeStop', () => {
cronJob.stop();
});
return cronJob;
}
async has(token: string) {
return !!(await this.repo.findOne({
where: {
token,
},
}));
}
async add(values) {
return this.repo.model.findOrCreate({
defaults: values,
where: {
token: values.token,
},
});
}
async deleteByExpiration() {
return this.repo.destroy({
filter: {
expiration: {
$dateNotAfter: new Date(),
},
},
});
}
}

View File

@ -5467,9 +5467,10 @@
version "1.6.2"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.6.2.tgz#bbe75f8c59e0b7077584920ce2cc76f8f354934d"
"@remix-run/router@1.6.3":
version "1.6.3"
resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.6.3.tgz#8205baf6e17ef93be35bf62c37d2d594e9be0dad"
"@remix-run/router@1.7.1":
version "1.7.1"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.1.tgz#fea7ac35ae4014637c130011f59428f618730498"
integrity sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ==
"@restart/hooks@^0.3.25":
version "0.3.27"
@ -6013,6 +6014,14 @@
"@types/keygrip" "*"
"@types/node" "*"
"@types/cron@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/cron/-/cron-2.0.1.tgz#d8bf7a24475f64197c7ac868c362b41be596e5f8"
integrity sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==
dependencies:
"@types/luxon" "*"
"@types/node" "*"
"@types/d3-array@*":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.5.tgz#857c1afffd3f51319bbc5b301956aca68acaa7b8"
@ -6405,6 +6414,11 @@
version "4.14.195"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
"@types/luxon@*":
version "3.3.0"
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.3.0.tgz#a61043a62c0a72696c73a0a305c544c96501e006"
integrity sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg==
"@types/markdown-it-highlightjs@3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/markdown-it-highlightjs/-/markdown-it-highlightjs-3.3.1.tgz#1e051211e7754ba478449fea7faeab3d177ca892"
@ -10116,6 +10130,13 @@ cron-parser@^4.6.0:
dependencies:
luxon "^3.2.1"
cron@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/cron/-/cron-2.3.1.tgz#71caa566ffef60ada9183b8677df4afa9a214ad7"
integrity sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==
dependencies:
luxon "^3.2.1"
croner@~4.1.92:
version "4.1.97"
resolved "https://registry.yarnpkg.com/croner/-/croner-4.1.97.tgz#6e373dc7bb3026fab2deb0d82685feef20796766"
@ -21521,10 +21542,11 @@ react-router-dom@6.3.0, react-router-dom@^6.11.2:
react-router "6.11.2"
react-router@6.11.2, react-router@6.3.0, react-router@^6.11.2:
version "6.12.1"
resolved "https://registry.npmmirror.com/react-router/-/react-router-6.12.1.tgz#9e4126aa1139ec6b5d347e19576d5e940cd46362"
version "6.14.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.1.tgz#5e82bcdabf21add859dc04b1859f91066b3a5810"
integrity sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g==
dependencies:
"@remix-run/router" "1.6.3"
"@remix-run/router" "1.7.1"
react-side-effect@^2.1.0:
version "2.1.2"