From 7e4b60c410e478e5d3fad93723521d678e25e1b9 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Sat, 9 Apr 2022 14:54:46 +0800 Subject: [PATCH] Feat/plugin users with jwt (#258) * feat: plugin users with jwt * update github actions env * feat: get jwt config from options * feat: jwtService * fix: type * fix: build error * fix: yarn repository * fix: yarn build * fix: yarn build --- packages/actions/src/index.ts | 1 + packages/plugin-users/package.json | 7 +- .../src/__tests__/actions.test.ts | 51 +++++++++++ .../plugin-users/src/__tests__/fields.test.ts | 3 +- .../plugin-users/src/__tests__/role.test.ts | 10 ++- packages/plugin-users/src/__tests__/utils.ts | 12 +++ packages/plugin-users/src/actions/users.ts | 16 ++-- .../plugin-users/src/collections/users.ts | 6 -- packages/plugin-users/src/jwt-service.ts | 34 ++++++++ .../src/middlewares/parseToken.ts | 22 +++-- packages/plugin-users/src/server.ts | 24 ++++- yarn.lock | 87 +++++++++++++++++++ 12 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 packages/plugin-users/src/__tests__/actions.test.ts create mode 100644 packages/plugin-users/src/__tests__/utils.ts create mode 100644 packages/plugin-users/src/jwt-service.ts diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 3af4600ae..50d111bb3 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -12,6 +12,7 @@ export interface Context extends Koa.Context { db: Database; action: Action; body: any; + app: any; [key: string]: any; } diff --git a/packages/plugin-users/package.json b/packages/plugin-users/package.json index c4bb83c53..12bc768c3 100644 --- a/packages/plugin-users/package.json +++ b/packages/plugin-users/package.json @@ -8,9 +8,12 @@ "build:cjs": "tsc --project tsconfig.build.json", "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm" }, - "dependencies": {}, + "dependencies": { + "jsonwebtoken": "^8.5.1" + }, "devDependencies": { - "@nocobase/test": "^0.6.0-alpha.0" + "@nocobase/test": "^0.6.0-alpha.0", + "@types/jsonwebtoken": "^8.5.8" }, "gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4" } diff --git a/packages/plugin-users/src/__tests__/actions.test.ts b/packages/plugin-users/src/__tests__/actions.test.ts new file mode 100644 index 000000000..9b37c6605 --- /dev/null +++ b/packages/plugin-users/src/__tests__/actions.test.ts @@ -0,0 +1,51 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import Database from '@nocobase/database'; +import PluginACL from '@nocobase/plugin-acl'; +import PluginUsers from '../server'; +import supertest from 'supertest'; +import { userPluginConfig } from './utils'; + +describe('actions', () => { + let api: MockServer; + let db: Database; + let agent; + let pluginUser; + + beforeEach(async () => { + api = mockServer(); + await api.cleanDb(); + api.plugin(PluginUsers, userPluginConfig); + api.plugin(PluginACL); + + await api.loadAndInstall(); + db = api.db; + + agent = supertest.agent(api.callback()); + pluginUser = api.getPlugin('@nocobase/plugin-users'); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should login user with password', async () => { + const { adminEmail, adminPassword } = userPluginConfig.installing; + + let response = await api.agent().resource('users').check(); + expect(response.statusCode).toEqual(401); + + response = await agent.post('/users:signin').send({ + email: adminEmail, + password: adminPassword, + }); + + expect(response.statusCode).toEqual(200); + + const data = response.body.data; + const token = data.token; + expect(token).toBeDefined(); + + response = await agent.get('/users:check').set({ Authorization: 'Bearer ' + token }); + expect(response.statusCode).toEqual(200); + }); +}); diff --git a/packages/plugin-users/src/__tests__/fields.test.ts b/packages/plugin-users/src/__tests__/fields.test.ts index e17a9ad4c..e338fe75a 100644 --- a/packages/plugin-users/src/__tests__/fields.test.ts +++ b/packages/plugin-users/src/__tests__/fields.test.ts @@ -1,5 +1,6 @@ import Database from '@nocobase/database'; import { mockServer, MockServer } from '@nocobase/test'; +import { userPluginConfig } from './utils'; describe('createdBy/updatedBy', () => { let api: MockServer; @@ -7,7 +8,7 @@ describe('createdBy/updatedBy', () => { beforeEach(async () => { api = mockServer(); - api.plugin(require('../server').default); + api.plugin(require('../server').default, userPluginConfig); await api.loadAndInstall(); db = api.db; }); diff --git a/packages/plugin-users/src/__tests__/role.test.ts b/packages/plugin-users/src/__tests__/role.test.ts index 1f799bc5f..79d6bf9c0 100644 --- a/packages/plugin-users/src/__tests__/role.test.ts +++ b/packages/plugin-users/src/__tests__/role.test.ts @@ -1,19 +1,24 @@ import Database, { BelongsToManyRepository } from '@nocobase/database'; import PluginACL from '@nocobase/plugin-acl'; import { MockServer, mockServer } from '@nocobase/test'; +import { userPluginConfig } from './utils'; +import UsersPlugin from '@nocobase/plugin-users'; describe('role', () => { let api: MockServer; let db: Database; + let usersPlugin: UsersPlugin; + beforeEach(async () => { api = mockServer(); await api.cleanDb(); - api.plugin(require('../server').default); + api.plugin(UsersPlugin, userPluginConfig); api.plugin(PluginACL); await api.loadAndInstall(); db = api.db; + usersPlugin = api.getPlugin('@nocobase/plugin-users'); }); afterEach(async () => { @@ -98,6 +103,7 @@ describe('role', () => { await userRolesRepo.add('test1'); await userRolesRepo.add('test2'); + const userToken = usersPlugin.jwtService.sign({ userId: user.get('id') }); const response = await api .agent() .post('/users:setDefaultRole') @@ -105,7 +111,7 @@ describe('role', () => { defaultRole: 'test2', }) .set({ - Authorization: `Bearer ${user.get('token')}`, + Authorization: `Bearer ${userToken}`, }); expect(response.statusCode).toEqual(200); diff --git a/packages/plugin-users/src/__tests__/utils.ts b/packages/plugin-users/src/__tests__/utils.ts new file mode 100644 index 000000000..4e9cf96b8 --- /dev/null +++ b/packages/plugin-users/src/__tests__/utils.ts @@ -0,0 +1,12 @@ +import { UserPluginConfig } from '../server'; + +export const userPluginConfig: UserPluginConfig = { + jwt: { + secret: '09f26e402586e2faa8da4c98a35f1b20d6b033c60', + }, + installing: { + adminEmail: 'test@test.com', + adminPassword: 'password', + adminNickname: 'test', + }, +}; diff --git a/packages/plugin-users/src/actions/users.ts b/packages/plugin-users/src/actions/users.ts index 45cea6c03..b406584f7 100644 --- a/packages/plugin-users/src/actions/users.ts +++ b/packages/plugin-users/src/actions/users.ts @@ -1,5 +1,7 @@ import { Context, Next } from '@nocobase/actions'; import { PasswordField } from '@nocobase/database'; +import UsersPlugin from '../server'; + import crypto from 'crypto'; export async function check(ctx: Context, next: Next) { @@ -14,7 +16,7 @@ export async function check(ctx: Context, next: Next) { export async function signin(ctx: Context, next: Next) { const { uniqueField = 'email', values } = ctx.action.params; - console.log('signin.values', values); + if (!values[uniqueField]) { ctx.throw(401, '请填写邮箱账号'); } @@ -32,19 +34,19 @@ export async function signin(ctx: Context, next: Next) { if (!isValid) { ctx.throw(401, '密码错误,请您重新输入'); } - if (!user.token) { - user.token = crypto.randomBytes(20).toString('hex'); - await user.save(); - } + + const pluginUser = ctx.app.getPlugin('@nocobase/plugin-users'); + ctx.body = { ...user.toJSON(), - token: user.get('token'), + token: pluginUser.jwtService.sign({ + userId: user.get('id'), + }), }; await next(); } export async function signout(ctx: Context, next: Next) { - await ctx.state.currentUser.update({ token: null }); ctx.body = ctx.state.currentUser; await next(); } diff --git a/packages/plugin-users/src/collections/users.ts b/packages/plugin-users/src/collections/users.ts index ef50382e7..bad62f649 100644 --- a/packages/plugin-users/src/collections/users.ts +++ b/packages/plugin-users/src/collections/users.ts @@ -65,12 +65,6 @@ export default { type: 'string', name: 'appLang', }, - { - type: 'string', - name: 'token', - unique: true, - hidden: true, - }, { type: 'string', name: 'resetToken', diff --git a/packages/plugin-users/src/jwt-service.ts b/packages/plugin-users/src/jwt-service.ts new file mode 100644 index 000000000..0dd34b33c --- /dev/null +++ b/packages/plugin-users/src/jwt-service.ts @@ -0,0 +1,34 @@ +import * as jwt from 'jsonwebtoken'; + +export interface JwtOptions { + secret: string; + expiresIn?: string; +} + +export class JwtService { + constructor(protected options: JwtOptions) {} + + private expiresIn() { + return this.options.expiresIn || '7d'; + } + + private secret() { + return this.options.secret; + } + + sign(payload: any) { + return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn() }); + } + + decode(token: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, this.secret(), (err: any, decoded: any) => { + if (err) { + return reject(err); + } + + resolve(decoded); + }); + }); + } +} diff --git a/packages/plugin-users/src/middlewares/parseToken.ts b/packages/plugin-users/src/middlewares/parseToken.ts index 1b7d664bd..52bd00d10 100644 --- a/packages/plugin-users/src/middlewares/parseToken.ts +++ b/packages/plugin-users/src/middlewares/parseToken.ts @@ -1,4 +1,5 @@ import { Context, Next } from '@nocobase/actions'; +import UsersPlugin from '../server'; // TODO(feature): 表名应在 options 中配置 // 中间件默认只解决解析 token 和附加对应 user 的工作,不解决是否提前报 401 退出。 @@ -33,14 +34,23 @@ function setCurrentRole(ctx, user) { } async function findUserByToken(ctx: Context) { - const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); - const User = ctx.db.getCollection('users'); - const user = await User.repository.findOne({ + const token = getTokenFromCtx(ctx); + if (!token) { + return null; + } + + const pluginUser = ctx.app.getPlugin('@nocobase/plugin-users'); + + const { userId } = await pluginUser.jwtService.decode(token); + + return await ctx.db.getRepository('users').findOne({ filter: { - token, + id: userId, }, appends: ['roles'], }); - - return user; +} + +function getTokenFromCtx(ctx: Context) { + return ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); } diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts index 3e014f38b..a98846c40 100644 --- a/packages/plugin-users/src/server.ts +++ b/packages/plugin-users/src/server.ts @@ -3,8 +3,26 @@ import { Plugin } from '@nocobase/server'; import { resolve } from 'path'; import * as actions from './actions/users'; import * as middlewares from './middlewares'; +import { JwtOptions, JwtService } from './jwt-service'; + +export interface UserPluginConfig { + jwt: JwtOptions; + + installing: { + adminNickname: string; + adminEmail: string; + adminPassword: string; + }; +} + +export default class UsersPlugin extends Plugin { + public jwtService: JwtService; + + constructor(app, options) { + super(app, options); + this.jwtService = new JwtService(options?.jwt); + } -export default class UsersPlugin extends Plugin { async beforeLoad() { this.db.on('users.afterCreateWithAssociations', async (model, options) => { const { transaction } = options; @@ -82,7 +100,7 @@ export default class UsersPlugin extends Plugin { adminNickname = 'Super Admin', adminEmail = 'admin@nocobase.com', adminPassword = 'admin123', - } = this.options; + } = this.options.installing; return { adminNickname, @@ -90,6 +108,7 @@ export default class UsersPlugin extends Plugin { adminPassword, }; } + async install() { const { adminNickname, adminPassword, adminEmail } = this.getRootUserInfo(); @@ -102,6 +121,7 @@ export default class UsersPlugin extends Plugin { roles: ['admin'], }, }); + const repo = this.db.getRepository('collections'); if (repo) { await repo.db2cm('users'); diff --git a/yarn.lock b/yarn.lock index fffc679a6..baf815222 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3169,6 +3169,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/jsonwebtoken@^8.5.8": + version "8.5.8" + resolved "https://registry.npmjs.org/@types%2fjsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44" + integrity sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A== + dependencies: + "@types/node" "*" + "@types/keygrip@*": version "1.0.2" resolved "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" @@ -4837,6 +4844,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@1.x, buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -6487,6 +6499,13 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1, ee-first@~1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -9923,6 +9942,22 @@ jsonparse@^1.2.0, jsonparse@^1.3.1: resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -9946,6 +9981,23 @@ jstoxml@^0.2.3: array-includes "^3.1.3" object.assign "^4.1.2" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.npmmirror.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.npmmirror.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + katex@^0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9" @@ -10288,16 +10340,51 @@ lodash.debounce@^4.0.8: resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"