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:
parent
4bf722326c
commit
6cfd586175
1
packages/app/client/src/plugins/api-keys.ts
Normal file
1
packages/app/client/src/plugins/api-keys.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from '@nocobase/plugin-api-keys/client';
|
@ -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';
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
@ -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> {
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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';
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
|
@ -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) {
|
|
||||||
const defaultRole = roles.find((item) => item?.rolesUsers?.default);
|
|
||||||
currentRole = (defaultRole || roles[0])?.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) {
|
if (!ctx.state.currentRole) {
|
||||||
ctx.state.currentRole = currentRole;
|
return ctx.throw(401, 'User role not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
9
packages/plugins/api-keys/README.md
Normal file
9
packages/plugins/api-keys/README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# api-keys
|
||||||
|
|
||||||
|
English | [中文](./README.zh-CN.md)
|
||||||
|
|
||||||
|
## 安装激活
|
||||||
|
|
||||||
|
内置插件无需手动安装激活。
|
||||||
|
|
||||||
|
## 使用方法
|
9
packages/plugins/api-keys/README.zh-CN.md
Normal file
9
packages/plugins/api-keys/README.zh-CN.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# api-keys
|
||||||
|
|
||||||
|
[English](./README.md) | 中文
|
||||||
|
|
||||||
|
## 安装激活
|
||||||
|
|
||||||
|
内置插件无需手动安装激活。
|
||||||
|
|
||||||
|
## 使用方法
|
4
packages/plugins/api-keys/client.d.ts
vendored
Executable file
4
packages/plugins/api-keys/client.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/client';
|
||||||
|
export { default } from './lib/client';
|
||||||
|
|
30
packages/plugins/api-keys/client.js
Executable file
30
packages/plugins/api-keys/client.js
Executable 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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
1
packages/plugins/api-keys/docs/en-US/changelog.md
Normal file
1
packages/plugins/api-keys/docs/en-US/changelog.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# API keys Changelog
|
9
packages/plugins/api-keys/docs/en-US/index.md
Normal file
9
packages/plugins/api-keys/docs/en-US/index.md
Normal 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.
|
14
packages/plugins/api-keys/docs/en-US/tabs.json
Normal file
14
packages/plugins/api-keys/docs/en-US/tabs.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Introduction",
|
||||||
|
"path": "index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Usage",
|
||||||
|
"path": "usage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Changelog",
|
||||||
|
"path": "changelog"
|
||||||
|
}
|
||||||
|
]
|
19
packages/plugins/api-keys/docs/en-US/usage.md
Normal file
19
packages/plugins/api-keys/docs/en-US/usage.md
Normal 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.
|
1
packages/plugins/api-keys/docs/zh-CN/changelog.md
Normal file
1
packages/plugins/api-keys/docs/zh-CN/changelog.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# API keys 更新日志
|
10
packages/plugins/api-keys/docs/zh-CN/index.md
Normal file
10
packages/plugins/api-keys/docs/zh-CN/index.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# API keys
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
该插件允许你创建和管理 API,生成的 API keys 可以用于访问 `NocoBase` 所有 API。
|
||||||
|
|
||||||
|
|
||||||
|
## 权限控制
|
||||||
|
|
||||||
|
创建 API key 时,可以为该 Key 绑定角色,角色的权限就是 Key 的权限。
|
14
packages/plugins/api-keys/docs/zh-CN/tabs.json
Normal file
14
packages/plugins/api-keys/docs/zh-CN/tabs.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "介绍",
|
||||||
|
"path": "index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "用法",
|
||||||
|
"path": "usage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "日志",
|
||||||
|
"path": "changelog"
|
||||||
|
}
|
||||||
|
]
|
19
packages/plugins/api-keys/docs/zh-CN/usage.md
Normal file
19
packages/plugins/api-keys/docs/zh-CN/usage.md
Normal 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。
|
24
packages/plugins/api-keys/package.json
Normal file
24
packages/plugins/api-keys/package.json
Normal 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
4
packages/plugins/api-keys/server.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/server';
|
||||||
|
export { default } from './lib/server';
|
||||||
|
|
30
packages/plugins/api-keys/server.js
Executable file
30
packages/plugins/api-keys/server.js
Executable 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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
13
packages/plugins/api-keys/src/client/Configuration/index.tsx
Normal file
13
packages/plugins/api-keys/src/client/Configuration/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
262
packages/plugins/api-keys/src/client/Configuration/schema.tsx
Normal file
262
packages/plugins/api-keys/src/client/Configuration/schema.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
29
packages/plugins/api-keys/src/client/index.tsx
Normal file
29
packages/plugins/api-keys/src/client/index.tsx
Normal 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;
|
3
packages/plugins/api-keys/src/client/locale/en-US.ts
Normal file
3
packages/plugins/api-keys/src/client/locale/en-US.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const locale = {};
|
||||||
|
|
||||||
|
export default locale;
|
17
packages/plugins/api-keys/src/client/locale/index.ts
Normal file
17
packages/plugins/api-keys/src/client/locale/index.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
14
packages/plugins/api-keys/src/client/locale/zh-CN.ts
Normal file
14
packages/plugins/api-keys/src/client/locale/zh-CN.ts
Normal 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;
|
91
packages/plugins/api-keys/src/collections/api-keys.ts
Normal file
91
packages/plugins/api-keys/src/collections/api-keys.ts
Normal 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;
|
1
packages/plugins/api-keys/src/collections/index.ts
Normal file
1
packages/plugins/api-keys/src/collections/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as apiKeysCollection } from './api-keys';
|
1
packages/plugins/api-keys/src/constants.ts
Normal file
1
packages/plugins/api-keys/src/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const NAMESPACE = 'api-keys';
|
1
packages/plugins/api-keys/src/index.ts
Normal file
1
packages/plugins/api-keys/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './server';
|
171
packages/plugins/api-keys/src/server/__tests__/actions.test.ts
Normal file
171
packages/plugins/api-keys/src/server/__tests__/actions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
32
packages/plugins/api-keys/src/server/actions/api-keys.ts
Normal file
32
packages/plugins/api-keys/src/server/actions/api-keys.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
1
packages/plugins/api-keys/src/server/index.ts
Normal file
1
packages/plugins/api-keys/src/server/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
1
packages/plugins/api-keys/src/server/locale/en-US.ts
Normal file
1
packages/plugins/api-keys/src/server/locale/en-US.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
2
packages/plugins/api-keys/src/server/locale/index.ts
Normal file
2
packages/plugins/api-keys/src/server/locale/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as enUS } from './en-US';
|
||||||
|
export { default as zhCN } from './zh-CN';
|
3
packages/plugins/api-keys/src/server/locale/zh-CN.ts
Normal file
3
packages/plugins/api-keys/src/server/locale/zh-CN.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
'Role not found': '角色不存在',
|
||||||
|
};
|
52
packages/plugins/api-keys/src/server/plugin.ts
Normal file
52
packages/plugins/api-keys/src/server/plugin.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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",
|
||||||
|
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user