feat: security (#5923)

* feat: password policy

* feat: password validator

* fix(inbox): i18n of channel title

* feat: atomic counter

* fix: bug

* fix: bloom

* chore: i18n

* fix: counter

* fix: build

* fix: bug

* fix: z-index

* fix: export

* test: add redis cache test

* fix: test

* fix: test

* fix: test

* fix: bug

* fix: form reset

* fix: locale

* fix: version

* fix: separate cache for sub apps

* chore: update
This commit is contained in:
YANG QIA 2024-12-29 08:33:27 +08:00 committed by GitHub
parent bee085710d
commit 84cfa82304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 633 additions and 45 deletions

View File

@ -46,7 +46,7 @@ jobs:
container: node:${{ matrix.node_version }} container: node:${{ matrix.node_version }}
services: services:
redis: redis:
image: redis:latest image: redis/redis-stack-server:latest
ports: ports:
- 6379:6379 - 6379:6379
steps: steps:
@ -75,6 +75,13 @@ jobs:
underscored: [true, false] underscored: [true, false]
schema: [public, nocobase] schema: [public, nocobase]
collection_schema: [public, user_schema] collection_schema: [public, user_schema]
cache_redis: ['']
include:
- node_version: 20
underscored: true
schema: public
collection_schema: public
cache_redis: redis://redis:6379/0
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:${{ matrix.node_version }} container: node:${{ matrix.node_version }}
services: services:
@ -93,7 +100,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
redis: redis:
image: redis:latest image: redis/redis-stack-server:latest
ports: ports:
- 6379:6379 - 6379:6379
steps: steps:
@ -125,6 +132,7 @@ jobs:
DB_TEST_DISTRIBUTOR_PORT: 23450 DB_TEST_DISTRIBUTOR_PORT: 23450
DB_TEST_PREFIX: test DB_TEST_PREFIX: test
ENCRYPTION_FIELD_KEY: 1%&glK;<UA}aIxJVc53-4G(rTi0vg@J] ENCRYPTION_FIELD_KEY: 1%&glK;<UA}aIxJVc53-4G(rTi0vg@J]
CACHE_REDIS_URL: ${{ matrix.cache_redis }}
timeout-minutes: 60 timeout-minutes: 60
mysql-test: mysql-test:
@ -142,7 +150,7 @@ jobs:
MYSQL_DATABASE: nocobase MYSQL_DATABASE: nocobase
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis: redis:
image: redis:latest image: redis/redis-stack-server:latest
ports: ports:
- 6379:6379 - 6379:6379
steps: steps:
@ -188,7 +196,7 @@ jobs:
MARIADB_DATABASE: nocobase MARIADB_DATABASE: nocobase
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis: redis:
image: redis:latest image: redis/redis-stack-server:latest
ports: ports:
- 6379:6379 - 6379:6379
steps: steps:

View File

@ -103,7 +103,9 @@ export class BaseAuth extends Auth {
try { try {
user = await this.validate(); user = await this.validate();
} catch (err) { } catch (err) {
this.ctx.throw(err.status || 401, err.message); this.ctx.throw(err.status || 401, err.message, {
...err,
});
} }
if (!user) { if (!user) {
this.ctx.throw(401, 'Unauthorized'); this.ctx.throw(401, 'Unauthorized');

View File

@ -6,6 +6,7 @@
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/lock-manager": "1.6.0-alpha.6",
"bloom-filters": "^3.0.1", "bloom-filters": "^3.0.1",
"cache-manager": "^5.2.4", "cache-manager": "^5.2.4",
"cache-manager-redis-yet": "^4.1.2" "cache-manager-redis-yet": "^4.1.2"

View File

@ -41,3 +41,41 @@ describe('bloomFilter', () => {
expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy(); expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy();
}); });
}); });
// It is required to install redis stack server that includes redis bloom.
(process.env.CACHE_REDIS_URL ? describe : describe.skip)('bloomFilter with redis', () => {
let bloomFilter: BloomFilter;
let cacheManager: CacheManager;
beforeEach(async () => {
cacheManager = new CacheManager({
stores: {
redis: {
url: process.env.CACHE_REDIS_URL,
},
},
});
bloomFilter = await cacheManager.createBloomFilter({ store: 'redis' });
await bloomFilter.reserve('bloom-test', 0.01, 1000);
});
afterEach(async () => {
await cacheManager.flushAll();
});
it('should add and check', async () => {
await bloomFilter.add('bloom-test', 'hello');
expect(await bloomFilter.exists('bloom-test', 'hello')).toBeTruthy();
expect(await bloomFilter.exists('bloom-test', 'world')).toBeFalsy();
});
it('should mAdd and check', async () => {
await bloomFilter.mAdd('bloom-test', ['hello', 'world']);
expect(await bloomFilter.exists('bloom-test', 'hello')).toBeTruthy();
expect(await bloomFilter.exists('bloom-test', 'world')).toBeTruthy();
});
it('should return false if not reserved', async () => {
expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy();
});
});

