feat(auth): support token security (#5948)

* feat(auth): support token security config
---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
Sheldon Guo 2025-01-18 22:18:51 +08:00 committed by GitHub
parent 1a3280ad24
commit cc6928c7d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1613 additions and 267 deletions

View File

@ -7,8 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { BaseAuth } from '../base/auth';
import { vi } from 'vitest';
import { BaseAuth } from '../base/auth';
import { AuthErrorCode } from '../auth';
describe('base-auth', () => {
it('should validate username', () => {
@ -29,20 +30,25 @@ describe('base-auth', () => {
expect(auth.validateUsername('01234567890123456789012345678901234567890123456789a')).toBe(false);
});
it('check: should return null when no token', async () => {
it('check: should return user null when no token', async () => {
const auth = new BaseAuth({
userCollection: {},
ctx: {
t: (s) => s,
getBearerToken: () => null,
throw: (httpCode, err) => {
throw new Error(err.message);
},
},
} as any);
expect(await auth.check()).toBe(null);
expect(auth.check()).rejects.toThrow('Unauthenticated. Please sign in to continue.');
});
it('check: should set roleName to headers', async () => {
const ctx = {
getBearerToken: () => 'token',
t: (s) => s,
headers: {},
logger: {
error: (...args) => console.log(args),
@ -51,9 +57,19 @@ describe('base-auth', () => {
authManager: {
jwt: {
decode: () => ({ userId: 1, roleName: 'admin' }),
blacklist: {
has: () => false,
},
},
tokenController: {
check: () => ({ status: 'valid' }),
removeLoginExpiredTokens: async () => null,
},
},
},
cache: {
wrap: async (key, fn) => fn(),
},
};
const auth = new BaseAuth({
ctx,
@ -70,6 +86,7 @@ describe('base-auth', () => {
it('check: should return user', async () => {
const ctx = {
t: (s) => s,
getBearerToken: () => 'token',
headers: {},
logger: {
@ -79,6 +96,13 @@ describe('base-auth', () => {
authManager: {
jwt: {
decode: () => ({ userId: 1, roleName: 'admin' }),
blacklist: {
has: () => false,
},
},
tokenController: {
check: () => ({ status: 'valid' }),
removeLoginExpiredTokens: () => null,
},
},
},
@ -99,16 +123,22 @@ describe('base-auth', () => {
it('signIn: should throw 401', async () => {
const ctx = {
throw: vi.fn().mockImplementation((status, message) => {
throw new Error(message);
}),
t: (s) => s,
throw: (httpCode, error) => {
throw new Error(error.code);
},
};
const auth = new BaseAuth({
userCollection: {},
ctx,
} as any);
await expect(auth.signIn()).rejects.toThrowError('Unauthorized');
try {
await auth.signIn();
} catch (e) {
expect(e.message).toBe(AuthErrorCode.NOT_EXIST_USER);
}
await expect(auth.signIn()).rejects.toThrow();
});
it('signIn: should return user and token', async () => {
@ -127,6 +157,15 @@ describe('base-auth', () => {
jwt: {
sign: () => 'token',
},
tokenController: {
add: () => 'access',
getConfig: () => ({
tokenExpirationTime: '30m',
sessionExpirationTime: '1d',
expiredTokenRenewLimit: '15m',
}),
removeLoginExpiredTokens: () => null,
},
},
},
};
@ -140,4 +179,24 @@ describe('base-auth', () => {
expect(res.token).toBe('token');
expect(res.user).toEqual({ id: 1 });
});
it('should throw invalid error', async () => {
const ctx = {
t: (s) => s,
getBearerToken: () => 'token',
throw: (httpCode, error) => {
throw new Error(error.code);
},
};
const auth = new BaseAuth({
userCollection: {},
ctx,
} as any);
try {
await auth.validate();
} catch (e) {
expect(e.message).toBe(AuthErrorCode.INVALID_TOKEN);
}
});
});

View File

@ -10,6 +10,7 @@
import { Database } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
import { vi } from 'vitest';
import { AuthErrorCode } from '../auth';
describe('middleware', () => {
let app: MockServer;
@ -20,7 +21,7 @@ describe('middleware', () => {
app = await createMockServer({
registerActions: true,
acl: true,
plugins: ['users', 'auth', 'acl', 'field-sort', 'data-source-manager'],
plugins: ['users', 'auth', 'acl', 'field-sort', 'data-source-manager', 'error-handler'],
});
// app.plugin(ApiKeysPlugin);
@ -70,7 +71,15 @@ describe('middleware', () => {
hasFn.mockImplementation(() => true);
const res = await agent.resource('auth').check();
expect(res.status).toBe(401);
expect(res.text).toContain('Token is invalid');
expect(res.body.errors.some((error) => error.code === AuthErrorCode.BLOCKED_TOKEN)).toBe(true);
});
it('should throw 401 when token in empty', async () => {
const visitorAgent = app.agent();
hasFn.mockImplementation(() => true);
const res = await visitorAgent.resource('auth').check();
expect(res.status).toBe(401);
expect(res.body.errors.some((error) => error.code === AuthErrorCode.EMPTY_TOKEN)).toBe(true);
});
});
});

View File

@ -9,10 +9,11 @@
import { Context, Next } from '@nocobase/actions';
import { Registry } from '@nocobase/utils';
import { ACL } from '@nocobase/acl';
import { Auth, AuthExtend } from './auth';
import { JwtOptions, JwtService } from './base/jwt-service';
import { ITokenBlacklistService } from './base/token-blacklist-service';
import { ITokenControlService } from './base/token-control-service';
export interface Authenticator {
authType: string;
options: Record<string, any>;
@ -40,6 +41,8 @@ export class AuthManager {
* @internal
*/
jwt: JwtService;
tokenController: ITokenControlService;
protected options: AuthManagerOptions;
protected authTypes: Registry<AuthConfig> = new Registry();
// authenticators collection manager.
@ -58,6 +61,10 @@ export class AuthManager {
this.jwt.blacklist = service;
}
setTokenControlService(service: ITokenControlService) {
this.tokenController = service;
}
/**
* registerTypes
* @description Add a new authenticate type and the corresponding authenticator.
@ -104,22 +111,13 @@ export class AuthManager {
/**
* middleware
* @description Auth middleware, used to check the authentication status.
* @description Auth middleware, used to check the user status.
*/
middleware() {
const self = this;
return async function AuthManagerMiddleware(ctx: Context & { auth: Auth }, next: Next) {
const token = ctx.getBearerToken();
if (token && (await ctx.app.authManager.jwt.blacklist?.has(token))) {
return ctx.throw(401, {
code: 'TOKEN_INVALID',
message: ctx.t('Token is invalid'),
});
}
const name = ctx.get(self.options.authKey) || self.options.default;
let authenticator: Auth;
try {
authenticator = await ctx.app.authManager.get(name, ctx);
@ -129,11 +127,18 @@ export class AuthManager {
ctx.logger.warn(err.message, { method: 'check', authenticator: name });
return next();
}
if (authenticator) {
const user = await ctx.auth.check();
if (user) {
ctx.auth.user = user;
}
if (!authenticator) {
return next();
}
if (await ctx.auth.skipCheck()) {
return next();
}
const user = await ctx.auth.check();
if (user) {
ctx.auth.user = user;
}
await next();
};

View File

@ -10,7 +10,6 @@
import { Context } from '@nocobase/actions';
import { Model } from '@nocobase/database';
import { Authenticator } from './auth-manager';
export type AuthConfig = {
authenticator: Authenticator;
options: {
@ -18,7 +17,25 @@ export type AuthConfig = {
};
ctx: Context;
};
export const AuthErrorCode = {
EMPTY_TOKEN: 'EMPTY_TOKEN' as const,
EXPIRED_TOKEN: 'EXPIRED_TOKEN' as const,
INVALID_TOKEN: 'INVALID_TOKEN' as const,
TOKEN_RENEW_FAILED: 'TOKEN_RENEW_FAILED' as const,
BLOCKED_TOKEN: 'BLOCKED_TOKEN' as const,
EXPIRED_SESSION: 'EXPIRED_SESSION' as const,
NOT_EXIST_USER: 'NOT_EXIST_USER' as const,
};
export type AuthErrorType = keyof typeof AuthErrorCode;
export class AuthError extends Error {
code: AuthErrorType;
constructor(options: { code: AuthErrorType; message: string }) {
super(options.message);
this.code = options.code;
}
}
export type AuthExtend<T extends Auth> = new (config: AuthConfig) => T;
interface IAuth {
@ -49,9 +66,21 @@ export abstract class Auth implements IAuth {
this.ctx = ctx;
}
async skipCheck() {
const token = this.ctx.getBearerToken();
if (!token && this.ctx.app.options.acl === false) {
return true;
}
const { resourceName, actionName } = this.ctx.action;
const acl = this.ctx.dataSource.acl;
const isPublic = await acl.allowManager.isAllowed(resourceName, actionName, this.ctx);
return isPublic;
}
// The abstract methods are required to be implemented by all authentications.
abstract check(): Promise<Model>;
// The following methods are mainly designed for user authentications.
async signIn(): Promise<any> {}
async signUp(): Promise<any> {}
async signOut(): Promise<any> {}

View File

@ -8,10 +8,13 @@
*/
import { Collection, Model } from '@nocobase/database';
import { Auth, AuthConfig } from '../auth';
import { JwtService } from './jwt-service';
import { Cache } from '@nocobase/cache';
import jwt from 'jsonwebtoken';
import { Auth, AuthConfig, AuthErrorCode, AuthError } from '../auth';
import { JwtService } from './jwt-service';
import { ITokenControlService } from './token-control-service';
const localeNamespace = 'auth';
/**
* BaseAuth
* @description A base class with jwt provide some common methods.
@ -40,6 +43,10 @@ export class BaseAuth extends Auth {
return this.ctx.app.authManager.jwt;
}
get tokenController(): ITokenControlService {
return this.ctx.app.authManager.tokenController;
}
set user(user: Model) {
this.ctx.state.currentUser = user;
}
@ -63,35 +70,109 @@ export class BaseAuth extends Auth {
return /^[^@.<>"'/]{1,50}$/.test(username);
}
async check() {
async check(): ReturnType<Auth['check']> {
const token = this.ctx.getBearerToken();
if (!token) {
return null;
this.ctx.throw(401, {
message: this.ctx.t('Unauthenticated. Please sign in to continue.', { ns: localeNamespace }),
code: AuthErrorCode.EMPTY_TOKEN,
});
}
let tokenStatus: 'valid' | 'expired' | 'invalid';
let payload;
try {
const { userId, roleName, iat, temp } = await this.jwt.decode(token);
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
const cache = this.ctx.cache as Cache;
const user = await cache.wrap(this.getCacheKey(userId), () =>
this.userRepository.findOne({
filter: {
id: userId,
},
raw: true,
}),
);
if (temp && user.passwordChangeTz && iat * 1000 < user.passwordChangeTz) {
throw new Error('Token is invalid');
}
return user;
payload = await this.jwt.decode(token);
tokenStatus = 'valid';
} catch (err) {
this.ctx.logger.error(err, { method: 'check' });
return null;
if (err.name === 'TokenExpiredError') {
tokenStatus = 'expired';
payload = jwt.decode(token);
} else {
this.ctx.logger.error(err, { method: 'jwt.decode' });
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
}
const { userId, roleName, iat, temp, jti, exp, signInTime } = payload ?? {};
const blocked = await this.jwt.blacklist.has(jti ?? token);
if (blocked) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.BLOCKED_TOKEN,
});
}
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
const cache = this.ctx.cache as Cache;
const user = await cache.wrap(this.getCacheKey(userId), () =>
this.userRepository.findOne({
filter: {
id: userId,
},
raw: true,
}),
);
if (!temp && tokenStatus !== 'valid') {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
if (tokenStatus === 'valid' && user.passwordChangeTz && iat * 1000 < user.passwordChangeTz) {
this.ctx.throw(401, {
message: this.ctx.t('User password changed, please signin again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
if (tokenStatus === 'expired') {
const tokenPolicy = await this.tokenController.getConfig();
if (!signInTime || Date.now() - signInTime > tokenPolicy.sessionExpirationTime) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.EXPIRED_SESSION,
});
}
if (tokenPolicy.expiredTokenRenewLimit > 0 && Date.now() - exp * 1000 > tokenPolicy.expiredTokenRenewLimit) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.EXPIRED_SESSION,
});
}
try {
const renewedResult = await this.tokenController.renew(jti);
const expiresIn = Math.floor(tokenPolicy.tokenExpirationTime / 1000);
const newToken = this.jwt.sign({ userId, roleName, temp, signInTime }, { jwtid: renewedResult.jti, expiresIn });
this.ctx.res.setHeader('x-new-token', newToken);
return user;
} catch (err) {
const options =
err instanceof AuthError
? { type: err.code, message: err.message }
: { message: err.message, type: AuthErrorCode.INVALID_TOKEN };
this.ctx.throw(401, {
message: this.ctx.t(options.message, { ns: localeNamespace }),
code: options.type,
});
}
}
return user;
}
async validate(): Promise<Model> {
@ -108,12 +189,25 @@ export class BaseAuth extends Auth {
});
}
if (!user) {
this.ctx.throw(401, 'Unauthorized');
this.ctx.throw(401, {
message: this.ctx.t('User not found. Please sign in again to continue.', { ns: localeNamespace }),
code: AuthErrorCode.NOT_EXIST_USER,
});
}
const token = this.jwt.sign({
userId: user.id,
temp: true,
});
const tokenInfo = await this.tokenController.add({ userId: user.id });
const expiresIn = Math.floor((await this.tokenController.getConfig()).tokenExpirationTime / 1000);
const token = this.jwt.sign(
{
userId: user.id,
temp: true,
iat: Math.floor(tokenInfo.issuedTime / 1000),
signInTime: tokenInfo.signInTime,
},
{
jwtid: tokenInfo.jti,
expiresIn,
},
);
return {
user,
token,

View File

@ -7,9 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import jwt, { SignOptions } from 'jsonwebtoken';
import jwt, { JwtPayload, SignOptions } from 'jsonwebtoken';
import { ITokenBlacklistService } from './token-blacklist-service';
export interface JwtOptions {
secret: string;
expiresIn?: string;
@ -50,9 +49,9 @@ export class JwtService {
}
/* istanbul ignore next -- @preserve */
decode(token: string): Promise<any> {
decode(token: string): Promise<JwtPayload> {
return new Promise((resolve, reject) => {
jwt.verify(token, this.secret(), (err: any, decoded: any) => {
jwt.verify(token, this.secret(), (err, decoded: JwtPayload) => {
if (err) {
return reject(err);
}
@ -70,9 +69,9 @@ export class JwtService {
return null;
}
try {
const { exp } = await this.decode(token);
const { exp, jti } = await this.decode(token);
return this.blacklist.add({
token,
token: jti ?? token,
expiration: new Date(exp * 1000).toString(),
});
} catch {

View File

@ -0,0 +1,36 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export interface TokenPolicyConfig {
tokenExpirationTime: string;
sessionExpirationTime: string;
expiredTokenRenewLimit: string;
}
type millisecond = number;
export type NumericTokenPolicyConfig = {
[K in keyof TokenPolicyConfig]: millisecond;
};
export type TokenInfo = {
jti: string;
userId: number;
issuedTime: EpochTimeStamp;
signInTime: EpochTimeStamp;
renewed: boolean;
};
export type JTIStatus = 'valid' | 'inactive' | 'blocked' | 'missing' | 'renewed' | 'expired';
export interface ITokenControlService {
getConfig(): Promise<NumericTokenPolicyConfig>;
setConfig(config: TokenPolicyConfig): Promise<any>;
renew(jti: string): Promise<{ jti: string; issuedTime: EpochTimeStamp }>;
add({ userId }: { userId: number }): Promise<TokenInfo>;
removeSessionExpiredTokens(userId: number): Promise<void>;
}

View File

@ -12,3 +12,4 @@ export * from './auth';
export * from './auth-manager';
export * from './base/auth';
export * from './base/token-blacklist-service';
export * from './base/token-control-service';

View File

@ -70,6 +70,18 @@ export class APIClient extends APIClientSDK {
api.auth = this.auth;
api.storagePrefix = this.storagePrefix;
api.notification = this.notification;
const handlers = [];
for (const handler of this.axios.interceptors.response['handlers']) {
if (handler.rejected['_name'] === 'handleNotificationError') {
handlers.push({
...handler,
rejected: api.handleNotificationError.bind(api),
});
} else {
handlers.push(handler);
}
}
api.axios.interceptors.response['handlers'] = handlers;
return api;
}
@ -136,66 +148,71 @@ export class APIClient extends APIClientSDK {
);
}
useNotificationMiddleware() {
this.axios.interceptors.response.use(
(response) => {
if (response.data?.messages?.length) {
const messages = response.data.messages.filter((item) => {
const lastTime = errorCache.get(typeof item === 'string' ? item : item.message);
if (lastTime && new Date().getTime() - lastTime < 500) {
return false;
}
errorCache.set(item.message, new Date().getTime());
return true;
});
notify('success', messages, this.notification);
}
return response;
},
async (error) => {
if (this.silence) {
console.error(error);
return;
// throw error;
}
const redirectTo = error?.response?.data?.redirectTo;
if (redirectTo) {
return (window.location.href = redirectTo);
}
if (error?.response?.data?.type === 'application/json') {
handleErrorMessage(error, this.notification);
} else {
if (errorCache.size > 10) {
errorCache.clear();
}
const maintaining = !!error?.response?.data?.error?.maintaining;
if (this.app.maintaining !== maintaining) {
this.app.maintaining = maintaining;
}
if (this.app.maintaining) {
this.app.error = error?.response?.data?.error;
throw error;
} else if (this.app.error) {
this.app.error = null;
}
let errs = this.toErrMessages(error);
errs = errs.filter((error) => {
const lastTime = errorCache.get(error.message);
if (lastTime && new Date().getTime() - lastTime < 500) {
return false;
}
errorCache.set(error.message, new Date().getTime());
return true;
});
if (errs.length === 0) {
throw error;
}
notify('error', errs, this.notification);
}
async handleNotificationError(error) {
if (this.silence) {
// console.error(error);
// return;
throw error;
}
const skipNotify: boolean | ((error: any) => boolean) = error.config?.skipNotify;
if (skipNotify && ((typeof skipNotify === 'function' && skipNotify(error)) || skipNotify === true)) {
throw error;
}
const redirectTo = error?.response?.data?.redirectTo;
if (redirectTo) {
return (window.location.href = redirectTo);
}
if (error?.response?.data?.type === 'application/json') {
handleErrorMessage(error, this.notification);
} else {
if (errorCache.size > 10) {
errorCache.clear();
}
const maintaining = !!error?.response?.data?.error?.maintaining;
if (this.app.maintaining !== maintaining) {
this.app.maintaining = maintaining;
}
if (this.app.maintaining) {
this.app.error = error?.response?.data?.error;
throw error;
},
);
} else if (this.app.error) {
this.app.error = null;
}
let errs = this.toErrMessages(error);
errs = errs.filter((error) => {
const lastTime = errorCache.get(error.message);
if (lastTime && new Date().getTime() - lastTime < 500) {
return false;
}
errorCache.set(error.message, new Date().getTime());
return true;
});
if (errs.length === 0) {
throw error;
}
notify('error', errs, this.notification);
}
throw error;
}
useNotificationMiddleware() {
const errorHandler = this.handleNotificationError.bind(this);
errorHandler['_name'] = 'handleNotificationError';
this.axios.interceptors.response.use((response) => {
if (response.data?.messages?.length) {
const messages = response.data.messages.filter((item) => {
const lastTime = errorCache.get(typeof item === 'string' ? item : item.message);
if (lastTime && new Date().getTime() - lastTime < 500) {
return false;
}
errorCache.set(item.message, new Date().getTime());
return true;
});
notify('success', messages, this.notification);
}
return response;
}, errorHandler);
}
silent() {

View File

@ -8,16 +8,18 @@
*/
import { get, set } from 'lodash';
import React, { ComponentType } from 'react';
import React, { ComponentType, createContext, useContext } from 'react';
import {
BrowserRouter,
BrowserRouterProps,
HashRouter,
createBrowserRouter,
createHashRouter,
createMemoryRouter,
HashRouterProps,
MemoryRouter,
MemoryRouterProps,
Outlet,
RouteObject,
useRoutes,
RouterProvider,
useRouteError,
} from 'react-router-dom';
import VariablesProvider from '../variables/VariablesProvider';
import { Application } from './Application';
@ -47,6 +49,16 @@ export class RouterManager {
protected routes: Record<string, RouteType> = {};
protected options: RouterOptions;
public app: Application;
private router;
get basename() {
return this.router.basename;
}
get state() {
return this.router.state;
}
get navigate() {
return this.router.navigate;
}
constructor(options: RouterOptions = {}, app: Application) {
this.options = options;
@ -127,34 +139,55 @@ export class RouterManager {
*/
getRouterComponent(children?: React.ReactNode) {
const { type = 'browser', ...opts } = this.options;
const Routers = {
hash: HashRouter,
browser: BrowserRouter,
memory: MemoryRouter,
const routerCreators = {
hash: createHashRouter,
browser: createBrowserRouter,
memory: createMemoryRouter,
};
const ReactRouter = Routers[type];
const routes = this.getRoutesTree();
const RenderRoutes = () => {
const element = useRoutes(routes);
return element;
const BaseLayoutContext = createContext<ComponentType>(null);
const Provider = () => {
const BaseLayout = useContext(BaseLayoutContext);
return (
<CustomRouterContextProvider>
<BaseLayout>
<VariablesProvider>
<Outlet />
{children}
</VariablesProvider>
</BaseLayout>
</CustomRouterContextProvider>
);
};
// bubble up error to application error boundary
const ErrorElement = () => {
const error = useRouteError();
throw error;
};
this.router = routerCreators[type](
[
{
element: <Provider />,
errorElement: <ErrorElement />,
children: routes,
},
],
opts,
);
const RenderRouter: React.FC<{ BaseLayout?: ComponentType }> = ({ BaseLayout = BlankComponent }) => {
return (
<RouterContextCleaner>
<ReactRouter {...opts}>
<CustomRouterContextProvider>
<BaseLayout>
<VariablesProvider>
<RenderRoutes />
{children}
</VariablesProvider>
</BaseLayout>
</CustomRouterContextProvider>
</ReactRouter>
</RouterContextCleaner>
<BaseLayoutContext.Provider value={BaseLayout}>
<RouterContextCleaner>
<RouterProvider router={this.router} />
</RouterContextCleaner>
</BaseLayoutContext.Provider>
);
};

View File

@ -857,5 +857,9 @@
"Notification": "Notification",
"Ellipsis overflow content": "Ellipsis overflow content",
"Hide column": "Hide column",
"In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect.": "In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect."
"In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect.": "In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect.",
"Unauthenticated. Please sign in to continue.": "Unauthenticated. Please sign in to continue.",
"User not found. Please sign in again to continue.": "User not found. Please sign in again to continue.",
"Your session has expired. Please sign in again.": "Your session has expired. Please sign in again.",
"User password changed, please signin again.": "User password changed, please signin again."
}

View File

@ -1046,12 +1046,16 @@
"Bulk enable": "批量激活",
"Search plugin...": "搜索插件...",
"Package name": "包名",
"Associate": "关联",
"Please add or select record": "请添加或选择数据",
"No data": "暂无数据",
"Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用",
"Unauthenticated. Please sign in to continue.": "未认证。请登录以继续。",
"User not found. Please sign in again to continue.": "无法找到用户信息,请重新登录以继续。",
"Your session has expired. Please sign in again.": "您的会话已过期,请重新登录。",
"User password changed, please signin again.": "用户密码已更改,请重新登录。",
"Show file name":"显示文件名",
"Outlined": "线框风格",
"Filled": "实底风格",
"Two tone": "双色风格",
"Associate": "关联",
"Please add or select record":"请添加或选择数据",
"No data":"暂无数据",
"Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用"
"Two tone": "双色风格"
}

View File

@ -10,7 +10,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import { Navigate } from 'react-router-dom';
import { useACLRoleContext } from '../acl';
import { ReturnTypeOfUseRequest, useRequest } from '../api-client';
import { ReturnTypeOfUseRequest, useAPIClient, useRequest } from '../api-client';
import { useAppSpin, useLocationNoUpdate } from '../application';
import { useCompile } from '../schema-component';
@ -39,10 +39,22 @@ export const useCurrentRoles = () => {
};
export const CurrentUserProvider = (props) => {
const api = useAPIClient();
const result = useRequest<any>(() =>
api
.request({
url: '/auth:check',
skipNotify: (error) => {
const errs = api.toErrMessages(error);
if (errs.find((error: { code?: string }) => error.code === 'EMPTY_TOKEN')) {
return true;
}
return false;
},
})
.then((res) => res?.data),
);
const { render } = useAppSpin();
const result = useRequest<any>({
url: 'auth:check',
});
if (result.loading) {
return render();

View File

@ -85,6 +85,18 @@ apiClient.auth.role = 'root';
// 用于解析 `$nToken` 的值
apiClient.auth.token = 'token';
mockRequest.onPost('/auth:check').reply(() => {
return [
200,
{
data: {
id: 0,
nickname: 'from request',
},
},
];
});
mockRequest.onGet('/auth:check').reply(() => {
return [
200,

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios';
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios';
import qs from 'qs';
export interface ActionParams {
@ -354,7 +354,11 @@ export class APIClient {
});
}
request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D> | ResourceActionOptions): Promise<R> {
request<T = any, R = AxiosResponse<T>, D = any>(
config: (AxiosRequestConfig<D> | ResourceActionOptions) & {
skipNotify?: boolean | ((error: any) => boolean);
},
): Promise<R> {
const { resource, resourceOf, action, params, headers } = config as any;
if (resource) {
return this.resource(resource, resourceOf, headers)[action](params);

View File

@ -76,6 +76,7 @@ interface Resource {
interface ExtendedAgent extends SuperAgentTest {
login: (user: any, roleName?: string) => ExtendedAgent;
loginUsingId: (userId: number, roleName?: string) => ExtendedAgent;
loginWithJti: (user: any, roleName?: string) => Promise<ExtendedAgent>;
resource: (name: string, resourceOf?: any) => Resource;
}
@ -124,7 +125,7 @@ export class MockServer extends Application {
agent(callback?): ExtendedAgent {
const agent = supertest.agent(callback || this.callback());
const prefix = this.resourcer.options.prefix;
const authManager = this.authManager;
const proxy = new Proxy(agent, {
get(target, method: string, receiver) {
if (['login', 'loginUsingId'].includes(method)) {
@ -147,6 +148,32 @@ export class MockServer extends Application {
.set('X-Authenticator', 'basic');
};
}
if (method === 'loginWithJti') {
return async (userOrId: any, roleName?: string) => {
const userId = typeof userOrId === 'number' ? userOrId : userOrId?.id;
const tokenInfo = await authManager.tokenController.add({ userId });
const expiresIn = (await authManager.tokenController.getConfig()).tokenExpirationTime;
return proxy
.auth(
jwt.sign(
{
userId,
temp: true,
roleName,
signInTime: Date.now(),
},
process.env.APP_KEY,
{
jwtid: tokenInfo.jti,
expiresIn,
},
),
{ type: 'bearer' },
)
.set('X-Authenticator', 'basic');
};
}
if (method === 'resource') {
return (name: string, resourceOf?: any) => {
const keys = name.split('.');

View File

@ -138,7 +138,7 @@ describe('destroy action with acl', () => {
const a1 = await A.repository.findOne({ filter: { title: 'a1' } });
const response = await app.agent().resource('a.bs', a1.get('id')).list();
const response = await app.agent().login(1).resource('a.bs', a1.get('id')).list();
expect(response.statusCode).toEqual(200);
});
@ -175,6 +175,7 @@ describe('destroy action with acl', () => {
const response = await app
.agent()
.login(1)
.resource('posts')
.destroy({
filterByTk: p1.get('id'),

View File

@ -68,8 +68,8 @@ describe('configuration', () => {
});
it('should not create/list collections', async () => {
expect((await guestAgent.resource('collections').create()).statusCode).toEqual(403);
expect((await guestAgent.resource('collections').list()).statusCode).toEqual(403);
expect((await guestAgent.resource('collections').create()).statusCode).toEqual(401);
expect((await guestAgent.resource('collections').list()).statusCode).toEqual(401);
});
it('should allow when role has allowConfigure with true value', async () => {

View File

@ -1,3 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare';
@ -7,9 +16,15 @@ describe('get action with acl', () => {
let Post;
let Comment;
let userAgent;
beforeEach(async () => {
app = await prepareApp();
const UserRepo = app.db.getCollection('users').repository;
const users = await UserRepo.create({
values: [{ nickname: 'a', roles: [{ name: 'test' }] }],
});
userAgent = app.agent().login(users[0]);
Post = app.db.collection({
name: 'posts',
@ -72,13 +87,10 @@ describe('get action with acl', () => {
},
);
const response = await (app as any)
.agent()
.resource('posts')
.get({
filterByTk: p1.get('id'),
fields: ['comments'],
});
const response = await userAgent.resource('posts').get({
filterByTk: p1.get('id'),
fields: ['comments'],
});
expect(response.status).toBe(200);

View File

@ -17,9 +17,20 @@ describe('list action with acl', () => {
let Post;
let Comment;
let userAgent;
let users;
beforeEach(async () => {
app = await prepareApp();
const UserRepo = app.db.getCollection('users').repository;
const root = await UserRepo.findOne({});
users = await UserRepo.create({
values: [
{ id: 2, nickname: 'a', roles: [{ name: 'user' }] },
{ id: 3, nickname: 'a', roles: [{ name: 'user' }] },
],
});
userAgent = app.agent().login(users[0], 'user');
Post = app.db.collection({
name: 'posts',
@ -91,8 +102,7 @@ describe('list action with acl', () => {
},
);
const response = await (app as any)
.agent()
const response = await userAgent
.set('X-With-ACL-Meta', true)
.resource('posts')
.list({
@ -165,7 +175,7 @@ describe('list action with acl', () => {
);
//@ts-ignore
const response = await app.agent().set('X-With-ACL-Meta', true).resource('tests').list({});
const response = await userAgent.set('X-With-ACL-Meta', true).resource('tests').list({});
const data = response.body;
expect(data.meta.allowedActions.view).toEqual(['t1', 't2', 't3']);
@ -188,31 +198,32 @@ describe('list action with acl', () => {
await Post.repository.create({
values: [
{ title: 'p1', createdById: 1 },
{ title: 'p2', createdById: 1 },
{ title: 'p3', createdById: 2 },
{ title: 'p1', createdById: users[0].id },
{ title: 'p2', createdById: users[0].id },
{ title: 'p3', createdById: users[1].id },
],
});
app.resourcer.use(
(ctx, next) => {
ctx.state.currentRole = 'user';
ctx.state.currentUser = {
id: 1,
};
// app.resourcer.use(
// (ctx, next) => {
// ctx.state.currentRole = 'user';
// ctx.state.currentUser = {
// id: 1,
// };
return next();
},
{
before: 'acl',
after: 'auth',
},
);
// return next();
// },
// {
// before: 'acl',
// after: 'auth',
// },
// );
const response = await (app as any).agent().set('X-With-ACL-Meta', true).resource('posts').list();
// @ts-ignore
const response = await app.agent().login(users[0].id, 'user').set('X-With-ACL-Meta', true).resource('posts').list();
const data = response.body;
expect(data.meta.allowedActions.view).toEqual([1, 2, 3]);
expect(data.meta.allowedActions.update).toEqual([1, 2]);
expect(data.meta.allowedActions.view).toEqual(expect.arrayContaining([1, 2, 3]));
expect(data.meta.allowedActions.update).toEqual(expect.arrayContaining([1, 2]));
expect(data.meta.allowedActions.destroy).toEqual([]);
});
@ -251,7 +262,7 @@ describe('list action with acl', () => {
);
// @ts-ignore
const response = await app.agent().set('X-With-ACL-Meta', true).resource('posts').list({});
const response = await userAgent.set('X-With-ACL-Meta', true).resource('posts').list({});
const data = response.body;
expect(data.meta.allowedActions.view).toEqual([1, 2, 3]);
@ -294,7 +305,7 @@ describe('list action with acl', () => {
);
// @ts-ignore
const getResponse = await app.agent().set('X-With-ACL-Meta', true).resource('posts').get({
const getResponse = await userAgent.set('X-With-ACL-Meta', true).resource('posts').get({
filterByTk: 1,
});
@ -377,7 +388,7 @@ describe('list association action with acl', () => {
},
});
const userAgent = app.agent().login(user).set('X-With-ACL-Meta', true);
const userAgent = app.agent().login(user, 'newRole').set('X-With-ACL-Meta', true);
const createResp = await userAgent.resource('posts').create({
values: {

View File

@ -19,7 +19,9 @@ describe('middleware', () => {
let db: Database;
let acl: ACL;
let admin;
let member;
let adminAgent;
let memberAgent;
beforeEach(async () => {
app = await prepareApp();
@ -38,9 +40,15 @@ describe('middleware', () => {
roles: ['admin'],
},
});
member = await UserRepo.create({
values: {
roles: ['member'],
},
});
const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().login(admin);
memberAgent = app.agent().login(member);
await db.getRepository('collections').create({
values: {
@ -119,7 +127,15 @@ describe('middleware', () => {
});
it('should throw 403 when no permission', async () => {
const response = await app.agent().resource('posts').create({
await db.getRepository('roles').update({
filterByTk: 'member',
values: {
strategy: {
actions: ['view'],
},
},
});
const response = await app.agent().login(member).resource('posts').create({
values: {},
});

View File

@ -12,7 +12,8 @@
"antd": "5.x",
"cron": "^2.3.1",
"react": "^18.2.0",
"react-i18next": "^11.15.1"
"react-i18next": "^11.15.1",
"ms": "^2.1.3"
},
"peerDependencies": {
"@nocobase/actions": "1.x",
@ -28,6 +29,7 @@
"description.zh-CN": "用户认证管理包括基础的密码认证、短信认证、SSO 协议的认证等,可扩展。",
"gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1",
"keywords": [
"Authentication"
"Authentication",
"Security"
]
}

View File

@ -11,24 +11,25 @@ import { Plugin, lazy, useLazy } from '@nocobase/client';
import { Registry } from '@nocobase/utils/client';
import { ComponentType } from 'react';
import { presetAuthType } from '../preset';
import type { Authenticator as AuthenticatorType } from './authenticator';
import { authCheckMiddleware } from './interceptors';
import { NAMESPACE } from './locale';
// import { AuthProvider } from './AuthProvider';
const { AuthProvider } = lazy(() => import('./AuthProvider'), 'AuthProvider');
import type { Authenticator as AuthenticatorType } from './authenticator';
// import { Options, SignInForm, SignUpForm } from './basic';
const { Options, SignInForm, SignUpForm } = lazy(() => import('./basic'), 'Options', 'SignInForm', 'SignUpForm');
import { NAMESPACE } from './locale';
// import { AuthLayout, SignInPage, SignUpPage } from './pages';
const { AuthLayout, SignInPage, SignUpPage } = lazy(() => import('./pages'), 'AuthLayout', 'SignInPage', 'SignUpPage');
// import { Authenticator } from './settings/Authenticator';
const { Authenticator } = lazy(() => import('./settings/Authenticator'), 'Authenticator');
const { TokenPolicySettings } = lazy(() => import('./settings/token-policy'), 'TokenPolicySettings');
// export { AuthenticatorsContextProvider, AuthLayout } from './pages/AuthLayout';
const { AuthenticatorsContextProvider, AuthLayout: ExportAuthLayout } = lazy(
() => import('./pages'),
'AuthenticatorsContextProvider',
'AuthLayout',
);
export { AuthenticatorsContextProvider, ExportAuthLayout as AuthLayout };
export { ExportAuthLayout as AuthLayout, AuthenticatorsContextProvider };
export type AuthOptions = {
components: Partial<{
@ -81,6 +82,15 @@ export class PluginAuthClient extends Plugin {
AdminSettingsForm: Options,
},
});
this.app.pluginSettingsManager.add(`security.token-policy`, {
title: `{{t("Token policy", { ns: "${NAMESPACE}" })}}`,
Component: TokenPolicySettings,
aclSnippet: `pm.security.token-policy`,
icon: 'ApiOutlined',
sort: 0,
});
const [fulfilled, rejected] = authCheckMiddleware({ app: this.app });
this.app.apiClient.axios.interceptors.response.use(fulfilled, rejected);
}
}

View File

@ -0,0 +1,69 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application } from '@nocobase/client';
import type { AxiosResponse } from 'axios';
import debounce from 'lodash/debounce';
const pathJoin = (parts, sep = '/') => parts.join(sep).replace(new RegExp(sep + '{1,}', 'g'), sep);
const debouncedRedirect = debounce(
(redirectFunc) => {
redirectFunc();
},
3000,
{ leading: true, trailing: false },
);
export function authCheckMiddleware({ app }: { app: Application }) {
const axios = app.apiClient.axios;
const resHandler = (res: AxiosResponse) => {
const newToken = res.headers['x-new-token'];
if (newToken) {
app.apiClient.auth.setToken(newToken);
}
return res;
};
const errHandler = (error) => {
const newToken = error.response.headers['x-new-token'];
if (newToken) {
app.apiClient.auth.setToken(newToken);
}
if (error.status === 401) {
const errors = error?.response?.data?.errors;
const firstError = Array.isArray(errors) ? errors[0] : null;
if (!firstError) {
throw error;
}
if (firstError?.code === 'USER_HAS_NO_ROLES_ERR') {
app.error = firstError;
throw error;
}
const state = app.router.state;
const { pathname, search } = state.location;
const basename = app.router.basename;
if (pathname !== app.getHref('signin')) {
const routePath = pathname.startsWith(app.router.basename) ? pathname.slice(basename.length) || '/' : pathname;
const redirectPath = pathJoin([app.router.basename, routePath]);
// to-do wait for solve infinite loop navigate
// if (errorType === ('TOKEN_RENEW_FAILED' satisfies AuthErrorType)) {
// return axios.request(error.config);
// }
debouncedRedirect(() => {
app.apiClient.auth.setToken(null);
app.router.navigate(`/signin?redirect=${redirectPath}${search}`, { replace: true });
});
}
}
throw error;
};
return [resHandler, errHandler];
}

View File

@ -0,0 +1,55 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect } from 'react';
import { InputNumber, Select } from 'antd';
import { connect, mapProps } from '@formily/react';
import { useAuthTranslation } from '../../locale';
const { Option } = Select;
const InputTime = connect(
(props) => {
const { t } = useAuthTranslation();
const { value, onChange, minNum = 1, ...restProps } = props;
const regex = /^(\d*)([a-zA-Z]*)$/;
const match = value ? value.match(regex) : null;
useEffect(() => {
if (!match) onChange('10m');
}, [match, onChange]);
const [time, unit] = match ? [parseInt(match[1]), match[2]] : [10, 'm'];
const TimeUnits = (
<Select value={unit} onChange={(unit) => onChange(`${time}${unit}`)} style={{ width: 120 }}>
<Option value="m">{t('Minutes')}</Option>
<Option value="h">{t('Hours')}</Option>
<Option value="d">{t('Days')}</Option>
</Select>
);
return (
<InputNumber
value={time}
addonAfter={TimeUnits}
min={minNum}
onChange={(time) => onChange(`${time ?? 1}${unit}`)}
{...restProps}
/>
);
},
mapProps({
onInput: 'onChange',
}),
);
export const componentsNameMap = {
InputTime: 'InputTime',
};
export const componentsMap = {
[componentsNameMap.InputTime]: InputTime,
};

View File

@ -0,0 +1,61 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useMemo, useEffect } from 'react';
import { App as AntdApp } from 'antd';
import { useForm } from '@formily/react';
import { createForm } from '@formily/core';
import { useAPIClient } from '@nocobase/client';
import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../../../constants';
import { useAuthTranslation } from '../../locale';
const useEditForm = () => {
const apiClient = useAPIClient();
const form = useMemo(() => createForm(), []);
useEffect(() => {
const fetch = async () => {
try {
const { data } = await apiClient.resource(tokenPolicyCollectionName).get({ filterByTk: tokenPolicyRecordKey });
if (data?.data?.config) form.setValues(data.data.config);
} catch (error) {
console.error(error);
}
};
fetch();
}, [form, apiClient]);
return { form };
};
export const useSubmitActionProps = () => {
const { message } = AntdApp.useApp();
const apiClient = useAPIClient();
const form = useForm();
const { t } = useAuthTranslation();
return {
type: 'primary',
async onClick() {
await form.submit();
const res = await apiClient.resource(tokenPolicyCollectionName).update({
values: { config: form.values },
filterByTk: tokenPolicyRecordKey,
});
if (res && res.status === 200) message.success(t('Saved successfully!'));
},
};
};
export const hooksNameMap = {
useSubmitActionProps: 'useSubmitActionProps',
useEditForm: 'useEditForm',
};
export const hooksMap = {
[hooksNameMap.useEditForm]: useEditForm,
[hooksNameMap.useSubmitActionProps]: useSubmitActionProps,
};

View File

@ -0,0 +1,86 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { ISchema, SchemaComponent } from '@nocobase/client';
import { Card } from 'antd';
import { uid } from '@formily/shared';
import { tval } from '@nocobase/utils/client';
import { useAuthTranslation } from '../../locale';
import { hooksNameMap, hooksMap } from './hooks';
import { componentsMap, componentsNameMap } from './components';
import { TokenPolicyConfig } from '../../../types';
type Properties = {
[K in keyof TokenPolicyConfig | 'footer']: any;
};
const schema: ISchema & { properties: Properties } = {
name: uid(),
'x-component': 'FormV2',
'x-use-component-props': hooksNameMap.useEditForm,
type: 'object',
properties: {
sessionExpirationTime: {
type: 'string',
title: "{{t('Session validity')}}",
'x-decorator': 'FormItem',
'x-component': componentsNameMap.InputTime,
required: true,
description: tval(
'The maximum valid time for each user login. During the session validity, the Token will be automatically updated. After the timeout, the user is required to log in again.',
),
},
tokenExpirationTime: {
type: 'string',
title: "{{t('Token validity period')}}",
'x-decorator': 'FormItem',
'x-component': componentsNameMap.InputTime,
required: true,
description: tval(
'The validity period of each issued API Token. After the Token expires, if it is within the session validity period and has not exceeded the refresh limit, the server will automatically issue a new Token to maintain the user session, otherwise the user is required to log in again. (Each Token can only be refreshed once)',
),
},
expiredTokenRenewLimit: {
type: 'string',
title: "{{t('Expired token refresh limit')}}",
'x-decorator': 'FormItem',
'x-component': componentsNameMap.InputTime,
'x-component-props': {
minNum: 0,
},
required: true,
description: tval(
'The maximum time limit allowed for refreshing a Token after it expires. After this time limit, the token cannot be automatically renewed, and the user needs to log in again.',
),
},
footer: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
},
properties: {
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-use-component-props': hooksNameMap.useSubmitActionProps,
},
},
},
},
};
export const TokenPolicySettings = () => {
const { t } = useAuthTranslation();
return (
<Card bordered={false}>
<SchemaComponent schema={schema} scope={{ t, ...hooksMap }} components={componentsMap}></SchemaComponent>
</Card>
);
};

View File

@ -0,0 +1,13 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const tokenPolicyRecordKey = 'token-policy-config';
export const tokenPolicyCacheKey = 'auth:' + tokenPolicyRecordKey;
export const tokenPolicyCollectionName = 'tokenControlConfig';
export const issuedTokensCollectionName = 'issuedTokens';

View File

@ -29,5 +29,21 @@
"Sign up settings": "Sign up settings",
"Sign up form": "Sign up form",
"At least one of the username or email fields is required": "At least one of the username or email fields is required",
"Password is not allowed to be changed": "Password is not allowed to be changed"
"Password is not allowed to be changed": "Password is not allowed to be changed",
"Token policy": "Token policy",
"Token validity period": "Token validity period",
"Session validity": "Session validity",
"Expired token refresh limit": "Expired token refresh limit",
"Enable operation timeout control": "Enable operation timeout control",
"Seconds": "Seconds",
"Minutes": "Minutes",
"Hours": "Hours",
"Days": "Days",
"Saved successfully!": "Saved successfully!",
"The maximum valid time for each user login. During the session validity, the Token will be automatically updated. After the timeout, the user is required to log in again.": "The maximum valid time for each user login. During the session validity, the Token will be automatically updated. After the timeout, the user is required to log in again.",
"The validity period of each issued API Token. After the Token expires, if it is within the session validity period and has not exceeded the refresh limit, the server will automatically issue a new Token to maintain the user session, otherwise the user is required to log in again. (Each Token can only be refreshed once)": "The validity period of each issued API Token. After the Token expires, if it is within the session validity period and has not exceeded the refresh limit, the server will automatically issue a new Token to maintain the user session, otherwise the user is required to log in again. (Each Token can only be refreshed once)",
"The maximum time limit allowed for refreshing a Token after it expires. After this time limit, the token cannot be automatically renewed, and the user needs to log in again.": "The maximum time limit allowed for refreshing a Token after it expires. After this time limit, the token cannot be automatically renewed, and the user needs to log in again.",
"In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect.": "In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect.",
"Your session has expired. Please sign in again.": "Your session has expired. Please sign in again.",
"Unauthenticated. Please sign in to continue.": "Unauthenticated. Please sign in to continue."
}

View File

@ -29,5 +29,20 @@
"Sign up settings": "注册设置",
"Sign up form": "注册表单",
"At least one of the username or email fields is required": "至少需要设置用户名或邮箱中的一个字段为必填字段",
"Password is not allowed to be changed": "密码不允许修改"
"Password is not allowed to be changed": "密码不允许修改",
"Token policy": "Token 策略",
"Token validity period": "Token 有效周期",
"Session validity": "会话有效期",
"Expired token refresh limit": "过期 Token 刷新时限",
"Enable operation timeout control": "启用操作超时控制",
"Seconds": "秒",
"Minutes": "分钟",
"Hours": "小时",
"Days": "天",
"Saved successfully!": "保存成功!",
"The maximum valid time for each user login. During the session validity, the Token will be automatically updated. After the timeout, the user is required to log in again.": "用户每次登录的最长有效时间在会话有效期内Token 会自动更新,超时后要求用户重新登录。",
"The validity period of each issued API Token. After the Token expires, if it is within the session validity period and has not exceeded the refresh limit, the server will automatically issue a new Token to maintain the user session, otherwise the user is required to log in again. (Each Token can only be refreshed once)": "每次签发的 API Token 的有效期。Token 过期后,如果处于会话有效期内,并且没有超过刷新时限,服务端将自动签发新 Token 以保持用户会话,否则要求用户重新登录。(每个 Token 只能被刷新一次)",
"The maximum time limit allowed for refreshing a Token after it expires. After this time limit, the token cannot be automatically renewed, and the user needs to log in again.": "Token 过期后允许刷新的最大时限超过此时限后Token 无法自动更新,用户需重新登录。",
"Your session has expired. Please sign in again.": "您的会话已过期,请重新登录。",
"Unauthenticated. Please sign in to continue.": "未认证。请登录以继续。"
}

View File

@ -222,7 +222,7 @@ describe('actions', () => {
password: '12345',
},
});
const userAgent = await agent.login(user);
const userAgent = await agent.loginWithJti(user, null);
// Should check password consistency
const res = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({
@ -442,7 +442,7 @@ describe('actions', () => {
password: '12345',
},
});
const userAgent = await agent.login(user);
const userAgent = await agent.loginWithJti(user, null);
const res = await userAgent.post('/auth:check').set({ 'X-Authenticator': 'basic' }).send();
expect(res.statusCode).toEqual(200);
expect(res.body.data.id).toBeDefined();
@ -453,8 +453,8 @@ describe('actions', () => {
});
expect(res2.statusCode).toEqual(200);
const res3 = await userAgent.post('/auth:check').set({ 'X-Authenticator': 'basic' }).send();
expect(res3.statusCode).toEqual(200);
expect(res3.body.data.id).toBeUndefined();
expect(res3.statusCode).toEqual(401);
expect(res3.text).toBe('User password changed, please signin again.');
});
});
});

View File

@ -0,0 +1,181 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { AuthErrorCode, BaseAuth } from '@nocobase/auth';
import { Database, Model } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
import { AuthErrorType } from '@nocobase/auth';
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
class MockContext {
token: string;
header: Map<string, string> = new Map();
public app: MockServer;
constructor({ app }: { app: MockServer }) {
this.app = app;
}
res = {
setHeader: (key: string, value: string) => {
this.header.set(key, value);
},
getHeader: (key: string) => {
return this.header.get(key);
},
};
t = (s) => s;
setToken(token: string) {
this.token = token;
}
getBearerToken() {
return this.token;
}
throw(status, errData) {
throw new Error(errData.code);
}
cache = {
wrap: async (key, fn) => fn(),
};
}
describe('auth', () => {
let auth: BaseAuth;
let app: MockServer;
let db: Database;
let user: Model;
let ctx: MockContext;
beforeEach(async () => {
if (process.env.CACHE_REDIS_URL) {
app = await createMockServer({
cacheManager: {
defaultStore: 'redis',
stores: {
redis: {
url: process.env.CACHE_REDIS_URL,
},
},
},
plugins: ['field-sort', 'users', 'auth'],
});
} else {
app = await createMockServer({
plugins: ['field-sort', 'users', 'auth'],
});
}
db = app.db;
app.authManager.setTokenBlacklistService({
has: async () => false,
add: async () => true,
});
user = await db.getRepository('users').create({
values: {
username: 'admin',
},
});
class MockBaseAuth extends BaseAuth {
async validate() {
return user;
}
}
ctx = new MockContext({ app });
auth = new MockBaseAuth({
userCollection: db.getCollection('users'),
ctx,
} as any);
await auth.tokenController.setConfig({
tokenExpirationTime: '1d',
sessionExpirationTime: '1d',
expiredTokenRenewLimit: '1d',
});
await app.cache.reset();
});
afterEach(async () => {
await app.cache.reset();
await app.destroy();
});
it('when token expired and login valid, it generate a new token', async () => {
await auth.tokenController.setConfig({
tokenExpirationTime: '1s',
sessionExpirationTime: '1d',
expiredTokenRenewLimit: '1d',
});
const { token } = await auth.signIn();
ctx.setToken(token);
await sleep(3000);
await auth.check();
expect(typeof ctx.res.getHeader('x-new-token')).toBe('string');
});
it('when exceed logintime, throw Unauthorized', async () => {
await auth.tokenController.setConfig({
tokenExpirationTime: '1s',
sessionExpirationTime: '2s',
expiredTokenRenewLimit: '1d',
});
const { token } = await auth.signIn();
ctx.setToken(token);
await sleep(3000);
await expect(auth.check()).rejects.toThrowError('EXPIRED_SESSION' satisfies AuthErrorType);
});
it('when exceed inactiveInterval, throw Unauthorized', async () => {
await auth.tokenController.setConfig({
tokenExpirationTime: '1s',
sessionExpirationTime: '1d',
expiredTokenRenewLimit: '1s',
});
const { token } = await auth.signIn();
ctx.setToken(token);
await sleep(3000);
await expect(auth.check()).rejects.toThrowError('EXPIRED_SESSION' satisfies AuthErrorType);
});
it('when token expired but not refresh, not throw error', async () => {
await auth.tokenController.setConfig({
tokenExpirationTime: '1s',
sessionExpirationTime: '1d',
expiredTokenRenewLimit: '1d',
});
const { token } = await auth.signIn();
ctx.setToken(token);
await sleep(3000);
const checkedUser = await auth.check();
expect(checkedUser.id).toEqual(user.id);
});
it('when call renew token with same jti multiple times, only one resolved', async () => {
const tokenInfo = await auth.tokenController.add({ userId: 1 });
const renewTasks = Array(15)
.fill(null)
.map(() => auth.tokenController.renew(tokenInfo.jti));
const allSettled = await Promise.allSettled(renewTasks);
const successTasks = allSettled.filter((result) => result.status === 'fulfilled');
expect(successTasks).toHaveLength(1);
const failedTasks = allSettled.filter(
(result) => result.status === 'rejected' && result.reason.code === AuthErrorCode.TOKEN_RENEW_FAILED,
);
expect(failedTasks).toHaveLength(14);
});
it('use token policy tokenExpirationTime as token expirein', async () => {
const config = await auth.tokenController.getConfig();
const { token } = await auth.signIn();
const decoded = await auth.jwt.decode(token);
expect(decoded.exp - decoded.iat).toBe(Math.floor(config.tokenExpirationTime / 1000));
expect(decoded.signInTime).toBeTruthy();
});
});

View File

@ -0,0 +1,125 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { BaseAuth } from '@nocobase/auth';
import { Database, Model } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
import ms from 'ms';
import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../../constants';
class MockContext {
token: string;
header: Map<string, string> = new Map();
public app: MockServer;
constructor({ app }: { app: MockServer }) {
this.app = app;
}
res = {
setHeader: (key: string, value: string) => {
this.header.set(key, value);
},
getHeader: (key: string) => {
return this.header.get(key);
},
};
t = (s) => s;
setToken(token: string) {
this.token = token;
}
getBearerToken() {
return this.token;
}
throw(status, errData) {
throw new Error(errData.code);
}
cache = {
wrap: async (key, fn) => fn(),
};
}
describe('auth', () => {
let auth: BaseAuth;
let app: MockServer;
let db: Database;
let user: Model;
let ctx: MockContext;
let adminAgent;
beforeEach(async () => {
if (process.env.CACHE_REDIS_URL) {
app = await createMockServer({
cacheManager: {
defaultStore: 'redis',
stores: {
redis: {
url: process.env.CACHE_REDIS_URL,
},
},
},
plugins: ['field-sort', 'users', 'auth'],
});
} else {
app = await createMockServer({
plugins: ['field-sort', 'users', 'auth'],
});
}
db = app.db;
app.authManager.setTokenBlacklistService({
has: async () => false,
add: async () => true,
});
user = await db.getRepository('users').create({
values: {
username: 'admin',
},
});
adminAgent = await app.agent().loginWithJti(user, 'admin');
class MockBaseAuth extends BaseAuth {
async validate() {
return user;
}
}
ctx = new MockContext({ app });
auth = new MockBaseAuth({
userCollection: db.getCollection('users'),
ctx,
} as any);
});
afterEach(async () => {
await app.cache.reset();
await app.destroy();
});
it('token policy has a default poilcy', async () => {
const config = await auth.tokenController.getConfig();
expect(typeof config.expiredTokenRenewLimit).toBe('number');
expect(typeof config.sessionExpirationTime).toBe('number');
expect(typeof config.tokenExpirationTime).toBe('number');
});
it('This test verifies that when the token policy configuration changes, the cache is automatically synchronized.', async () => {
await adminAgent.resource(tokenPolicyCollectionName).update({
values: {
config: {
tokenExpirationTime: '5h',
sessionExpirationTime: '2h',
expiredTokenRenewLimit: '3h',
},
},
filterByTk: tokenPolicyRecordKey,
});
const config = await auth.tokenController.getConfig();
expect(config.tokenExpirationTime).toBe(ms('5h'));
expect(config.sessionExpirationTime).toBe(ms('2h'));
expect(config.expiredTokenRenewLimit).toBe(ms('3h'));
});
});

View File

@ -0,0 +1,49 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { defineCollection } from '@nocobase/database';
import { issuedTokensCollectionName } from '../../constants';
export default defineCollection({
name: issuedTokensCollectionName,
autoGenId: false,
createdAt: true,
updatedAt: true,
fields: [
{
name: 'id',
type: 'uuid',
primaryKey: true,
allowNull: false,
interface: 'input',
},
{
type: 'bigInt',
name: 'signInTime',
allowNull: false,
},
{
name: 'jti',
type: 'uuid',
allowNull: false,
index: true,
},
{
type: 'bigInt',
name: 'issuedTime',
allowNull: false,
},
{
type: 'bigInt',
name: 'userId',
allowNull: false,
},
],
});

View File

@ -0,0 +1,36 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { defineCollection } from '@nocobase/database';
import { tokenPolicyCollectionName } from '../../constants';
export default defineCollection({
name: tokenPolicyCollectionName,
autoGenId: false,
createdAt: true,
createdBy: true,
updatedAt: true,
updatedBy: true,
fields: [
{
name: 'key',
type: 'string',
primaryKey: true,
allowNull: false,
interface: 'input',
},
{
type: 'json',
name: 'config',
allowNull: false,
defaultValue: {},
},
],
});

View File

@ -12,3 +12,4 @@ export { AuthModel } from './model/authenticator';
export { presetAuthType } from '../preset';
export { default } from './plugin';
export * from '../constants';

View File

@ -0,0 +1,36 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Migration } from '@nocobase/server';
import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../../constants';
export default class extends Migration {
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
appVersion = '<1.6.1';
async up() {
const tokenPolicyRepo = this.app.db.getRepository(tokenPolicyCollectionName);
const tokenPolicy = await tokenPolicyRepo.findOne({ filterByTk: tokenPolicyRecordKey });
if (tokenPolicy) {
this.app.authManager.tokenController.setConfig(tokenPolicy.config);
} else {
const config = {
tokenExpirationTime: '1d',
sessionExpirationTime: '7d',
expiredTokenRenewLimit: '1d',
};
await tokenPolicyRepo.create({
values: {
key: tokenPolicyRecordKey,
config,
},
});
this.app.authManager.tokenController.setConfig(config);
}
}
}

View File

@ -18,11 +18,35 @@ import { BasicAuth } from './basic-auth';
import { AuthModel } from './model/authenticator';
import { Storer } from './storer';
import { TokenBlacklistService } from './token-blacklist';
import { TokenController } from './token-controller';
import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../constants';
export class PluginAuthServer extends Plugin {
cache: Cache;
afterAdd() {}
afterAdd() {
this.app.on('afterLoad', async () => {
if (this.app.authManager.tokenController) {
return;
}
const cache = await this.app.cacheManager.createCache({
name: 'auth-token-controller',
prefix: 'auth-token-controller',
});
const tokenController = new TokenController({ cache, app: this.app, logger: this.app.log });
this.app.authManager.setTokenControlService(tokenController);
const tokenPolicyRepo = this.app.db.getRepository(tokenPolicyCollectionName);
try {
const res = await tokenPolicyRepo.findOne({ filterByTk: tokenPolicyRecordKey });
if (res) {
this.app.authManager.tokenController.setConfig(res.config);
}
} catch (error) {
this.app.logger.warn('access control config not exist, use default value');
}
});
}
async beforeLoad() {
this.app.db.registerModels({ AuthModel });
@ -93,8 +117,8 @@ export class PluginAuthServer extends Plugin {
this.app.resourceManager.registerActionHandler(`authenticators:${action}`, handler),
);
// Set up ACL
['check', 'signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action));
['signOut', 'changePassword'].forEach((action) => this.app.acl.allow('auth', action, 'loggedIn'));
['signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action));
['check', 'signOut', 'changePassword'].forEach((action) => this.app.acl.allow('auth', action, 'loggedIn'));
this.app.acl.allow('authenticators', 'publicList');
this.app.acl.registerSnippet({
name: `pm.${this.name}.authenticators`,
@ -131,15 +155,18 @@ export class PluginAuthServer extends Plugin {
logger: this.app.logger,
} as any);
const user = await auth.check();
if (!user) {
this.app.logger.error(`Invalid token: ${payload.token}`);
this.app.emit(`ws:removeTag`, {
clientId,
tagKey: 'userId',
});
return;
let user: Model;
try {
user = await auth.check();
} catch (error) {
if (!user) {
this.app.logger.error(error);
this.app.emit(`ws:removeTag`, {
clientId,
tagKey: 'userId',
});
return;
}
}
this.app.emit(`ws:setTag`, {
@ -242,29 +269,53 @@ export class PluginAuthServer extends Plugin {
},
'auth:signOut',
]);
this.app.acl.registerSnippet({
name: `pm.security.token-policy`,
actions: [`${tokenPolicyCollectionName}:*`],
});
this.app.db.on(`${tokenPolicyCollectionName}.afterSave`, async (model) => {
this.app.authManager.tokenController?.setConfig(model.config);
});
}
async install(options?: InstallOptions) {
const repository = this.db.getRepository('authenticators');
const exist = await repository.findOne({ filter: { name: presetAuthenticator } });
if (exist) {
return;
}
await repository.create({
values: {
name: presetAuthenticator,
authType: presetAuthType,
description: 'Sign in with username/email.',
enabled: true,
options: {
public: {
allowSignUp: true,
const authRepository = this.db.getRepository('authenticators');
const exist = await authRepository.findOne({ filter: { name: presetAuthenticator } });
if (!exist) {
await authRepository.create({
values: {
name: presetAuthenticator,
authType: presetAuthType,
description: 'Sign in with username/email.',
enabled: true,
options: {
public: {
allowSignUp: true,
},
},
},
});
}
const tokenPolicyRepo = this.app.db.getRepository(tokenPolicyCollectionName);
const res = await tokenPolicyRepo.findOne({ filterByTk: tokenPolicyRecordKey });
if (res) {
return;
}
const config = {
tokenExpirationTime: '1d',
sessionExpirationTime: '7d',
expiredTokenRenewLimit: '1d',
};
await tokenPolicyRepo.create({
values: {
key: tokenPolicyRecordKey,
config,
},
});
}
async remove() {}
}

View File

@ -0,0 +1,152 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
ITokenControlService,
TokenPolicyConfig,
NumericTokenPolicyConfig,
AuthError,
AuthErrorCode,
TokenInfo,
} from '@nocobase/auth';
import type { SystemLogger } from '@nocobase/logger';
import { Cache } from '@nocobase/cache';
import { randomUUID } from 'crypto';
import ms from 'ms';
import Application from '@nocobase/server';
import Database, { Repository } from '@nocobase/database';
import { issuedTokensCollectionName, tokenPolicyCollectionName, tokenPolicyRecordKey } from '../constants';
type TokenControlService = ITokenControlService;
const JTICACHEKEY = 'token-jti';
export class TokenController implements TokenControlService {
cache: Cache;
app: Application;
db: Database;
logger: SystemLogger;
constructor({ cache, app, logger }: { cache: Cache; app: Application; logger: SystemLogger }) {
this.cache = cache;
this.app = app;
this.logger = logger;
}
async setTokenInfo(id: string, value: TokenInfo): Promise<void> {
const repo = this.app.db.getRepository<Repository<TokenInfo>>(issuedTokensCollectionName);
await repo.updateOrCreate({ filterKeys: ['id'], values: value });
await this.cache.set(`${JTICACHEKEY}:${id}`, value);
return;
}
getConfig() {
return this.cache.wrap<NumericTokenPolicyConfig>('config', async () => {
const repo = this.app.db.getRepository(tokenPolicyCollectionName);
const configRecord = await repo.findOne({ filterByTk: tokenPolicyRecordKey });
if (!configRecord) return null;
const config = configRecord.config as TokenPolicyConfig;
return {
tokenExpirationTime: ms(config.tokenExpirationTime),
sessionExpirationTime: ms(config.sessionExpirationTime),
expiredTokenRenewLimit: ms(config.expiredTokenRenewLimit),
};
});
}
setConfig(config: TokenPolicyConfig) {
return this.cache.set('config', {
tokenExpirationTime: ms(config.tokenExpirationTime),
sessionExpirationTime: ms(config.sessionExpirationTime),
expiredTokenRenewLimit: ms(config.expiredTokenRenewLimit),
});
}
async removeSessionExpiredTokens(userId: number) {
const config = await this.getConfig();
const issuedTokenRepo = this.app.db.getRepository(issuedTokensCollectionName);
const currTS = Date.now();
return issuedTokenRepo.destroy({
filter: {
userId: userId,
signInTime: {
$lt: currTS - config.sessionExpirationTime,
},
},
});
}
async add({ userId }: { userId: number }) {
const jti = randomUUID();
const currTS = Date.now();
const data = {
jti,
issuedTime: currTS,
signInTime: currTS,
renewed: false,
userId,
};
await this.setTokenInfo(jti, data);
try {
if (process.env.DB_DIALECT === 'sqlite') {
// SQLITE does not support concurrent operations
await this.removeSessionExpiredTokens(userId);
} else {
this.removeSessionExpiredTokens(userId);
}
} catch (err) {
this.logger.error(err, { module: 'auth', submodule: 'token-controller', method: 'removeSessionExpiredTokens' });
}
return data;
}
renew: TokenControlService['renew'] = async (jti) => {
const repo = this.app.db.getRepository(issuedTokensCollectionName);
const model = this.app.db.getModel(issuedTokensCollectionName);
const exists = await repo.findOne({ filter: { jti } });
if (!exists) {
this.logger.error('jti not found', {
module: 'auth',
submodule: 'token-controller',
method: 'renew',
jti,
code: AuthErrorCode.TOKEN_RENEW_FAILED,
});
throw new AuthError({
message: 'Your session has expired. Please sign in again.',
code: AuthErrorCode.TOKEN_RENEW_FAILED,
});
}
const newId = randomUUID();
const issuedTime = Date.now();
const [count] = await model.update(
{ jti: newId, issuedTime },
{ where: { jti } },
);
if (count === 1) {
return { jti: newId, issuedTime };
} else {
this.logger.error('jti renew failed', {
module: 'auth',
submodule: 'token-controller',
method: 'renew',
jti,
code: AuthErrorCode.TOKEN_RENEW_FAILED,
});
throw new AuthError({
message: 'Your session has expired. Please sign in again.',
code: AuthErrorCode.TOKEN_RENEW_FAILED,
});
}
};
}

View File

@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export type { TokenPolicyConfig as TokenPolicyConfig } from '@nocobase/auth';

View File

@ -10,12 +10,13 @@
import { MockServer, waitSecond } from '@nocobase/test';
import { Dumper } from '../dumper';
import createApp from './index';
let adminAgent;
describe('backup files', () => {
let app: MockServer;
beforeEach(async () => {
app = await createApp();
adminAgent = app.agent().login(1);
});
afterEach(async () => {
@ -23,12 +24,9 @@ describe('backup files', () => {
});
it('should create dump file', async () => {
const createResponse = await app
.agent()
.resource('backupFiles')
.create({
dataTypes: ['meta', 'config', 'business'],
});
const createResponse = await adminAgent.resource('backupFiles').create({
dataTypes: ['meta', 'config', 'business'],
});
expect(createResponse.status).toBe(200);
@ -61,7 +59,7 @@ describe('backup files', () => {
await waitSecond(1000);
const fileName = Dumper.generateFileName();
await dumper.writeLockFile(fileName);
const listResponse = await app.agent().resource('backupFiles').list();
const listResponse = await adminAgent.resource('backupFiles').list();
expect(listResponse.status).toBe(200);
@ -72,7 +70,7 @@ describe('backup files', () => {
});
it('should list backup file', async () => {
const listResponse = await app.agent().resource('backupFiles').list();
const listResponse = await adminAgent.resource('backupFiles').list();
expect(listResponse.status).toBe(200);
@ -83,7 +81,7 @@ describe('backup files', () => {
});
it('should get backup file', async () => {
const getResponse = await app.agent().resource('backupFiles').get({
const getResponse = await adminAgent.resource('backupFiles').get({
filterByTk: dumpKey,
});
@ -95,27 +93,24 @@ describe('backup files', () => {
});
it('should restore from file name', async () => {
const restoreResponse = await app
.agent()
.resource('backupFiles')
.restore({
values: {
filterByTk: dumpKey,
dataTypes: ['meta', 'config', 'business'],
},
});
const restoreResponse = await adminAgent.resource('backupFiles').restore({
values: {
filterByTk: dumpKey,
dataTypes: ['meta', 'config', 'business'],
},
});
expect(restoreResponse.status).toBe(200);
});
it('should destroy dump file', async () => {
const destroyResponse = await app.agent().resource('backupFiles').destroy({
const destroyResponse = await adminAgent.resource('backupFiles').destroy({
filterByTk: dumpKey,
});
expect(destroyResponse.status).toBe(200);
const getResponse = await app.agent().resource('backupFiles').get({
const getResponse = await adminAgent.resource('backupFiles').get({
filterByTk: dumpKey,
});
@ -124,7 +119,7 @@ describe('backup files', () => {
it('should restore from upload file', async () => {
const filePath = dumper.backUpFilePath(dumpKey);
const packageInfoResponse = await app.agent().post('/backupFiles:upload').attach('file', filePath);
const packageInfoResponse = await adminAgent.post('/backupFiles:upload').attach('file', filePath);
expect(packageInfoResponse.status).toBe(200);
const data = packageInfoResponse.body.data;
@ -132,15 +127,12 @@ describe('backup files', () => {
expect(data['key']).toBeTruthy();
expect(data['meta']).toBeTruthy();
const restoreResponse = await app
.agent()
.resource('backupFiles')
.restore({
values: {
key: data['key'],
dataTypes: ['meta', 'config', 'business'],
},
});
const restoreResponse = await adminAgent.resource('backupFiles').restore({
values: {
key: data['key'],
dataTypes: ['meta', 'config', 'business'],
},
});
expect(restoreResponse.status).toBe(200);
});
@ -162,7 +154,7 @@ describe('backup files', () => {
context: {},
});
const response = await app.agent().get('/backupFiles:dumpableCollections');
const response = await adminAgent.get('/backupFiles:dumpableCollections');
expect(response.status).toBe(200);

View File

@ -31,6 +31,7 @@ describe('workflow > action-trigger', () => {
beforeEach(async () => {
app = await getApp({
plugins: ['users', 'auth', 'acl', 'data-source-manager', 'system-settings', Plugin],
acl: true,
});
await app.pm.get('auth').install();
agent = app.agent();

View File

@ -523,7 +523,7 @@ describe('workflow > instructions > request', () => {
const token = jwt.sign(
{
userId: typeof user.id,
userId: user.id,
},
process.env.APP_KEY,
{