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 <chenlinxh@gmail.com>
This commit is contained in:
Dunqing 2023-06-30 11:20:35 +08:00 committed by GitHub
parent 4bf722326c
commit 6cfd586175
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 967 additions and 52 deletions

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-api-keys/client';

View File

@ -1,7 +1,7 @@
import Koa from 'koa'; import { Cache } from '@nocobase/cache';
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { Action } from '@nocobase/resourcer'; import { Action } from '@nocobase/resourcer';
import { Cache } from '@nocobase/cache'; import Koa from 'koa';
import lodash from 'lodash'; import lodash from 'lodash';
import * as actions from './actions'; import * as actions from './actions';

View File

@ -2,6 +2,7 @@ import { Context, Next } from '@nocobase/actions';
import { Model } from '@nocobase/database'; import { Model } from '@nocobase/database';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
import { Auth, AuthExtend } from './auth'; import { Auth, AuthExtend } from './auth';
import { JwtOptions, JwtService } from './base/jwt-service';
type Storer = { type Storer = {
get: (name: string) => Promise<Model>; get: (name: string) => Promise<Model>;
@ -10,6 +11,7 @@ type Storer = {
type AuthManagerOptions = { type AuthManagerOptions = {
authKey: string; authKey: string;
default?: string; default?: string;
jwt?: JwtOptions;
}; };
type AuthConfig = { type AuthConfig = {
@ -21,9 +23,11 @@ export class AuthManager {
protected authTypes: Registry<AuthConfig> = new Registry(); protected authTypes: Registry<AuthConfig> = new Registry();
// authenticators collection manager. // authenticators collection manager.
protected storer: Storer; protected storer: Storer;
jwt: JwtService;
constructor(options: AuthManagerOptions) { constructor(options: AuthManagerOptions) {
this.options = options; this.options = options;
this.jwt = new JwtService(options.jwt);
} }
setStorer(storer: Storer) { setStorer(storer: Storer) {

View File

@ -1,13 +1,12 @@
import { Auth, AuthConfig } from '../auth';
import { JwtOptions, JwtService } from './jwt-service';
import { Collection, Model } from '@nocobase/database'; import { Collection, Model } from '@nocobase/database';
import { Auth, AuthConfig } from '../auth';
import { JwtService } from './jwt-service';
/** /**
* BaseAuth * BaseAuth
* @description A base class with jwt provide some common methods. * @description A base class with jwt provide some common methods.
*/ */
export class BaseAuth extends Auth { export class BaseAuth extends Auth {
protected jwt: JwtService;
protected userCollection: Collection; protected userCollection: Collection;
constructor( constructor(
@ -15,10 +14,17 @@ export class BaseAuth extends Auth {
userCollection: Collection; userCollection: Collection;
}, },
) { ) {
const { options, userCollection } = config; const { userCollection } = config;
super(config); super(config);
this.userCollection = userCollection; 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) { set user(user: Model) {
@ -35,13 +41,17 @@ export class BaseAuth extends Auth {
return null; return null;
} }
try { try {
const { userId } = await this.jwt.decode(token); const { userId, roleName } = await this.jwt.decode(token);
const user = await this.userCollection.repository.findOne({
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
return await this.userRepository.findOne({
filter: { filter: {
id: userId, id: userId,
}, },
}); });
return user;
} catch (err) { } catch (err) {
this.ctx.logger.error(err); this.ctx.logger.error(err);
return null; return null;

View File

@ -1,15 +1,21 @@
import jwt from 'jsonwebtoken'; import jwt, { SignOptions } from 'jsonwebtoken';
export interface JwtOptions { export interface JwtOptions {
secret: string; secret: string;
expiresIn?: string; expiresIn?: string;
} }
export type SignPayload = Parameters<typeof jwt.sign>[0];
export class JwtService { export class JwtService {
constructor(protected options: JwtOptions) { constructor(
const { secret, expiresIn } = options || {}; protected options: JwtOptions = {
secret: process.env.APP_KEY,
},
) {
const { secret, expiresIn } = options;
this.options = { this.options = {
secret: secret || process.env.APP_KEY, secret: secret,
expiresIn: expiresIn || process.env.JWT_EXPIRES_IN || '7d', expiresIn: expiresIn || process.env.JWT_EXPIRES_IN || '7d',
}; };
} }
@ -22,8 +28,8 @@ export class JwtService {
return this.options.secret; return this.options.secret;
} }
sign(payload: any) { sign(payload: SignPayload, options?: SignOptions) {
return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn() }); return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn(), ...options });
} }
decode(token: string): Promise<any> { decode(token: string): Promise<any> {

View File

@ -83,7 +83,7 @@ export const Action: ComposedAction = observer(
const [formValueChanged, setFormValueChanged] = useState(false); const [formValueChanged, setFormValueChanged] = useState(false);
const Designer = useDesigner(); const Designer = useDesigner();
const field = useField<any>(); const field = useField<any>();
const { run } = useAction(); const { run, element } = useAction();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const compile = useCompile(); const compile = useCompile();
const form = useForm(); const form = useForm();
@ -163,6 +163,7 @@ export const Action: ComposedAction = observer(
{popover && <RecursionField basePath={field.address} onlyRenderProperties schema={fieldSchema} />} {popover && <RecursionField basePath={field.address} onlyRenderProperties schema={fieldSchema} />}
{!popover && renderButton()} {!popover && renderButton()}
{!popover && <div onClick={(e) => e.stopPropagation()}>{props.children}</div>} {!popover && <div onClick={(e) => e.stopPropagation()}>{props.children}</div>}
{element}
</ActionContextProvider> </ActionContextProvider>
); );
}, },

View File

@ -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 { toArr } from '@formily/shared';
import React, { Fragment, useRef, useState } from 'react'; import React, { Fragment, useRef, useState } from 'react';
import { useDesignable } from '../../';
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider'; import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
import { CollectionProvider } from '../../../collection-manager'; import { CollectionProvider } from '../../../collection-manager';
import { RecordProvider, useRecord } from '../../../record-provider'; import { RecordProvider, useRecord } from '../../../record-provider';
import { FormProvider } from '../../core'; import { FormProvider } from '../../core';
import { useCompile } from '../../hooks'; import { useCompile } from '../../hooks';
import { useDesignable } from '../../';
import { ActionContextProvider, useActionContext } from '../action'; import { ActionContextProvider, useActionContext } from '../action';
import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip'; import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks'; import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';

View File

@ -55,7 +55,7 @@ describe('role', () => {
expect(ctx.state.currentRole).toBe('root'); 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({ ctx.state.currentUser = await db.getRepository('users').findOne({
appends: ['roles'], appends: ['roles'],
}); });
@ -64,8 +64,11 @@ describe('role', () => {
return 'abc'; return 'abc';
} }
}; };
const throwFn = jest.fn();
ctx.throw = throwFn;
await setCurrentRole(ctx, () => {}); 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 () => { it('should set role with anonymous', async () => {

View File

@ -93,7 +93,7 @@ describe('actions', () => {
const rolesCheckResponse2 = (await loggedAgent.set('Accept', 'application/json').get('/roles:check')) as any; 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'); expect(rolesCheckResponse2.body.errors[0].message).toEqual('User role not found');
}); });

View File

@ -8,9 +8,6 @@ const map2obj = (map: Map<string, string>) => {
export async function checkAction(ctx, next) { export async function checkAction(ctx, next) {
const currentRole = ctx.state.currentRole; const currentRole = ctx.state.currentRole;
if (!currentRole) {
throw new Error('User role not found');
}
const roleInstance = await ctx.db.getRepository('roles').findOne({ const roleInstance = await ctx.db.getRepository('roles').findOne({
filter: { filter: {

View File

@ -84,6 +84,6 @@ export default {
type: 'set', type: 'set',
name: 'snippets', name: 'snippets',
defaultValue: ['!ui.*', '!pm', '!pm.*'], defaultValue: ['!ui.*', '!pm', '!pm.*'],
}, }
], ],
} as CollectionOptions; } as CollectionOptions;

View File

@ -1,5 +1,8 @@
export async function setCurrentRole(ctx, next) { import { Context } from '@nocobase/actions';
let currentRole = ctx.get('X-Role'); import { Repository } from '@nocobase/database';
export async function setCurrentRole(ctx: Context, next) {
const currentRole = ctx.get('X-Role');
if (currentRole === 'anonymous') { if (currentRole === 'anonymous') {
ctx.state.currentRole = currentRole; ctx.state.currentRole = currentRole;
@ -10,22 +13,22 @@ export async function setCurrentRole(ctx, next) {
return 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(); const roles = await repository.find();
ctx.state.currentUser.setDataValue('roles', roles); ctx.state.currentUser.setDataValue('roles', roles);
if (roles.length == 1) { // 1. If the X-Role is set, use the specified role
currentRole = roles[0].name; if (currentRole) {
} else if (roles.length > 1) { ctx.state.currentRole = roles.find((role) => role.name === currentRole)?.name;
const role = roles.find((item) => item.name === currentRole); }
if (!role) { // 2. If the X-Role is not set, use the default role
const defaultRole = roles.find((item) => item?.rolesUsers?.default); else {
currentRole = (defaultRole || roles[0])?.name; const defaultRole = roles.find((item) => item?.rolesUsers?.default);
} ctx.state.currentRole = (defaultRole || roles[0])?.name;
} }
if (currentRole) { if (!ctx.state.currentRole) {
ctx.state.currentRole = currentRole; return ctx.throw(401, 'User role not found');
} }
await next(); await next();

View File

@ -0,0 +1,9 @@
# api-keys
English | [中文](./README.zh-CN.md)
## 安装激活
内置插件无需手动安装激活。
## 使用方法

View File

@ -0,0 +1,9 @@
# api-keys
[English](./README.md) | 中文
## 安装激活
内置插件无需手动安装激活。
## 使用方法

4
packages/plugins/api-keys/client.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -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];
}
});
});

View File

@ -0,0 +1 @@
# API keys Changelog

View File

@ -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.

View File

@ -0,0 +1,14 @@
[
{
"title": "Introduction",
"path": "index"
},
{
"title": "Usage",
"path": "usage"
},
{
"title": "Changelog",
"path": "changelog"
}
]

View File

@ -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.

View File

@ -0,0 +1 @@
# API keys 更新日志

View File

@ -0,0 +1,10 @@
# API keys
## 简介
该插件允许你创建和管理 API生成的 API keys 可以用于访问 `NocoBase` 所有 API。
## 权限控制
创建 API key 时,可以为该 Key 绑定角色,角色的权限就是 Key 的权限。

View File

@ -0,0 +1,14 @@
[
{
"title": "介绍",
"path": "index"
},
{
"title": "用法",
"path": "usage"
},
{
"title": "日志",
"path": "changelog"
}
]

View File

@ -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。

View File

@ -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"
}

4
packages/plugins/api-keys/server.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -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];
}
});
});

View File

@ -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 (
<CollectionManagerProvider collections={[apiKeysCollection]}>
<RecursionField schema={configurationSchema} />
</CollectionManagerProvider>
);
};

View File

@ -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: (
<Space direction="vertical">
<Alert
message={t('Make sure to copy your personal access key now as you will not be able to see this again.')}
type="warning"
></Alert>
<Typography.Text copyable>{response.data?.data?.token}</Typography.Text>
</Space>
),
});
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,
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -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 (
<SettingsCenterProvider
settings={{
['api-keys']: {
title: t('API keys'),
icon: 'EnvironmentOutlined',
tabs: {
configuration: {
title: t('Keys manager'),
component: Configuration,
},
},
},
}}
>
<SchemaComponentOptions components={{}}>{props.children}</SchemaComponentOptions>
</SettingsCenterProvider>
);
});
ApiKeysProvider.displayName = 'ApiKeysProvider';
export default ApiKeysProvider;

View File

@ -0,0 +1,3 @@
const locale = {};
export default locale;

View File

@ -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',
});
}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1 @@
export { default as apiKeysCollection } from './api-keys';