View File

@ -0,0 +1,127 @@
/**
* 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 { LockManager } from '@nocobase/lock-manager';
import { CacheManager } from '../cache-manager';
import { Counter, LockCounter } from '../counter';
describe('memory counter', () => {
let counter: Counter;
let cacheManager: CacheManager;
beforeEach(async () => {
cacheManager = new CacheManager();
cacheManager.registerStore({ name: 'memory', store: 'memory' });
counter = await cacheManager.createCounter({ name: 'test-counter', store: 'memory' });
});
afterEach(async () => {
await cacheManager.flushAll();
});
test('incr, incrby, get, reset', async () => {
const res = await counter.incr('test-key-1');
expect(res).toBe(1);
const res2 = await counter.incr('test-key-1');
expect(res2).toBe(2);
const res3 = await counter.incrby('test-key-1', 3);
expect(res3).toBe(5);
expect(await counter.get('test-key-1')).toBe(5);
await counter.reset('test-key-1');
expect(await counter.get('test-key-1')).toBe(0);
});
test('atomic', async () => {
const promises = [];
for (let i = 0; i < 100; i++) {
promises.push(counter.incr('test-key-1'));
}
await Promise.all(promises);
expect(await counter.get('test-key-1')).toBe(100);
});
});
// It is required to install redis
(process.env.CACHE_REDIS_URL ? describe : describe.skip)('redis counter', () => {
let counter: Counter;
let cacheManager: CacheManager;
beforeEach(async () => {
cacheManager = new CacheManager({
stores: {
redis: {
url: process.env.CACHE_REDIS_URL,
},
},
});
counter = await cacheManager.createCounter({ name: 'test-counter', store: 'redis' });
});
afterEach(async () => {
await cacheManager.flushAll();
});
test('incr, incrby, get, reset', async () => {
const res = await counter.incr('test-key-1');
expect(res).toBe(1);
const res2 = await counter.incr('test-key-1');
expect(res2).toBe(2);
const res3 = await counter.incrby('test-key-1', 3);
expect(res3).toBe(5);
expect(await counter.get('test-key-1')).toBe(5);
await counter.reset('test-key-1');
expect(await counter.get('test-key-1')).toBe(0);
});
test('atomic', async () => {
const promises = [];
for (let i = 0; i < 100; i++) {
promises.push(counter.incr('test-key-1'));
}
await Promise.all(promises);
expect(await counter.get('test-key-1')).toBe(100);
});
});
describe('lock counter', () => {
let counter: Counter;
let cacheManager: CacheManager;
beforeEach(async () => {
cacheManager = new CacheManager();
const cache = await cacheManager.createCache({ name: 'memory', store: 'memory' });
const lockManager = new LockManager();
counter = new LockCounter(cache, lockManager);
});
afterEach(async () => {
await cacheManager.flushAll();
});
test('incr, incrby, get, reset', async () => {
const res = await counter.incr('test-key-1');
expect(res).toBe(1);
const res2 = await counter.incr('test-key-1');
expect(res2).toBe(2);
const res3 = await counter.incrby('test-key-1', 3);
expect(res3).toBe(5);
expect(await counter.get('test-key-1')).toBe(5);
await counter.reset('test-key-1');
expect(await counter.get('test-key-1')).toBe(0);
});
test('atomic', async () => {
const promises = [];
for (let i = 0; i < 100; i++) {
promises.push(counter.incr('test-key-1'));
}
await Promise.all(promises);
expect(await counter.get('test-key-1')).toBe(100);
});
});

View File

@ -21,27 +21,30 @@ export class RedisBloomFilter implements BloomFilter {
this.cache = cache; this.cache = cache;
} }
getStore() { private get store() {
return this.cache.store.store as RedisStore; return this.cache.store.store as RedisStore;
} }
async reserve(key: string, errorRate: number, capacity: number) { async reserve(key: string, errorRate: number, capacity: number) {
const store = this.getStore(); try {
await store.client.bf.reserve(key, errorRate, capacity); await this.store.client.bf.reserve(key, errorRate, capacity);
} catch (error) {
if (error.message.includes('ERR item exists')) {
return;
}
throw error;
}
} }
async add(key: string, value: string) { async add(key: string, value: string) {
const store = this.getStore(); await this.store.client.bf.add(key, value);
await store.client.bf.add(key, value);
} }
async mAdd(key: string, values: string[]) { async mAdd(key: string, values: string[]) {
const store = this.getStore(); await this.store.client.bf.mAdd(key, values);
await store.client.bf.mAdd(key, values);
} }
async exists(key: string, value: string) { async exists(key: string, value: string) {
const store = this.getStore(); return this.store.client.bf.exists(key, value);
return await store.client.bf.exists(key, value);
} }
} }

View File

@ -15,6 +15,8 @@ import deepmerge from 'deepmerge';
import { MemoryBloomFilter } from './bloom-filter/memory-bloom-filter'; import { MemoryBloomFilter } from './bloom-filter/memory-bloom-filter';
import { BloomFilter } from './bloom-filter'; import { BloomFilter } from './bloom-filter';
import { RedisBloomFilter } from './bloom-filter/redis-bloom-filter'; import { RedisBloomFilter } from './bloom-filter/redis-bloom-filter';
import { Counter, MemoryCounter, RedisCounter, LockCounter } from './counter';
import { LockManager } from '@nocobase/lock-manager';
type StoreOptions = { type StoreOptions = {
store?: 'memory' | FactoryStore<Store, any>; store?: 'memory' | FactoryStore<Store, any>;
@ -28,10 +30,12 @@ export type CacheManagerOptions = Partial<{
stores: { stores: {
[storeType: string]: StoreOptions; [storeType: string]: StoreOptions;
}; };
prefix: string;
}>; }>;
export class CacheManager { export class CacheManager {
defaultStore: string; defaultStore: string;
prefix?: string;
private stores = new Map< private stores = new Map<
string, string,
{ {
@ -69,8 +73,9 @@ export class CacheManager {
}, },
}; };
const cacheOptions = deepmerge(defaultOptions, options || {}); const cacheOptions = deepmerge(defaultOptions, options || {});
const { defaultStore = 'memory', stores } = cacheOptions; const { defaultStore = 'memory', stores, prefix } = cacheOptions;
this.defaultStore = defaultStore; this.defaultStore = defaultStore;
this.prefix = prefix;
for (const [name, store] of Object.entries(stores)) { for (const [name, store] of Object.entries(stores)) {
const { store: s, ...globalConfig } = store; const { store: s, ...globalConfig } = store;
this.registerStore({ name, store: s, ...globalConfig }); this.registerStore({ name, store: s, ...globalConfig });
@ -102,7 +107,9 @@ export class CacheManager {
} }
async createCache(options: { name: string; prefix?: string; store?: string; [key: string]: any }) { async createCache(options: { name: string; prefix?: string; store?: string; [key: string]: any }) {
const { name, prefix, store = this.defaultStore, ...config } = options; const { name, store = this.defaultStore, ...config } = options;
let { prefix } = options;
prefix = this.prefix ? (prefix ? `${this.prefix}:${prefix}` : this.prefix) : prefix;
if (!lodash.isEmpty(config) || store === 'memory') { if (!lodash.isEmpty(config) || store === 'memory') {
const newStore = await this.createStore({ name, storeType: store, ...config }); const newStore = await this.createStore({ name, storeType: store, ...config });
return this.newCache({ name, prefix, store: newStore }); return this.newCache({ name, prefix, store: newStore });
@ -161,4 +168,33 @@ export class CacheManager {
throw new Error(`BloomFilter store [${store}] is not supported`); throw new Error(`BloomFilter store [${store}] is not supported`);
} }
} }
/**
* @experimental
*/
async createCounter(
options: { name: string; prefix?: string; store?: string },
lockManager?: LockManager,
): Promise<Counter> {
const { store = this.defaultStore, name, prefix } = options || {};
let cache: Cache;
if (store !== 'memory') {
try {
cache = this.getCache(name);
} catch (error) {
cache = await this.createCache({ name, store, prefix });
}
}
switch (store) {
case 'memory':
return new MemoryCounter();
case 'redis':
return new RedisCounter(cache);
default:
if (!lockManager) {
throw new Error(`Counter store [${store}] is not supported`);
}
return new LockCounter(cache, lockManager);
}
}
} }

