diff --git a/packages/core/auth/src/__tests__/auth-manager.test.ts b/packages/core/auth/src/__tests__/auth-manager.test.ts index 0a943451b..6640904ac 100644 --- a/packages/core/auth/src/__tests__/auth-manager.test.ts +++ b/packages/core/auth/src/__tests__/auth-manager.test.ts @@ -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 = 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'); + }); + }); + }); }); diff --git a/packages/core/auth/src/auth-manager.ts b/packages/core/auth/src/auth-manager.ts index f2820947b..ac3c324e2 100644 --- a/packages/core/auth/src/auth-manager.ts +++ b/packages/core/auth/src/auth-manager.ts @@ -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; @@ -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 { diff --git a/packages/core/auth/src/base/auth.ts b/packages/core/auth/src/base/auth.ts index 247040eda..a194487e9 100644 --- a/packages/core/auth/src/base/auth.ts +++ b/packages/core/auth/src/base/auth.ts @@ -81,4 +81,12 @@ export class BaseAuth extends Auth { token, }; } + + async signOut(): Promise { + const token = this.ctx.getBearerToken(); + if (!token) { + return; + } + return await this.jwt.block(token); + } } diff --git a/packages/core/auth/src/base/jwt-service.ts b/packages/core/auth/src/base/jwt-service.ts index 24aed5744..16f22f431 100644 --- a/packages/core/auth/src/base/jwt-service.ts +++ b/packages/core/auth/src/base/jwt-service.ts @@ -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(), + }); + } } diff --git a/packages/core/auth/src/base/token-blacklist-service.ts b/packages/core/auth/src/base/token-blacklist-service.ts new file mode 100644 index 000000000..f98089197 --- /dev/null +++ b/packages/core/auth/src/base/token-blacklist-service.ts @@ -0,0 +1,4 @@ +export interface ITokenBlacklistService { + has(token: string): Promise; + add(values: { token: string; expiration: string | Date }): Promise; +} diff --git a/packages/core/auth/src/index.ts b/packages/core/auth/src/index.ts index 22d986933..c7c83edca 100644 --- a/packages/core/auth/src/index.ts +++ b/packages/core/auth/src/index.ts @@ -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'; diff --git a/packages/core/test/src/mockServer.ts b/packages/core/test/src/mockServer.ts index 61e96676a..359316e38 100644 --- a/packages/core/test/src/mockServer.ts +++ b/packages/core/test/src/mockServer.ts @@ -99,6 +99,9 @@ export class MockServer extends Application { userId: typeof userOrId === 'number' ? userOrId : userOrId?.id, }, process.env.APP_KEY, + { + expiresIn: '1d', + }, ), { type: 'bearer' }, ) diff --git a/packages/plugins/api-keys/src/collections/api-keys.ts b/packages/plugins/api-keys/src/collections/api-keys.ts index 49725f9ee..1be6f1ff9 100644 --- a/packages/plugins/api-keys/src/collections/api-keys.ts +++ b/packages/plugins/api-keys/src/collections/api-keys.ts @@ -78,5 +78,10 @@ export default { ], }, }, + { + name: 'token', + type: 'string', + hidden: true, + }, ], } as CollectionOptions; diff --git a/packages/plugins/api-keys/src/server/__tests__/actions.test.ts b/packages/plugins/api-keys/src/server/__tests__/actions.test.ts index 27fcdab0e..9c655746b 100644 --- a/packages/plugins/api-keys/src/server/__tests__/actions.test.ts +++ b/packages/plugins/api-keys/src/server/__tests__/actions.test.ts @@ -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); + }); }); }); diff --git a/packages/plugins/api-keys/src/server/actions/api-keys.ts b/packages/plugins/api-keys/src/server/actions/api-keys.ts index 448350cb6..8bfe82639 100644 --- a/packages/plugins/api-keys/src/server/actions/api-keys.ts +++ b/packages/plugins/api-keys/src/server/actions/api-keys.ts @@ -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); +} diff --git a/packages/plugins/api-keys/src/server/plugin.ts b/packages/plugins/api-keys/src/server/plugin.ts index cdb8e033e..a33b6a533 100644 --- a/packages/plugins/api-keys/src/server/plugin.ts +++ b/packages/plugins/api-keys/src/server/plugin.ts @@ -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 { name: this.resourceName, actions: { create, + destroy, }, only: ['list', 'create', 'destroy'], }); diff --git a/packages/plugins/auth/package.json b/packages/plugins/auth/package.json index 3285e4efb..6a8714b2c 100644 --- a/packages/plugins/auth/package.json +++ b/packages/plugins/auth/package.json @@ -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": "用户认证", diff --git a/packages/plugins/auth/src/server/__tests__/token-blacklist.test.ts b/packages/plugins/auth/src/server/__tests__/token-blacklist.test.ts new file mode 100644 index 000000000..c6400cf2d --- /dev/null +++ b/packages/plugins/auth/src/server/__tests__/token-blacklist.test.ts @@ -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(); + }); +}); diff --git a/packages/plugins/auth/src/server/collections/token-blacklist.ts b/packages/plugins/auth/src/server/collections/token-blacklist.ts new file mode 100644 index 000000000..fd388e278 --- /dev/null +++ b/packages/plugins/auth/src/server/collections/token-blacklist.ts @@ -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; diff --git a/packages/plugins/auth/src/server/plugin.ts b/packages/plugins/auth/src/server/plugin.ts index cd609b4b0..677ed6bdc 100644 --- a/packages/plugins/auth/src/server/plugin.ts +++ b/packages/plugins/auth/src/server/plugin.ts @@ -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() {} } diff --git a/packages/plugins/auth/src/server/token-blacklist.ts b/packages/plugins/auth/src/server/token-blacklist.ts new file mode 100644 index 000000000..841b9dbf8 --- /dev/null +++ b/packages/plugins/auth/src/server/token-blacklist.ts @@ -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(), + }, + }, + }); + } +} diff --git a/yarn.lock b/yarn.lock index 3a96e22aa..2c4ce75f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"