From 6cfd586175eeed6316850b7766e1a2577ee032e3 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Fri, 30 Jun 2023 11:20:35 +0800 Subject: [PATCH] feat(plugin-api-keys): support fetch api via api-keys (#2136) * feat(plugin-api-keys): support fetch api via api-keys * feat: full support * fix: missing parseToken in auth * feat: add created at column * feat: configure snippet * fix: remove unused code * fix: revert * chore: update deps * feat: improve role * fix: avoid create api key without not exist role * feat: improve select roles * refactor: when no X-Role is found, roles should not be randomly assigned * feat: improve code * feat: improve current role * fix: revert * fix: revert apilicent * fix: revert auth * feat: improve currentRole logic * feat: use resourcer.use instead it * refactor: remove api-keys-auth * fix: type * refactor: move jwt to authManager * refactor: remove unused code * refactor: remove protected * Revert "refactor: remove unused code" This reverts commit 8f81535ab7e9c412bdc4d4bc05abad64ff60ba3f. * feat: remove unused code * feat: improve code * fix: test error * test: update test * test: add test cases * docs: update * chore: update X-Role * fix: token's roleName not work * docs: update usage * fix: i18n Add APi key * docs: update capital * docs: update * feat: clean * Update package.json * Update roles.ts * fix: api key --------- Co-authored-by: chenos --- packages/app/client/src/plugins/api-keys.ts | 1 + packages/core/actions/src/index.ts | 4 +- packages/core/auth/src/auth-manager.ts | 4 + packages/core/auth/src/base/auth.ts | 26 +- packages/core/auth/src/base/jwt-service.ts | 18 +- .../schema-component/antd/action/Action.tsx | 3 +- .../antd/association-field/InternalViewer.tsx | 4 +- .../acl/src/__tests__/setCurrentRole.test.ts | 7 +- .../plugins/acl/src/__tests__/users.test.ts | 2 +- .../plugins/acl/src/actions/role-check.ts | 3 - packages/plugins/acl/src/collections/roles.ts | 2 +- .../acl/src/middlewares/setCurrentRole.ts | 29 +- packages/plugins/api-keys/README.md | 9 + packages/plugins/api-keys/README.zh-CN.md | 9 + packages/plugins/api-keys/client.d.ts | 4 + packages/plugins/api-keys/client.js | 30 ++ .../plugins/api-keys/docs/en-US/changelog.md | 1 + packages/plugins/api-keys/docs/en-US/index.md | 9 + .../plugins/api-keys/docs/en-US/tabs.json | 14 + packages/plugins/api-keys/docs/en-US/usage.md | 19 ++ .../plugins/api-keys/docs/zh-CN/changelog.md | 1 + packages/plugins/api-keys/docs/zh-CN/index.md | 10 + .../plugins/api-keys/docs/zh-CN/tabs.json | 14 + packages/plugins/api-keys/docs/zh-CN/usage.md | 19 ++ packages/plugins/api-keys/package.json | 24 ++ packages/plugins/api-keys/server.d.ts | 4 + packages/plugins/api-keys/server.js | 30 ++ .../src/client/Configuration/index.tsx | 13 + .../src/client/Configuration/schema.tsx | 262 ++++++++++++++++++ .../plugins/api-keys/src/client/index.tsx | 29 ++ .../api-keys/src/client/locale/en-US.ts | 3 + .../api-keys/src/client/locale/index.ts | 17 ++ .../api-keys/src/client/locale/zh-CN.ts | 14 + .../api-keys/src/collections/api-keys.ts | 91 ++++++ .../plugins/api-keys/src/collections/index.ts | 1 + packages/plugins/api-keys/src/constants.ts | 1 + packages/plugins/api-keys/src/index.ts | 1 + .../src/server/__tests__/actions.test.ts | 171 ++++++++++++ .../api-keys/src/server/actions/api-keys.ts | 32 +++ packages/plugins/api-keys/src/server/index.ts | 1 + .../api-keys/src/server/locale/en-US.ts | 1 + .../api-keys/src/server/locale/index.ts | 2 + .../api-keys/src/server/locale/zh-CN.ts | 3 + .../plugins/api-keys/src/server/plugin.ts | 52 ++++ .../plugins/auth/src/server/basic-auth.ts | 12 +- packages/plugins/oidc/src/server/oidc-auth.ts | 3 +- packages/plugins/saml/src/server/saml-auth.ts | 3 +- .../plugins/sms-auth/src/server/sms-auth.ts | 5 +- packages/presets/nocobase/package.json | 1 + packages/presets/nocobase/src/index.ts | 1 + 50 files changed, 967 insertions(+), 52 deletions(-) create mode 100644 packages/app/client/src/plugins/api-keys.ts create mode 100644 packages/plugins/api-keys/README.md create mode 100644 packages/plugins/api-keys/README.zh-CN.md create mode 100755 packages/plugins/api-keys/client.d.ts create mode 100755 packages/plugins/api-keys/client.js create mode 100644 packages/plugins/api-keys/docs/en-US/changelog.md create mode 100644 packages/plugins/api-keys/docs/en-US/index.md create mode 100644 packages/plugins/api-keys/docs/en-US/tabs.json create mode 100644 packages/plugins/api-keys/docs/en-US/usage.md create mode 100644 packages/plugins/api-keys/docs/zh-CN/changelog.md create mode 100644 packages/plugins/api-keys/docs/zh-CN/index.md create mode 100644 packages/plugins/api-keys/docs/zh-CN/tabs.json create mode 100644 packages/plugins/api-keys/docs/zh-CN/usage.md create mode 100644 packages/plugins/api-keys/package.json create mode 100755 packages/plugins/api-keys/server.d.ts create mode 100755 packages/plugins/api-keys/server.js create mode 100644 packages/plugins/api-keys/src/client/Configuration/index.tsx create mode 100644 packages/plugins/api-keys/src/client/Configuration/schema.tsx create mode 100644 packages/plugins/api-keys/src/client/index.tsx create mode 100644 packages/plugins/api-keys/src/client/locale/en-US.ts create mode 100644 packages/plugins/api-keys/src/client/locale/index.ts create mode 100644 packages/plugins/api-keys/src/client/locale/zh-CN.ts create mode 100644 packages/plugins/api-keys/src/collections/api-keys.ts create mode 100644 packages/plugins/api-keys/src/collections/index.ts create mode 100644 packages/plugins/api-keys/src/constants.ts create mode 100644 packages/plugins/api-keys/src/index.ts create mode 100644 packages/plugins/api-keys/src/server/__tests__/actions.test.ts create mode 100644 packages/plugins/api-keys/src/server/actions/api-keys.ts create mode 100644 packages/plugins/api-keys/src/server/index.ts create mode 100644 packages/plugins/api-keys/src/server/locale/en-US.ts create mode 100644 packages/plugins/api-keys/src/server/locale/index.ts create mode 100644 packages/plugins/api-keys/src/server/locale/zh-CN.ts create mode 100644 packages/plugins/api-keys/src/server/plugin.ts diff --git a/packages/app/client/src/plugins/api-keys.ts b/packages/app/client/src/plugins/api-keys.ts new file mode 100644 index 000000000..779e374e5 --- /dev/null +++ b/packages/app/client/src/plugins/api-keys.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-api-keys/client'; \ No newline at end of file diff --git a/packages/core/actions/src/index.ts b/packages/core/actions/src/index.ts index f18d4f107..75528e515 100644 --- a/packages/core/actions/src/index.ts +++ b/packages/core/actions/src/index.ts @@ -1,7 +1,7 @@ -import Koa from 'koa'; +import { Cache } from '@nocobase/cache'; import { Database } from '@nocobase/database'; import { Action } from '@nocobase/resourcer'; -import { Cache } from '@nocobase/cache'; +import Koa from 'koa'; import lodash from 'lodash'; import * as actions from './actions'; diff --git a/packages/core/auth/src/auth-manager.ts b/packages/core/auth/src/auth-manager.ts index 1d90b6dbf..f2820947b 100644 --- a/packages/core/auth/src/auth-manager.ts +++ b/packages/core/auth/src/auth-manager.ts @@ -2,6 +2,7 @@ import { Context, Next } from '@nocobase/actions'; import { Model } from '@nocobase/database'; import { Registry } from '@nocobase/utils'; import { Auth, AuthExtend } from './auth'; +import { JwtOptions, JwtService } from './base/jwt-service'; type Storer = { get: (name: string) => Promise; @@ -10,6 +11,7 @@ type Storer = { type AuthManagerOptions = { authKey: string; default?: string; + jwt?: JwtOptions; }; type AuthConfig = { @@ -21,9 +23,11 @@ export class AuthManager { protected authTypes: Registry = new Registry(); // authenticators collection manager. protected storer: Storer; + jwt: JwtService; constructor(options: AuthManagerOptions) { this.options = options; + this.jwt = new JwtService(options.jwt); } setStorer(storer: Storer) { diff --git a/packages/core/auth/src/base/auth.ts b/packages/core/auth/src/base/auth.ts index 144d038c7..247040eda 100644 --- a/packages/core/auth/src/base/auth.ts +++ b/packages/core/auth/src/base/auth.ts @@ -1,13 +1,12 @@ -import { Auth, AuthConfig } from '../auth'; -import { JwtOptions, JwtService } from './jwt-service'; import { Collection, Model } from '@nocobase/database'; +import { Auth, AuthConfig } from '../auth'; +import { JwtService } from './jwt-service'; /** * BaseAuth * @description A base class with jwt provide some common methods. */ export class BaseAuth extends Auth { - protected jwt: JwtService; protected userCollection: Collection; constructor( @@ -15,10 +14,17 @@ export class BaseAuth extends Auth { userCollection: Collection; }, ) { - const { options, userCollection } = config; + const { userCollection } = config; super(config); this.userCollection = userCollection; - this.jwt = new JwtService(options.jwt as JwtOptions); + } + + get userRepository() { + return this.userCollection.repository; + } + + get jwt(): JwtService { + return this.ctx.app.authManager.jwt; } set user(user: Model) { @@ -35,13 +41,17 @@ export class BaseAuth extends Auth { return null; } try { - const { userId } = await this.jwt.decode(token); - const user = await this.userCollection.repository.findOne({ + const { userId, roleName } = await this.jwt.decode(token); + + if (roleName) { + this.ctx.headers['x-role'] = roleName; + } + + return await this.userRepository.findOne({ filter: { id: userId, }, }); - return user; } catch (err) { this.ctx.logger.error(err); return null; diff --git a/packages/core/auth/src/base/jwt-service.ts b/packages/core/auth/src/base/jwt-service.ts index 4a3aafde0..24aed5744 100644 --- a/packages/core/auth/src/base/jwt-service.ts +++ b/packages/core/auth/src/base/jwt-service.ts @@ -1,15 +1,21 @@ -import jwt from 'jsonwebtoken'; +import jwt, { SignOptions } from 'jsonwebtoken'; export interface JwtOptions { secret: string; expiresIn?: string; } +export type SignPayload = Parameters[0]; + export class JwtService { - constructor(protected options: JwtOptions) { - const { secret, expiresIn } = options || {}; + constructor( + protected options: JwtOptions = { + secret: process.env.APP_KEY, + }, + ) { + const { secret, expiresIn } = options; this.options = { - secret: secret || process.env.APP_KEY, + secret: secret, expiresIn: expiresIn || process.env.JWT_EXPIRES_IN || '7d', }; } @@ -22,8 +28,8 @@ export class JwtService { return this.options.secret; } - sign(payload: any) { - return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn() }); + sign(payload: SignPayload, options?: SignOptions) { + return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn(), ...options }); } decode(token: string): Promise { diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index 227b867cc..5ce4c8bb4 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -83,7 +83,7 @@ export const Action: ComposedAction = observer( const [formValueChanged, setFormValueChanged] = useState(false); const Designer = useDesigner(); const field = useField(); - const { run } = useAction(); + const { run, element } = useAction(); const fieldSchema = useFieldSchema(); const compile = useCompile(); const form = useForm(); @@ -163,6 +163,7 @@ export const Action: ComposedAction = observer( {popover && } {!popover && renderButton()} {!popover &&
e.stopPropagation()}>{props.children}
} + {element} ); }, diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx index 06289cedf..c59471628 100644 --- a/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx @@ -1,12 +1,12 @@ -import { RecursionField, observer, useField, useFieldSchema } from '@formily/react'; +import { observer, RecursionField, useField, useFieldSchema } from '@formily/react'; import { toArr } from '@formily/shared'; import React, { Fragment, useRef, useState } from 'react'; +import { useDesignable } from '../../'; import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider'; import { CollectionProvider } from '../../../collection-manager'; import { RecordProvider, useRecord } from '../../../record-provider'; import { FormProvider } from '../../core'; import { useCompile } from '../../hooks'; -import { useDesignable } from '../../'; import { ActionContextProvider, useActionContext } from '../action'; import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip'; import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks'; diff --git a/packages/plugins/acl/src/__tests__/setCurrentRole.test.ts b/packages/plugins/acl/src/__tests__/setCurrentRole.test.ts index 6b2e996b2..33335bdcd 100644 --- a/packages/plugins/acl/src/__tests__/setCurrentRole.test.ts +++ b/packages/plugins/acl/src/__tests__/setCurrentRole.test.ts @@ -55,7 +55,7 @@ describe('role', () => { expect(ctx.state.currentRole).toBe('root'); }); - it('should set role with default when x-role does not exist', async () => { + it('should throw 401', async () => { ctx.state.currentUser = await db.getRepository('users').findOne({ appends: ['roles'], }); @@ -64,8 +64,11 @@ describe('role', () => { return 'abc'; } }; + const throwFn = jest.fn(); + ctx.throw = throwFn; await setCurrentRole(ctx, () => {}); - expect(ctx.state.currentRole).toBe('root'); + expect(throwFn).lastCalledWith(401, 'User role not found'); + expect(ctx.state.currentRole).not.toBeDefined(); }); it('should set role with anonymous', async () => { diff --git a/packages/plugins/acl/src/__tests__/users.test.ts b/packages/plugins/acl/src/__tests__/users.test.ts index e80c2ed42..9ac6a549e 100644 --- a/packages/plugins/acl/src/__tests__/users.test.ts +++ b/packages/plugins/acl/src/__tests__/users.test.ts @@ -93,7 +93,7 @@ describe('actions', () => { const rolesCheckResponse2 = (await loggedAgent.set('Accept', 'application/json').get('/roles:check')) as any; - expect(rolesCheckResponse2.status).toEqual(500); + expect(rolesCheckResponse2.status).toEqual(401); expect(rolesCheckResponse2.body.errors[0].message).toEqual('User role not found'); }); diff --git a/packages/plugins/acl/src/actions/role-check.ts b/packages/plugins/acl/src/actions/role-check.ts index 88018d84e..5ae43a4b4 100644 --- a/packages/plugins/acl/src/actions/role-check.ts +++ b/packages/plugins/acl/src/actions/role-check.ts @@ -8,9 +8,6 @@ const map2obj = (map: Map) => { export async function checkAction(ctx, next) { const currentRole = ctx.state.currentRole; - if (!currentRole) { - throw new Error('User role not found'); - } const roleInstance = await ctx.db.getRepository('roles').findOne({ filter: { diff --git a/packages/plugins/acl/src/collections/roles.ts b/packages/plugins/acl/src/collections/roles.ts index 8e1b67fcd..7f9f53694 100644 --- a/packages/plugins/acl/src/collections/roles.ts +++ b/packages/plugins/acl/src/collections/roles.ts @@ -84,6 +84,6 @@ export default { type: 'set', name: 'snippets', defaultValue: ['!ui.*', '!pm', '!pm.*'], - }, + } ], } as CollectionOptions; diff --git a/packages/plugins/acl/src/middlewares/setCurrentRole.ts b/packages/plugins/acl/src/middlewares/setCurrentRole.ts index 54e153f7a..8373a486d 100644 --- a/packages/plugins/acl/src/middlewares/setCurrentRole.ts +++ b/packages/plugins/acl/src/middlewares/setCurrentRole.ts @@ -1,5 +1,8 @@ -export async function setCurrentRole(ctx, next) { - let currentRole = ctx.get('X-Role'); +import { Context } from '@nocobase/actions'; +import { Repository } from '@nocobase/database'; + +export async function setCurrentRole(ctx: Context, next) { + const currentRole = ctx.get('X-Role'); if (currentRole === 'anonymous') { ctx.state.currentRole = currentRole; @@ -10,22 +13,22 @@ export async function setCurrentRole(ctx, next) { return next(); } - const repository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id); + const repository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id) as unknown as Repository; const roles = await repository.find(); ctx.state.currentUser.setDataValue('roles', roles); - if (roles.length == 1) { - currentRole = roles[0].name; - } else if (roles.length > 1) { - const role = roles.find((item) => item.name === currentRole); - if (!role) { - const defaultRole = roles.find((item) => item?.rolesUsers?.default); - currentRole = (defaultRole || roles[0])?.name; - } + // 1. If the X-Role is set, use the specified role + if (currentRole) { + ctx.state.currentRole = roles.find((role) => role.name === currentRole)?.name; + } + // 2. If the X-Role is not set, use the default role + else { + const defaultRole = roles.find((item) => item?.rolesUsers?.default); + ctx.state.currentRole = (defaultRole || roles[0])?.name; } - if (currentRole) { - ctx.state.currentRole = currentRole; + if (!ctx.state.currentRole) { + return ctx.throw(401, 'User role not found'); } await next(); diff --git a/packages/plugins/api-keys/README.md b/packages/plugins/api-keys/README.md new file mode 100644 index 000000000..80897d280 --- /dev/null +++ b/packages/plugins/api-keys/README.md @@ -0,0 +1,9 @@ +# api-keys + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/api-keys/README.zh-CN.md b/packages/plugins/api-keys/README.zh-CN.md new file mode 100644 index 000000000..9089fa194 --- /dev/null +++ b/packages/plugins/api-keys/README.zh-CN.md @@ -0,0 +1,9 @@ +# api-keys + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/api-keys/client.d.ts b/packages/plugins/api-keys/client.d.ts new file mode 100755 index 000000000..765db9222 --- /dev/null +++ b/packages/plugins/api-keys/client.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; + diff --git a/packages/plugins/api-keys/client.js b/packages/plugins/api-keys/client.js new file mode 100755 index 000000000..238820257 --- /dev/null +++ b/packages/plugins/api-keys/client.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/client")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/api-keys/docs/en-US/changelog.md b/packages/plugins/api-keys/docs/en-US/changelog.md new file mode 100644 index 000000000..10ba1d511 --- /dev/null +++ b/packages/plugins/api-keys/docs/en-US/changelog.md @@ -0,0 +1 @@ +# API keys Changelog diff --git a/packages/plugins/api-keys/docs/en-US/index.md b/packages/plugins/api-keys/docs/en-US/index.md new file mode 100644 index 000000000..a01b146a3 --- /dev/null +++ b/packages/plugins/api-keys/docs/en-US/index.md @@ -0,0 +1,9 @@ +# API keys + +## Introduction + +This plugin allows you to create and manage APIs. The generated API keys can be used to access all `NocoBase` APIs. + +## Access Control + +When creating an API key, you can bind it to a role, and the role's permissions will be the same as the Key's permissions. diff --git a/packages/plugins/api-keys/docs/en-US/tabs.json b/packages/plugins/api-keys/docs/en-US/tabs.json new file mode 100644 index 000000000..40b052611 --- /dev/null +++ b/packages/plugins/api-keys/docs/en-US/tabs.json @@ -0,0 +1,14 @@ +[ + { + "title": "Introduction", + "path": "index" + }, + { + "title": "Usage", + "path": "usage" + }, + { + "title": "Changelog", + "path": "changelog" + } +] diff --git a/packages/plugins/api-keys/docs/en-US/usage.md b/packages/plugins/api-keys/docs/en-US/usage.md new file mode 100644 index 000000000..f0c617752 --- /dev/null +++ b/packages/plugins/api-keys/docs/en-US/usage.md @@ -0,0 +1,19 @@ +# API keys Usage + +## Creating an API key + +After enabling the plugin, go to the API keys plugin management page, click `Add API key`, fill in the relevant information, and click Save to create an API key. + +## Using an API key + +Add the `Authorization` field to the request header, with the value of `Bearer ${API_KEY}`, to access all `NocoBase` APIs using the API key. + +Here's an example using cURL: + +```bash +curl '{domain}/api/roles:check' -H 'Authorization: Bearer {API key}' +``` + +## Deleting an API key + +Currently, deleting an API key does not make it invalid. Please keep your API key safe. diff --git a/packages/plugins/api-keys/docs/zh-CN/changelog.md b/packages/plugins/api-keys/docs/zh-CN/changelog.md new file mode 100644 index 000000000..d27043e37 --- /dev/null +++ b/packages/plugins/api-keys/docs/zh-CN/changelog.md @@ -0,0 +1 @@ +# API keys 更新日志 diff --git a/packages/plugins/api-keys/docs/zh-CN/index.md b/packages/plugins/api-keys/docs/zh-CN/index.md new file mode 100644 index 000000000..f15387312 --- /dev/null +++ b/packages/plugins/api-keys/docs/zh-CN/index.md @@ -0,0 +1,10 @@ +# API keys + +## 简介 + +该插件允许你创建和管理 API,生成的 API keys 可以用于访问 `NocoBase` 所有 API。 + + +## 权限控制 + +创建 API key 时,可以为该 Key 绑定角色,角色的权限就是 Key 的权限。 diff --git a/packages/plugins/api-keys/docs/zh-CN/tabs.json b/packages/plugins/api-keys/docs/zh-CN/tabs.json new file mode 100644 index 000000000..56eaacdd4 --- /dev/null +++ b/packages/plugins/api-keys/docs/zh-CN/tabs.json @@ -0,0 +1,14 @@ +[ + { + "title": "介绍", + "path": "index" + }, + { + "title": "用法", + "path": "usage" + }, + { + "title": "日志", + "path": "changelog" + } +] diff --git a/packages/plugins/api-keys/docs/zh-CN/usage.md b/packages/plugins/api-keys/docs/zh-CN/usage.md new file mode 100644 index 000000000..0bd739bdc --- /dev/null +++ b/packages/plugins/api-keys/docs/zh-CN/usage.md @@ -0,0 +1,19 @@ +# API keys 使用方法 + +## 创建 API key + +当你启用插件后,前往 API keys 的插件管理页面,点击 `添加 API key` 并填写相关信息,点击 `保存` 即可创建 API key。 + +## 使用 API key + +在请求头中添加 `Authorization` 字段,值为 `Bearer ${API_KEY}`,即可使用 API key 访问 `NocoBase` 所有 API。 + +cURL 的例子如下 + +```bash +curl '{domain}/api/roles:check' -H 'Authorization: Bearer {API key}' +``` + +## 删除 API key + +目前删除 API key 并不能使 Key 失效,请注意保管好你的 API key。 diff --git a/packages/plugins/api-keys/package.json b/packages/plugins/api-keys/package.json new file mode 100644 index 000000000..4a8e35a70 --- /dev/null +++ b/packages/plugins/api-keys/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nocobase/plugin-api-keys", + "displayName": "API keys", + "displayName.zh-CN": "API keys", + "description": "Allow user use API key to fetch nocobase api", + "description.zh-CN": "允许用户使用 api 密钥来访问 nocobase api", + "version": "0.10.0-alpha.5", + "license": "AGPL-3.0", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "dependencies": { + "@nocobase/actions": "0.10.0-alpha.5", + "@nocobase/database": "0.10.0-alpha.5", + "@nocobase/resourcer": "0.10.0-alpha.5", + "@nocobase/server": "0.10.0-alpha.5", + "@nocobase/utils": "0.10.0-alpha.5", + "jsonwebtoken": "^8.5.1" + }, + "devDependencies": { + "@nocobase/test": "0.10.0-alpha.5", + "@types/jsonwebtoken": "^8.5.8" + }, + "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" +} diff --git a/packages/plugins/api-keys/server.d.ts b/packages/plugins/api-keys/server.d.ts new file mode 100755 index 000000000..e70edb928 --- /dev/null +++ b/packages/plugins/api-keys/server.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; + diff --git a/packages/plugins/api-keys/server.js b/packages/plugins/api-keys/server.js new file mode 100755 index 000000000..d06a7eb92 --- /dev/null +++ b/packages/plugins/api-keys/server.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/server")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/api-keys/src/client/Configuration/index.tsx b/packages/plugins/api-keys/src/client/Configuration/index.tsx new file mode 100644 index 000000000..2c1b1ed38 --- /dev/null +++ b/packages/plugins/api-keys/src/client/Configuration/index.tsx @@ -0,0 +1,13 @@ +import { RecursionField } from '@formily/react'; +import { CollectionManagerProvider } from '@nocobase/client'; +import React from 'react'; +import { apiKeysCollection } from '../../collections'; +import { configurationSchema } from './schema'; + +export const Configuration = () => { + return ( + + + + ); +}; diff --git a/packages/plugins/api-keys/src/client/Configuration/schema.tsx b/packages/plugins/api-keys/src/client/Configuration/schema.tsx new file mode 100644 index 000000000..20f353315 --- /dev/null +++ b/packages/plugins/api-keys/src/client/Configuration/schema.tsx @@ -0,0 +1,262 @@ +import { ISchema, useForm } from '@formily/react'; +import { uid } from '@formily/shared'; +import { useActionContext, useBlockRequestContext, useRecord } from '@nocobase/client'; +import { Alert, Modal, Space, Typography } from 'antd'; +import React from 'react'; +import { generateNTemplate, useTranslation } from '../locale'; +const { useModal } = Modal; + +const useCreateAction = () => { + const form = useForm(); + const { setVisible } = useActionContext(); + const { resource, service } = useBlockRequestContext(); + const { t } = useTranslation(); + const [modalIns, element] = useModal(); + return { + async run() { + await form.submit(); + const response = await resource.create({ + values: form.values, + }); + + modalIns.success({ + title: t('API key created successfully'), + onOk: () => { + form.reset(); + setVisible(false); + }, + content: ( + + + {response.data?.data?.token} + + ), + }); + service?.refresh(); + }, + element, + }; +}; + +const useDestroyAction = () => { + const record = useRecord(); + const { resource, service } = useBlockRequestContext(); + return { + async run() { + await resource.destroy({ + filterByTk: record.id, + }); + service.refresh(); + }, + }; +}; + +export const configurationSchema: ISchema = { + type: 'object', + properties: { + configuration: { + type: 'void', + 'x-decorator': 'TableBlockProvider', + 'x-decorator-props': { + collection: 'apiKeys', + resource: 'apiKeys', + action: 'list', + params: { + pageSize: 20, + appends: ['role'], + sort: ['-createdAt'], + }, + rowKey: 'name', + showIndex: true, + }, + 'x-component': 'CardItem', + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 'var(--nb-spacing)', + }, + }, + properties: { + create: { + type: 'void', + 'x-action': 'create', + title: generateNTemplate('Add API key'), + 'x-component': 'Action', + 'x-component-props': { + openMode: 'drawer', + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + title: generateNTemplate('Add API key'), + 'x-decorator': 'Form', + 'x-component': 'Action.Modal', + 'x-component-props': { + style: { + maxWidth: '520px', + width: '100%', + }, + }, + properties: { + name: { + type: 'string', + title: generateNTemplate('Key name'), + required: true, + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + role: { + type: 'string', + title: generateNTemplate('Role'), + required: true, + 'x-collection-field': 'apiKeys.role', + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + expiresIn: { + type: 'string', + title: generateNTemplate('Expiration'), + required: true, + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + default: '30d', + }, + footer: { + type: 'void', + 'x-component': 'Action.Modal.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: useCreateAction, + }, + }, + }, + }, + }, + }, + }, + 'x-align': 'right', + }, + }, + }, + [uid()]: { + type: 'array', + 'x-component': 'TableV2', + 'x-component-props': { + rowKey: 'id', + useProps: '{{ useTableBlockProps }}', + }, + properties: { + column1: { + type: 'void', + 'x-decorator': 'TableV2.Column.Decorator', + 'x-component': 'TableV2.Column', + title: generateNTemplate('Key name'), + properties: { + name: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column2: { + type: 'void', + 'x-decorator': 'TableV2.Column.Decorator', + 'x-component': 'TableV2.Column', + title: generateNTemplate('Role'), + properties: { + role: { + type: 'object', + 'x-collection-field': 'apiKeys.role', + 'x-component': 'CollectionField', + 'x-component-props': { + enableLink: false, + }, + 'x-read-pretty': true, + }, + }, + }, + column3: { + type: 'void', + 'x-decorator': 'TableV2.Column.Decorator', + 'x-component': 'TableV2.Column', + title: generateNTemplate('Expiration'), + properties: { + expiresIn: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column4: { + type: 'void', + 'x-decorator': 'TableV2.Column.Decorator', + 'x-component': 'TableV2.Column', + title: generateNTemplate('Created at'), + properties: { + createdAt: { + type: 'date', + // 'x-component': 'CollectionField', + 'x-component': 'DatePicker', + 'x-component-props': { + format: 'YYYY-MM-DD HH:mm:ss', + }, + 'x-read-pretty': true, + }, + }, + }, + actionColumn: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-action-column': 'actions', + 'x-decorator': 'TableV2.Column.ActionBar', + 'x-component': 'TableV2.Column', + properties: { + columnActions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: generateNTemplate('Delete API key'), + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: useDestroyAction, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/api-keys/src/client/index.tsx b/packages/plugins/api-keys/src/client/index.tsx new file mode 100644 index 000000000..c7a1a5dd0 --- /dev/null +++ b/packages/plugins/api-keys/src/client/index.tsx @@ -0,0 +1,29 @@ +import { SchemaComponentOptions, SettingsCenterProvider } from '@nocobase/client'; +import React from 'react'; +import { Configuration } from './Configuration'; +import { useTranslation } from './locale'; + +const ApiKeysProvider = React.memo((props) => { + const { t } = useTranslation(); + return ( + + {props.children} + + ); +}); +ApiKeysProvider.displayName = 'ApiKeysProvider'; + +export default ApiKeysProvider; diff --git a/packages/plugins/api-keys/src/client/locale/en-US.ts b/packages/plugins/api-keys/src/client/locale/en-US.ts new file mode 100644 index 000000000..2d905d194 --- /dev/null +++ b/packages/plugins/api-keys/src/client/locale/en-US.ts @@ -0,0 +1,3 @@ +const locale = {}; + +export default locale; diff --git a/packages/plugins/api-keys/src/client/locale/index.ts b/packages/plugins/api-keys/src/client/locale/index.ts new file mode 100644 index 000000000..86e36797c --- /dev/null +++ b/packages/plugins/api-keys/src/client/locale/index.ts @@ -0,0 +1,17 @@ +import { i18n } from '@nocobase/client'; +import { useTranslation as useT } from 'react-i18next'; +import { NAMESPACE } from '../../constants'; + +export function lang(key: string) { + return i18n.t(key, { ns: NAMESPACE }); +} + +export function generateNTemplate(key: string) { + return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`; +} + +export function useTranslation() { + return useT([NAMESPACE, 'client'], { + nsMode: 'fallback', + }); +} diff --git a/packages/plugins/api-keys/src/client/locale/zh-CN.ts b/packages/plugins/api-keys/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..8ecae6e23 --- /dev/null +++ b/packages/plugins/api-keys/src/client/locale/zh-CN.ts @@ -0,0 +1,14 @@ +const locale = { + 'API key created successfully': 'API key 创建成功', + 'Make sure to copy your personal access key now as you will not be able to see this again.': + '请确保现在复制你的个人访问密钥,因为你将无法再次看到这个密钥。', + 'Key name': '密钥名称', + Expiration: '过期时间', + 'Delete API key': '删除 API key', + Role: '角色', + 'Keys manager': '密钥管理', + 'Created at': '创建时间', + 'Add API key': '添加 API key', +}; + +export default locale; diff --git a/packages/plugins/api-keys/src/collections/api-keys.ts b/packages/plugins/api-keys/src/collections/api-keys.ts new file mode 100644 index 000000000..e430ef564 --- /dev/null +++ b/packages/plugins/api-keys/src/collections/api-keys.ts @@ -0,0 +1,91 @@ +import type { CollectionOptions } from '@nocobase/database'; + +export default { + namespace: 'api-keys', + duplicator: 'optional', + name: 'apiKeys', + title: '{{t("API keys")}}', + sortable: 'sort', + model: 'ApiKeyModel', + createdBy: true, + updatedAt: false, + updatedBy: false, + logging: true, + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + interface: 'id', + }, + { + type: 'string', + name: 'name', + interface: 'input', + uiSchema: { + type: 'string', + title: '{{t("name")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'obo', + type: 'belongsTo', + name: 'role', + target: 'roles', + foreignKey: 'roleName', + uiSchema: { + type: 'object', + title: '{{t("Roles")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + fieldNames: { + label: 'title', + value: 'name', + }, + service: { + params: { + filter: { + $and: [ + { + users: { id: { $eq: '{{$user.id}}' } }, + }, + ], + }, + }, + }, + }, + }, + }, + { + name: 'expiresIn', + type: 'string', + interface: 'select', + uiSchema: { + type: 'string', + title: '{{t("Expires")}}', + 'x-component': 'Select', + enum: [ + { + label: '{{t("1 day")}}', + value: '1d', + }, + { + label: '{{t("7 days")}}', + value: '7d', + }, + { + label: '{{t("30 days")}}', + value: '30d', + }, + { + label: '{{t("90 days")}}', + value: '90d', + }, + ], + }, + }, + ], +} as CollectionOptions; diff --git a/packages/plugins/api-keys/src/collections/index.ts b/packages/plugins/api-keys/src/collections/index.ts new file mode 100644 index 000000000..50a7b012b --- /dev/null +++ b/packages/plugins/api-keys/src/collections/index.ts @@ -0,0 +1 @@ +export { default as apiKeysCollection } from './api-keys'; diff --git a/packages/plugins/api-keys/src/constants.ts b/packages/plugins/api-keys/src/constants.ts new file mode 100644 index 000000000..ab5fd31e1 --- /dev/null +++ b/packages/plugins/api-keys/src/constants.ts @@ -0,0 +1 @@ +export const NAMESPACE = 'api-keys'; diff --git a/packages/plugins/api-keys/src/index.ts b/packages/plugins/api-keys/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/plugins/api-keys/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/plugins/api-keys/src/server/__tests__/actions.test.ts b/packages/plugins/api-keys/src/server/__tests__/actions.test.ts new file mode 100644 index 000000000..27fcdab0e --- /dev/null +++ b/packages/plugins/api-keys/src/server/__tests__/actions.test.ts @@ -0,0 +1,171 @@ +import Database, { Repository } from '@nocobase/database'; +import { mockServer, MockServer } from '@nocobase/test'; + +describe('actions', () => { + let app: MockServer; + let db: Database; + let repo: Repository; + let agent; + let resource; + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + acl: true, + plugins: ['users', 'auth', 'api-keys', 'acl'], + }); + + // app.plugin(ApiKeysPlugin); + await app.loadAndInstall({ clean: true }); + db = app.db; + repo = db.getRepository('apiKeys'); + agent = app.agent(); + resource = agent.set('X-Role', 'admin').resource('apiKeys'); + }); + + afterEach(async () => { + await repo.destroy({ + truncate: true, + }); + await db.close(); + }); + + let user; + let testUser; + let role; + let testRole; + let createData; + const expiresIn = 60 * 60 * 24; + + beforeEach(async () => { + const userRepo = await db.getRepository('users'); + user = await userRepo.findOne({ + appends: ['roles'], + }); + testUser = await userRepo.create({ + values: { + nickname: 'test', + roles: user.roles, + }, + }); + const roleRepo = await db.getRepository('roles'); + testRole = await roleRepo.create({ + values: { + name: 'TEST_ROLE', + }, + }); + + role = await (db.getRepository('users.roles', user.id) as unknown as Repository).findOne({ + where: { + default: true, + }, + }); + createData = { + values: { + name: 'TEST', + role, + expiresIn, + }, + }; + await agent.login(user); + }); + + describe('create', () => { + let result; + let tokenData; + + beforeEach(async () => { + result = (await resource.create(createData)).body.data; + tokenData = await app.authManager.jwt.decode(result.token); + }); + + it('basic', async () => { + expect(result).toHaveProperty('token'); + }); + + it('the role that does not belong to you should throw error', async () => { + const res = await resource.create({ + values: { + ...createData, + role: testRole, + }, + }); + expect(res.status).toBe(400); + expect(res.text).toBe('Role not found'); + }); + + it('token should work', async () => { + const checkRes = await agent.set('Authorization', `Bearer ${result.token}`).resource('auth').check(); + expect(checkRes.body.data.nickname).toBe(user.nickname); + }); + + it('token expiresIn correctly', async () => { + expect(tokenData.exp - tokenData.iat).toBe(expiresIn); + }); + + it('token roleName correctly', async () => { + expect(tokenData.roleName).toBe(role.name); + }); + }); + + describe('list', () => { + beforeEach(async () => { + await resource.create(createData); + }); + + it('basic', async () => { + const res = await resource.list(); + expect(res.body.data.length).toBe(1); + const data = res.body.data[0]; + expect(data.name).toContain(createData.values.name); + expect(data.roleName).toContain(createData.values.role.name); + }); + + it("Only show current user's API keys", async () => { + expect((await resource.list()).body.data.length).toBe(1); + await agent.login(testUser); + expect((await resource.list()).body.data.length).toBe(0); + const values = { + name: 'TEST_USER_KEY', + expiresIn: 180 * 24 * 60 * 60, + role, + }; + await resource.create({ + values, + }); + const listData = (await resource.list()).body.data; + expect(listData.length).toBe(1); + expect(listData[0].name).toBe(values.name); + }); + }); + + describe('destroy', () => { + let result; + + beforeEach(async () => { + result = (await resource.create(createData)).body.data; + }); + + it('basic', async () => { + const res = await resource.list(); + expect(res.body.data.length).toBe(1); + const data = res.body.data[0]; + await resource.destroy({ + id: data.id, + }); + expect((await resource.list()).body.data.length).toBe(0); + }); + + it("Cannot delete other user's API keys", async () => { + const res = await resource.list(); + expect(res.body.data.length).toBe(1); + const data = res.body.data[0]; + await agent.login(testUser); + await resource.destroy({ + id: data.id, + }); + await agent.login(user); + expect((await resource.list()).body.data.length).toBe(1); + }); + }); +}); diff --git a/packages/plugins/api-keys/src/server/actions/api-keys.ts b/packages/plugins/api-keys/src/server/actions/api-keys.ts new file mode 100644 index 000000000..448350cb6 --- /dev/null +++ b/packages/plugins/api-keys/src/server/actions/api-keys.ts @@ -0,0 +1,32 @@ +import actions, { Context, Next } from '@nocobase/actions'; +import { Repository } from '@nocobase/database'; + +export async function create(ctx: Context, next: Next) { + const { values } = ctx.action.params; + + if (!values.role) { + return; + } + + const repository = ctx.db.getRepository('users.roles', ctx.auth.user.id) as unknown as Repository; + const role = await repository.findOne({ + filter: { + name: values.role.name, + }, + }); + if (!role) { + throw ctx.throw(400, ctx.t('Role not found')); + } + + 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(); + }); +} diff --git a/packages/plugins/api-keys/src/server/index.ts b/packages/plugins/api-keys/src/server/index.ts new file mode 100644 index 000000000..b68aea57f --- /dev/null +++ b/packages/plugins/api-keys/src/server/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugins/api-keys/src/server/locale/en-US.ts b/packages/plugins/api-keys/src/server/locale/en-US.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/api-keys/src/server/locale/en-US.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/api-keys/src/server/locale/index.ts b/packages/plugins/api-keys/src/server/locale/index.ts new file mode 100644 index 000000000..b0b1d48d5 --- /dev/null +++ b/packages/plugins/api-keys/src/server/locale/index.ts @@ -0,0 +1,2 @@ +export { default as enUS } from './en-US'; +export { default as zhCN } from './zh-CN'; diff --git a/packages/plugins/api-keys/src/server/locale/zh-CN.ts b/packages/plugins/api-keys/src/server/locale/zh-CN.ts new file mode 100644 index 000000000..c4ccc3a3e --- /dev/null +++ b/packages/plugins/api-keys/src/server/locale/zh-CN.ts @@ -0,0 +1,3 @@ +export default { + 'Role not found': '角色不存在', +}; diff --git a/packages/plugins/api-keys/src/server/plugin.ts b/packages/plugins/api-keys/src/server/plugin.ts new file mode 100644 index 000000000..cdb8e033e --- /dev/null +++ b/packages/plugins/api-keys/src/server/plugin.ts @@ -0,0 +1,52 @@ +import { Plugin } from '@nocobase/server'; +import { resolve } from 'path'; +import { NAMESPACE } from '../constants'; +import { create } from './actions/api-keys'; +import { enUS, zhCN } from './locale'; + +export interface ApiKeysPluginConfig { + name?: string; +} + +export default class ApiKeysPlugin extends Plugin { + resourceName = 'apiKeys'; + constructor(app, options) { + super(app, options); + } + + async beforeLoad() { + this.app.i18n.addResources('zh-CN', NAMESPACE, zhCN); + this.app.i18n.addResources('en-US', NAMESPACE, enUS); + + await this.app.resourcer.define({ + name: this.resourceName, + actions: { + create, + }, + only: ['list', 'create', 'destroy'], + }); + + this.app.acl.registerSnippet({ + name: ['pm', this.name, 'configuration'].join('.'), + actions: ['apiKeys:list', 'apiKeys:create', 'apiKeys:destroy'], + }); + } + + async load() { + await this.db.import({ + directory: resolve(__dirname, '../collections'), + }); + + this.app.resourcer.use(async (ctx, next) => { + const { resourceName, actionName } = ctx.action.params; + if (resourceName == this.resourceName && ['list', 'destroy'].includes(actionName)) { + ctx.action.mergeParams({ + filter: { + createdById: ctx.auth.user.id, + }, + }); + } + await next(); + }); + } +} diff --git a/packages/plugins/auth/src/server/basic-auth.ts b/packages/plugins/auth/src/server/basic-auth.ts index a02ad684c..5b98b943e 100644 --- a/packages/plugins/auth/src/server/basic-auth.ts +++ b/packages/plugins/auth/src/server/basic-auth.ts @@ -1,7 +1,7 @@ import { AuthConfig, BaseAuth } from '@nocobase/auth'; -import { namespace } from '../preset'; import { PasswordField } from '@nocobase/database'; import crypto from 'crypto'; +import { namespace } from '../preset'; export class BasicAuth extends BaseAuth { constructor(config: AuthConfig) { @@ -16,7 +16,7 @@ export class BasicAuth extends BaseAuth { if (!values[uniqueField]) { ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); } - const user = await this.userCollection.repository.findOne({ + const user = await this.userRepository.findOne({ where: { [uniqueField]: values[uniqueField], }, @@ -54,7 +54,7 @@ export class BasicAuth extends BaseAuth { if (!email) { ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); } - const user = await this.userCollection.repository.findOne({ + const user = await this.userRepository.findOne({ where: { email, }, @@ -72,7 +72,7 @@ export class BasicAuth extends BaseAuth { const { values: { email, password, resetToken }, } = ctx.action.params; - const user = await this.userCollection.repository.findOne({ + const user = await this.userRepository.findOne({ where: { email, resetToken, @@ -91,7 +91,7 @@ export class BasicAuth extends BaseAuth { async getUserByResetToken() { const ctx = this.ctx; const { token } = ctx.action.params; - const user = await this.userCollection.repository.findOne({ + const user = await this.userRepository.findOne({ where: { resetToken: token, }, @@ -111,7 +111,7 @@ export class BasicAuth extends BaseAuth { if (!currentUser) { ctx.throw(401); } - const user = await this.userCollection.repository.findOne({ + const user = await this.userRepository.findOne({ where: { email: currentUser.email, }, diff --git a/packages/plugins/oidc/src/server/oidc-auth.ts b/packages/plugins/oidc/src/server/oidc-auth.ts index fb53d3c4f..009102f13 100644 --- a/packages/plugins/oidc/src/server/oidc-auth.ts +++ b/packages/plugins/oidc/src/server/oidc-auth.ts @@ -71,8 +71,7 @@ export class OIDCAuth extends BaseAuth { // When email is provided, use email to find user // If found, associate the user with the current authenticator if (email) { - const userRepo = this.userCollection.repository; - const user = await userRepo.findOne({ + const user = await this.userRepository.findOne({ filter: { email }, }); if (user) { diff --git a/packages/plugins/saml/src/server/saml-auth.ts b/packages/plugins/saml/src/server/saml-auth.ts index 2866de8a6..39367799a 100644 --- a/packages/plugins/saml/src/server/saml-auth.ts +++ b/packages/plugins/saml/src/server/saml-auth.ts @@ -52,8 +52,7 @@ export class SAMLAuth extends BaseAuth { // When email is provided or nameID is email, use email to find user // If found, associate the user with the current authenticator if (email || nameID.match(/^.+@.+\..+$/)) { - const userRepo = this.userCollection.repository; - const user = await userRepo.findOne({ + const user = await this.userRepository.findOne({ filter: { email: email || nameID }, }); if (user) { diff --git a/packages/plugins/sms-auth/src/server/sms-auth.ts b/packages/plugins/sms-auth/src/server/sms-auth.ts index 2a914f5f1..69ae1d145 100644 --- a/packages/plugins/sms-auth/src/server/sms-auth.ts +++ b/packages/plugins/sms-auth/src/server/sms-auth.ts @@ -1,7 +1,7 @@ import { AuthConfig, BaseAuth } from '@nocobase/auth'; import { Model } from '@nocobase/database'; -import VerificationPlugin from '@nocobase/plugin-verification'; import { AuthModel } from '@nocobase/plugin-auth'; +import VerificationPlugin from '@nocobase/plugin-verification'; import { namespace } from '../constants'; export class SMSAuth extends BaseAuth { @@ -26,8 +26,7 @@ export class SMSAuth extends BaseAuth { } = ctx.action.params; try { // History data compatible processing - const userRepo = this.userCollection.repository; - user = await userRepo.findOne({ + user = await this.userRepository.findOne({ filter: { phone }, }); if (user) { diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 429b07b00..6a2a447ca 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -6,6 +6,7 @@ "types": "./lib/index.d.ts", "dependencies": { "@nocobase/plugin-acl": "0.10.0-alpha.5", + "@nocobase/plugin-api-keys": "0.10.0-alpha.5", "@nocobase/plugin-audit-logs": "0.10.0-alpha.5", "@nocobase/plugin-auth": "0.10.0-alpha.5", "@nocobase/plugin-charts": "0.10.0-alpha.5", diff --git a/packages/presets/nocobase/src/index.ts b/packages/presets/nocobase/src/index.ts index a560e663b..d18bfb78a 100644 --- a/packages/presets/nocobase/src/index.ts +++ b/packages/presets/nocobase/src/index.ts @@ -38,6 +38,7 @@ export class PresetNocoBase extends Plugin { 'snapshot-field', 'graph-collection-manager', 'mobile-client', + 'api-keys', ]; splitNames(name: string) {