View File

@ -0,0 +1,25 @@
/**
* 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.
*/
/**
* @experimental
* atomic counter
*/
export interface Counter {
get(key: string): Promise<number>;
incr(key: string): Promise<number>;
incr(key: string, ttl: number): Promise<number>;
incrby(key: string, val: number): Promise<number>;
incrby(key: string, val: number, ttl: number): Promise<number>;
reset(key: string): Promise<void>;
}
export { MemoryCounter } from './memory-counter';
export { RedisCounter } from './redis-counter';
export { LockCounter } from './lock-counter';

View File

@ -0,0 +1,52 @@
/**
* 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 { LockManager } from '@nocobase/lock-manager';
import { Counter as ICounter } from '.';
import { Cache } from '../cache';
/**
* @experimental
*/
export class LockCounter implements ICounter {
cache: Cache;
lockManager: LockManager;
constructor(cache: Cache, lockManager: LockManager) {
this.cache = cache;
this.lockManager = lockManager;
}
async get(key: string) {
return ((await this.cache.get(key)) as number) || 0;
}
async incr(key: string, ttl?: number) {
return this.incrby(key, 1, ttl);
}
async incrby(key: string, value: number, ttl?: number) {
const lockKey = `lock:${key}`;
const release = await this.lockManager.acquire(lockKey, 3000);
try {
const v = (await this.cache.get(key)) as number;
const n = v || 0;
const newValue = n + value;
await this.cache.set(key, newValue, ttl);
return newValue;
} catch (error) {
throw error;
} finally {
await release();
}
}
async reset(key: string) {
return this.cache.del(key);
}
}