View File

@ -0,0 +1 @@
export const NAMESPACE = 'api-keys';

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -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);
});
});
});

View File

@ -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();
});
}

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,2 @@
export { default as enUS } from './en-US';
export { default as zhCN } from './zh-CN';

View File

@ -0,0 +1,3 @@
export default {
'Role not found': '角色不存在',
};

View File

@ -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<ApiKeysPluginConfig> {
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();
});
}
}

View File

@ -1,7 +1,7 @@
import { AuthConfig, BaseAuth } from '@nocobase/auth'; import { AuthConfig, BaseAuth } from '@nocobase/auth';
import { namespace } from '../preset';
import { PasswordField } from '@nocobase/database'; import { PasswordField } from '@nocobase/database';
import crypto from 'crypto'; import crypto from 'crypto';
import { namespace } from '../preset';
export class BasicAuth extends BaseAuth { export class BasicAuth extends BaseAuth {
constructor(config: AuthConfig) { constructor(config: AuthConfig) {
@ -16,7 +16,7 @@ export class BasicAuth extends BaseAuth {
if (!values[uniqueField]) { if (!values[uniqueField]) {
ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); 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: { where: {
[uniqueField]: values[uniqueField], [uniqueField]: values[uniqueField],
}, },
@ -54,7 +54,7 @@ export class BasicAuth extends BaseAuth {
if (!email) { if (!email) {
ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); 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: { where: {
email, email,
}, },
@ -72,7 +72,7 @@ export class BasicAuth extends BaseAuth {
const { const {
values: { email, password, resetToken }, values: { email, password, resetToken },
} = ctx.action.params; } = ctx.action.params;
const user = await this.userCollection.repository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
email, email,
resetToken, resetToken,
@ -91,7 +91,7 @@ export class BasicAuth extends BaseAuth {
async getUserByResetToken() { async getUserByResetToken() {
const ctx = this.ctx; const ctx = this.ctx;
const { token } = ctx.action.params; const { token } = ctx.action.params;
const user = await this.userCollection.repository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
resetToken: token, resetToken: token,
}, },
@ -111,7 +111,7 @@ export class BasicAuth extends BaseAuth {
if (!currentUser) { if (!currentUser) {
ctx.throw(401); ctx.throw(401);
} }
const user = await this.userCollection.repository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
email: currentUser.email, email: currentUser.email,
}, },

View File

@ -71,8 +71,7 @@ export class OIDCAuth extends BaseAuth {
// When email is provided, use email to find user // When email is provided, use email to find user
// If found, associate the user with the current authenticator // If found, associate the user with the current authenticator
if (email) { if (email) {
const userRepo = this.userCollection.repository; const user = await this.userRepository.findOne({
const user = await userRepo.findOne({
filter: { email }, filter: { email },
}); });
if (user) { if (user) {

View File

@ -52,8 +52,7 @@ export class SAMLAuth extends BaseAuth {
// When email is provided or nameID is email, use email to find user // When email is provided or nameID is email, use email to find user
// If found, associate the user with the current authenticator // If found, associate the user with the current authenticator
if (email || nameID.match(/^.+@.+\..+$/)) { if (email || nameID.match(/^.+@.+\..+$/)) {
const userRepo = this.userCollection.repository; const user = await this.userRepository.findOne({
const user = await userRepo.findOne({
filter: { email: email || nameID }, filter: { email: email || nameID },
}); });
if (user) { if (user) {

View File

@ -1,7 +1,7 @@
import { AuthConfig, BaseAuth } from '@nocobase/auth'; import { AuthConfig, BaseAuth } from '@nocobase/auth';
import { Model } from '@nocobase/database'; import { Model } from '@nocobase/database';
import VerificationPlugin from '@nocobase/plugin-verification';
import { AuthModel } from '@nocobase/plugin-auth'; import { AuthModel } from '@nocobase/plugin-auth';
import VerificationPlugin from '@nocobase/plugin-verification';
import { namespace } from '../constants'; import { namespace } from '../constants';
export class SMSAuth extends BaseAuth { export class SMSAuth extends BaseAuth {
@ -26,8 +26,7 @@ export class SMSAuth extends BaseAuth {
} = ctx.action.params; } = ctx.action.params;
try { try {
// History data compatible processing // History data compatible processing
const userRepo = this.userCollection.repository; user = await this.userRepository.findOne({
user = await userRepo.findOne({
filter: { phone }, filter: { phone },
}); });
if (user) { if (user) {

View File

@ -6,6 +6,7 @@
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/plugin-acl": "0.10.0-alpha.5", "@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-audit-logs": "0.10.0-alpha.5",
"@nocobase/plugin-auth": "0.10.0-alpha.5", "@nocobase/plugin-auth": "0.10.0-alpha.5",
"@nocobase/plugin-charts": "0.10.0-alpha.5", "@nocobase/plugin-charts": "0.10.0-alpha.5",

View File

@ -38,6 +38,7 @@ export class PresetNocoBase extends Plugin {
'snapshot-field', 'snapshot-field',
'graph-collection-manager', 'graph-collection-manager',
'mobile-client', 'mobile-client',
'api-keys',
]; ];
splitNames(name: string) { splitNames(name: string) {