View File

@ -0,0 +1,74 @@
/**
* 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 { Counter as ICounter } from '.';
// Since the memory store of cache-manager only offers a promise-based API,
// we use a simple memory cache with a synchronous API for the atomic counter.
// The implementation is based on https://github.com/isaacs/node-lru-cache?tab=readme-ov-file#storage-bounds-safety
class Cache {
data = new Map();
timers = new Map();
set(k: string, v: any, ttl?: number) {
if (ttl) {
if (this.timers.has(k)) {
clearTimeout(this.timers.get(k));
}
this.timers.set(
k,
setTimeout(() => this.del(k), ttl),
);
}
this.data.set(k, v);
}
get(k: string) {
return this.data.get(k);
}
del(k: string) {
if (this.timers.has(k)) {
clearTimeout(this.timers.get(k));
}
this.timers.delete(k);
return this.data.delete(k);
}
}
/**
* @experimental
*/
export class MemoryCounter implements ICounter {
cache = new Cache();
async get(key: string) {
return this.cache.get(key) || 0;
}
async incr(key: string, ttl?: number) {
return this.incrby(key, 1, ttl);
}
async incrby(key: string, value: number, ttl?: number) {
const v = this.cache.get(key);
const n = v || 0;
const newValue = n + value;
if (!v) {
this.cache.set(key, newValue, ttl);
} else {
this.cache.set(key, newValue);
}
return newValue;
}
async reset(key: string) {
this.cache.del(key);
}
}

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 { RedisStore } from 'cache-manager-redis-yet';
import { Counter as ICounter } from '.';
import { Cache } from '../cache';
const script = `
local key = KEYS[1]
local value = tonumber(ARGV[1]) or 1
local ttl = tonumber(ARGV[2])
local current = redis.call('INCRBY', key, value)
if tonumber(current) == value and ttl then
redis.call('PEXPIRE', key, ttl)
end
return current
`;
/**
* @experimental
*/
export class RedisCounter implements ICounter {
cache: Cache;
scriptSha: string;
constructor(cache: Cache) {
this.cache = cache;
}
private get store() {
return this.cache.store.store as RedisStore;
}
async get(key: string) {
return ((await this.cache.get(key)) as number) || 0;
}
async incr(key: string, ttl?: number) {
return this.incrby(key, 1, ttl);
}
async incrby(key: string, value: number, ttl?: number) {
if (!this.scriptSha) {
this.scriptSha = await this.store.client.scriptLoad(script);
}
const result = await this.store.client.evalSha(this.scriptSha, {
keys: [this.cache.key(key)],
arguments: [value, ttl].map((v) => (v ? v.toString() : '')),
});
return Number(result);
}
async reset(key: string) {
return this.cache.del(key);
}
}

View File

@ -10,3 +10,4 @@
export * from './cache-manager'; export * from './cache-manager';
export * from './cache'; export * from './cache';
export * from './bloom-filter'; export * from './bloom-filter';
export * from './counter';

View File

@ -111,7 +111,7 @@ export class APIClient extends APIClientSDK {
this.auth.setRole(null); this.auth.setRole(null);
window.location.reload(); window.location.reload();
} }
if (errs.find((error: { code?: string }) => error.code === 'TOKEN_INVALID')) { if (errs.find((error: { code?: string }) => error.code === 'TOKEN_INVALID' || error.code === 'USER_LOCKED')) {
this.auth.setToken(null); this.auth.setToken(null);
} }
if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_FOR_USER')) { if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_FOR_USER')) {

View File

@ -889,7 +889,7 @@
"Data source": "数据源", "Data source": "数据源",
"DataSource": "数据源", "DataSource": "数据源",
"Data model": "数据模型", "Data model": "数据模型",
"Security": "认证与安全", "Security": "安全",
"Action": "操作", "Action": "操作",
"System": "系统管理", "System": "系统管理",
"Other": "其他", "Other": "其他",
@ -1036,8 +1036,8 @@
"Bulk enable": "批量激活", "Bulk enable": "批量激活",
"Search plugin...": "搜索插件...", "Search plugin...": "搜索插件...",
"Package name": "包名", "Package name": "包名",
"Associate":"关联", "Associate": "关联",
"Please add or select record":"请添加或选择数据", "Please add or select record": "请添加或选择数据",
"No data":"暂无数据", "No data": "暂无数据",
"Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用" "Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用"
} }

View File

@ -15,7 +15,6 @@ import { Button, Modal, Result, Spin } from 'antd';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { Navigate, useNavigate } from 'react-router-dom'; import { Navigate, useNavigate } from 'react-router-dom';
import { ACLPlugin } from '../acl'; import { ACLPlugin } from '../acl';
import { useAPIClient } from '../api-client';
import { Application } from '../application'; import { Application } from '../application';
import { Plugin } from '../application/Plugin'; import { Plugin } from '../application/Plugin';
import { BlockSchemaComponentPlugin } from '../block-provider'; import { BlockSchemaComponentPlugin } from '../block-provider';
@ -33,6 +32,7 @@ import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsPlugin } from '../system-settings'; import { SystemSettingsPlugin } from '../system-settings';
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user'; import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
import { LocalePlugin } from './plugins/LocalePlugin'; import { LocalePlugin } from './plugins/LocalePlugin';
import { tval } from '@nocobase/utils/client';
const AppSpin = () => { const AppSpin = () => {
return ( return (
@ -285,6 +285,11 @@ export class NocoBaseBuildInPlugin extends Plugin {
this.app.use(CurrentUserProvider); this.app.use(CurrentUserProvider);
this.app.use(CurrentUserSettingsMenuProvider); this.app.use(CurrentUserSettingsMenuProvider);
this.app.pluginSettingsManager.add('security', {
title: tval('Security'),
icon: 'SafetyOutlined',
});
} }
addRoutes() { addRoutes() {

View File

@ -32,12 +32,12 @@ export const useActionContext = () => {
async onOk() { async onOk() {
ctx.setFormValueChanged(false); ctx.setFormValueChanged(false);
ctx.setVisible?.(false); ctx.setVisible?.(false);
form?.reset?.();
}, },
}); });
} else { } else {
ctx.setVisible?.(false); ctx.setVisible?.(false);
} }
form?.reset?.();
} else { } else {
ctx.setVisible?.(visible); ctx.setVisible?.(visible);
} }

View File

@ -28,9 +28,7 @@ const useCloseAction = () => {
return { return {
async run() { async run() {
setVisible(false); setVisible(false);
form.submit((values) => { await form.reset();
console.log(values);
});
}, },
}; };
}; };
@ -60,7 +58,7 @@ const schema: ISchema = {
'x-decorator': 'Form', 'x-decorator': 'Form',
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
'x-component-props': { 'x-component-props': {
zIndex: 10000, zIndex: 2000,
}, },
type: 'void', type: 'void',
title: '{{t("Change password")}}', title: '{{t("Change password")}}',
@ -78,6 +76,7 @@ const schema: ISchema = {
required: true, required: true,
'x-component': 'Password', 'x-component': 'Password',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-validator': { password: true },
'x-component-props': { checkStrength: true, style: {} }, 'x-component-props': { checkStrength: true, style: {} },
'x-reactions': [ 'x-reactions': [
{ {

View File

@ -581,7 +581,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
} }
async createCacheManager() { async createCacheManager() {
this._cacheManager = await createCacheManager(this, this.options.cacheManager); this._cacheManager = await createCacheManager(this, {
prefix: this.name,
...this.options.cacheManager,
});
return this._cacheManager; return this._cacheManager;
} }

View File

@ -55,6 +55,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
title: '{{t("Password")}}', title: '{{t("Password")}}',
'x-component': 'Password', 'x-component': 'Password',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-validator': { password: true },
'x-component-props': { checkStrength: true, style: {} }, 'x-component-props': { checkStrength: true, style: {} },
'x-reactions': [ 'x-reactions': [
{ {
@ -73,6 +74,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
'x-component': 'Password', 'x-component': 'Password',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
title: '{{t("Confirm password")}}', title: '{{t("Confirm password")}}',
'x-validator': { password: true },
'x-component-props': { style: {} }, 'x-component-props': { style: {} },
'x-reactions': [ 'x-reactions': [
{ {

View File

@ -7,4 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
export { AuthModel, BasicAuth, default } from './server'; export { AuthModel, BasicAuth, default, presetAuthType } from './server';

View File

@ -46,7 +46,10 @@ export class BasicAuth extends BaseAuth {
const field = this.userCollection.getField<PasswordField>('password'); const field = this.userCollection.getField<PasswordField>('password');
const valid = await field.verify(password, user.password); const valid = await field.verify(password, user.password);
if (!valid) { if (!valid) {
ctx.throw(401, ctx.t('The username/email or password is incorrect, please re-enter', { ns: namespace })); ctx.throw(401, ctx.t('The username/email or password is incorrect, please re-enter', { ns: namespace }), {
code: 'INCORRECT_PASSWORD',
user,
});
} }
return user; return user;
} }

View File

@ -9,5 +9,6 @@
export { BasicAuth } from './basic-auth'; export { BasicAuth } from './basic-auth';
export { AuthModel } from './model/authenticator'; export { AuthModel } from './model/authenticator';
export { presetAuthType } from '../preset';
export { default } from './plugin'; export { default } from './plugin';

View File

@ -23,6 +23,7 @@ export class PluginAuthServer extends Plugin {
cache: Cache; cache: Cache;
afterAdd() {} afterAdd() {}
async beforeLoad() { async beforeLoad() {
this.app.db.registerModels({ AuthModel }); this.app.db.registerModels({ AuthModel });
} }
@ -33,8 +34,7 @@ export class PluginAuthServer extends Plugin {
prefix: 'auth', prefix: 'auth',
store: 'memory', store: 'memory',
}); });
// Set up auth manager
// Set up auth manager and register preset auth type
const storer = new Storer({ const storer = new Storer({
db: this.db, db: this.db,
cache: this.cache, cache: this.cache,
@ -45,7 +45,7 @@ export class PluginAuthServer extends Plugin {
// If blacklist service is not set, should configure default blacklist service // If blacklist service is not set, should configure default blacklist service
this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this)); this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this));
} }
// register preset auth type
this.app.authManager.registerTypes(presetAuthType, { this.app.authManager.registerTypes(presetAuthType, {
auth: BasicAuth, auth: BasicAuth,
title: tval('Password', { ns: namespace }), title: tval('Password', { ns: namespace }),

View File

@ -30,11 +30,22 @@ export class TokenBlacklistService implements ITokenBlacklistService {
// 0.1% error rate requires 14.4 bits per item // 0.1% error rate requires 14.4 bits per item
// 14.4*1000000/8/1024/1024 = 1.72MB // 14.4*1000000/8/1024/1024 = 1.72MB
await this.bloomFilter.reserve(this.cacheKey, 0.001, 1000000); await this.bloomFilter.reserve(this.cacheKey, 0.001, 1000000);
const data = await this.repo.find({ fields: ['token'], raw: true }); const data = await this.repo.find({
fields: ['token'],
filter: {
expiration: {
$dateAfter: new Date(),
},
},
raw: true,
});
const tokens = data.map((item: any) => item.token); const tokens = data.map((item: any) => item.token);
if (!tokens.length) {
return;
}
await this.bloomFilter.mAdd(this.cacheKey, tokens); await this.bloomFilter.mAdd(this.cacheKey, tokens);
} catch (error) { } catch (error) {
plugin.app.logger.error('token-blacklist: create bloom filter failed', error); plugin.app.logger.warn('token-blacklist: create bloom filter failed', error);
this.bloomFilter = null; this.bloomFilter = null;
} }
}); });

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 { createMockServer, MockServer, MockServerOptions } from '@nocobase/test';
import { AppSupervisor } from '@nocobase/server';
describe('cache', async () => {
let app: MockServer;
let agent: any;
beforeEach(async () => {
const options: MockServerOptions = {
plugins: ['multi-app-manager', 'field-sort'],
};
if (process.env.CACHE_REDIS_URL) {
options.cacheManager = {
defaultStore: 'redis',
stores: {
redis: {
url: process.env.CACHE_REDIS_URL,
},
},
};
}
app = await createMockServer(options);
await app.db.getRepository('applications').create({
values: {
name: 'test_sub',
options: {
plugins: [],
},
},
context: {
waitSubAppInstall: true,
},
});
agent = app.agent();
});
afterEach(async () => {
await app.cacheManager.flushAll();
await app.destroy();
});
test('should separate cache for multiple apps', async () => {
const cache1 = app.cache;
const subApp = await AppSupervisor.getInstance().getApp('test_sub');
const cache2 = subApp.cache;
await cache1.set('test', 'test1');
await cache2.set('test', 'test2');
expect(await cache1.get('test')).toBe('test1');
expect(await cache2.get('test')).toBe('test2');
});
});

View File

@ -34,6 +34,10 @@ describe('sub app', async () => {
}); });
afterEach(async () => { afterEach(async () => {
const subApp = await AppSupervisor.getInstance().getApp('test_sub');
await subApp.db.clean({ drop: true });
await subApp.destroy();
await app.db.clean({ drop: true });
await app.destroy(); await app.destroy();
}); });

View File

@ -134,6 +134,10 @@ const defaultAppOptionsFactory = (appName: string, mainApp: Application) => {
resourcer: { resourcer: {
prefix: process.env.API_BASE_PATH, prefix: process.env.API_BASE_PATH,
}, },
cacheManager: {
...mainApp.options.cacheManager,
prefix: appName,
},
}; };
}; };

View File

@ -9,12 +9,11 @@
import React from 'react'; import React from 'react';
import { observer } from '@formily/reactive-react'; import { observer } from '@formily/reactive-react';
import { Schema } from '@formily/react';
import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd'; import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { dayjs } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
import { useLocalTranslation } from '../../locale'; import { useLocalTranslation } from '../../locale';
import { import {
fetchChannels, fetchChannels,
selectedChannelNameObs, selectedChannelNameObs,
@ -25,11 +24,12 @@ import {
channelStatusFilterObs, channelStatusFilterObs,
ChannelStatus, ChannelStatus,
} from '../observables'; } from '../observables';
import MessageList from './MessageList'; import MessageList from './MessageList';
import FilterTab from './FilterTab'; import FilterTab from './FilterTab';
import { useApp } from '@nocobase/client';
const InnerInboxContent = () => { const InnerInboxContent = () => {
const app = useApp();
const { token } = theme.useToken(); const { token } = theme.useToken();
const { t } = useLocalTranslation(); const { t } = useLocalTranslation();
const channels = channelListObs.value; const channels = channelListObs.value;
@ -82,6 +82,7 @@ const InnerInboxContent = () => {
style={{ paddingBottom: '20px' }} style={{ paddingBottom: '20px' }}
loading={channels.length === 0 && isFetchingChannelsObs.value} loading={channels.length === 0 && isFetchingChannelsObs.value}
renderItem={(item) => { renderItem={(item) => {
const title = Schema.compile(item.title, { t: app.i18n.t });
const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText; const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText;
const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary; const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary;
return ( return (
@ -116,8 +117,9 @@ const InnerInboxContent = () => {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
fontWeight: 'bold', fontWeight: 'bold',
}} }}
title={title}
> >
{item.title} {title}
</div> </div>
<div <div
style={{ style={{

View File

@ -9,7 +9,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { observer } from '@formily/reactive-react'; import { observer } from '@formily/reactive-react';
import { Schema } from '@formily/react';
import { Card, Descriptions, Button, Spin, Tag, ConfigProvider, Typography, Tooltip, theme } from 'antd'; import { Card, Descriptions, Button, Spin, Tag, ConfigProvider, Typography, Tooltip, theme } from 'antd';
import { dayjs } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -25,8 +25,10 @@ import {
updateMessage, updateMessage,
inboxVisible, inboxVisible,
} from '../observables'; } from '../observables';
import { useApp } from '@nocobase/client';
const MessageList = observer(() => { const MessageList = observer(() => {
const app = useApp();
const { t } = useLocalTranslation(); const { t } = useLocalTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = theme.useToken(); const { token } = theme.useToken();
@ -70,6 +72,8 @@ const MessageList = observer(() => {
fetchMessages({ filter, limit: 30 }); fetchMessages({ filter, limit: 30 });
}, [messages, selectedChannelName]); }, [messages, selectedChannelName]);
const title = Schema.compile(channelMapObs.value[selectedChannelName].title, { t: app.i18n.t });
return ( return (
<ConfigProvider <ConfigProvider
theme={{ theme={{
@ -77,7 +81,7 @@ const MessageList = observer(() => {
}} }}
> >
<Typography.Title level={4} style={{ marginBottom: token.marginLG }}> <Typography.Title level={4} style={{ marginBottom: token.marginLG }}>
{channelMapObs.value[selectedChannelName].title} {title}
</Typography.Title> </Typography.Title>
{messages.length === 0 && isFecthingMessageObs.value ? ( {messages.length === 0 && isFecthingMessageObs.value ? (

View File

@ -15,7 +15,11 @@ import { useNavigate } from 'react-router-dom';
import { dayjs } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
import InfiniteScrollContent from './InfiniteScrollContent'; import InfiniteScrollContent from './InfiniteScrollContent';
import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables'; import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables';
import { Schema } from '@formily/react';
import { useApp } from '@nocobase/client';
const InternalChannelList = () => { const InternalChannelList = () => {
const app = useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const channels = channelListObs.value; const channels = channelListObs.value;
const listRef = useRef<ListRef>(null); const listRef = useRef<ListRef>(null);
@ -57,6 +61,7 @@ const InternalChannelList = () => {
}} }}
> >
{channelListObs.value.map((item) => { {channelListObs.value.map((item) => {
const channelTitle = Schema.compile(item.title, { t: app.i18n.t });
return ( return (
<List.Item <List.Item
key={item.name} key={item.name}
@ -84,7 +89,7 @@ const InternalChannelList = () => {
alignItems: 'center', alignItems: 'center',
}} }}
> >
<div> {item.title}</div> <div> {channelTitle}</div>
<div style={{ color: 'var(--adm-color-weak)', fontSize: 'var(--adm-font-size-main)' }}> <div style={{ color: 'var(--adm-color-weak)', fontSize: 'var(--adm-font-size-main)' }}>
{dayjs(item.latestMsgReceiveTimestamp).fromNow(true)} {dayjs(item.latestMsgReceiveTimestamp).fromNow(true)}
</div> </div>

View File

@ -11,7 +11,7 @@ import React, { useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile'; import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile';
import { observer } from '@formily/reactive-react'; import { observer } from '@formily/reactive-react';
import { useCurrentUserContext, css } from '@nocobase/client'; import { useCurrentUserContext, css, useApp } from '@nocobase/client';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { dayjs } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
@ -33,7 +33,10 @@ import {
} from '../../observables'; } from '../../observables';
import { useLocalTranslation } from '../../../locale'; import { useLocalTranslation } from '../../../locale';
import InfiniteScrollContent from './InfiniteScrollContent'; import InfiniteScrollContent from './InfiniteScrollContent';
import { Schema } from '@formily/react';
const MobileMessagePageInner = () => { const MobileMessagePageInner = () => {
const app = useApp();
const { t } = useLocalTranslation(); const { t } = useLocalTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const ctx = useCurrentUserContext(); const ctx = useCurrentUserContext();
@ -95,7 +98,7 @@ const MobileMessagePageInner = () => {
setFecthMsgStatus('failure'); setFecthMsgStatus('failure');
} }
}, [messages]); }, [messages]);
const title = selectedChannelObs.value?.title || t('Message'); const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message');
return ( return (
<MobilePageProvider> <MobilePageProvider>

View File

@ -28,6 +28,13 @@ export const PasswordField: React.FC = () => {
field.reset(); field.reset();
}, [field, ctx.visible]); }, [field, ctx.visible]);
useEffect(() => {
if (!field.value) {
return;
}
field.validate();
}, [field.value]);
return ( return (
<Row gutter={10}> <Row gutter={10}>
<Col span={18}> <Col span={18}>

View File

@ -327,6 +327,12 @@ export const usersSchema: ISchema = {
'x-use-decorator-props': 'useEditFormProps', 'x-use-decorator-props': 'useEditFormProps',
title: '{{t("Change password")}}', title: '{{t("Change password")}}',
properties: { properties: {
username: {
'x-component': 'CollectionField',
'x-component-props': {
hidden: true,
},
},
password: { password: {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-component-props': { 'x-component-props': {

View File

@ -1,6 +1,7 @@
{ {
"Users & Permissions": "Users & Permissions", "Users & Permissions": "Users & Permissions",
"Add users": "Add users", "Add users": "Add users",
"Add user": "Add user",
"Remove user": "Remove user", "Remove user": "Remove user",
"Are you sure you want to remove it?": "Are you sure you want to remove it?", "Are you sure you want to remove it?": "Are you sure you want to remove it?",
"Random password": "Random password", "Random password": "Random password",

View File

@ -1,6 +1,7 @@
{ {
"Users & Permissions": "ユーザーと権限", "Users & Permissions": "ユーザーと権限",
"Add users": "ユーザーを追加", "Add users": "ユーザーを追加",
"Add user": "ユーザーを追加",
"Remove user": "ユーザーを削除", "Remove user": "ユーザーを削除",
"Are you sure you want to remove it?": "本当に削除してよろしいですか?", "Are you sure you want to remove it?": "本当に削除してよろしいですか?",
"Random password": "ランダムパスワード" "Random password": "ランダムパスワード"

View File

@ -1,6 +1,7 @@
{ {
"Users & Permissions": "用户和权限", "Users & Permissions": "用户和权限",
"Add users": "添加用户", "Add users": "添加用户",
"Add user": "添加用户",
"Remove user": "移除用户", "Remove user": "移除用户",
"Are you sure you want to remove it?": "你确定要移除吗?", "Are you sure you want to remove it?": "你确定要移除吗?",
"Random password": "随机密码", "Random password": "随机密码",

View File

@ -89,6 +89,7 @@ export default defineCollection({
type: 'string', type: 'string',
title: '{{t("Password")}}', title: '{{t("Password")}}',
'x-component': 'Password', 'x-component': 'Password',
'x-validator': { password: true },
}, },
}, },
{ {

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';
export default class UpdatePasswordValidatorMigration extends Migration {
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
// appVersion = '<1.6.0-alpha.7';
async up() {
const Field = this.context.db.getRepository('fields');
const field = await Field.findOne({
filter: {
name: 'password',
collectionName: 'users',
},
});
if (!field) {
return;
}
await field.update({
uiSchema: {
...field.options.uiSchema,
'x-validator': { password: true },
},
});
}
async down() {}
}