mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
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:
parent
bee085710d
commit
84cfa82304
16
.github/workflows/nocobase-test-backend.yml
vendored
16
.github/workflows/nocobase-test-backend.yml
vendored
@ -46,7 +46,7 @@ jobs:
|
||||
container: node:${{ matrix.node_version }}
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
image: redis/redis-stack-server:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@ -75,6 +75,13 @@ jobs:
|
||||
underscored: [true, false]
|
||||
schema: [public, nocobase]
|
||||
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
|
||||
container: node:${{ matrix.node_version }}
|
||||
services:
|
||||
@ -93,7 +100,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:latest
|
||||
image: redis/redis-stack-server:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@ -125,6 +132,7 @@ jobs:
|
||||
DB_TEST_DISTRIBUTOR_PORT: 23450
|
||||
DB_TEST_PREFIX: test
|
||||
ENCRYPTION_FIELD_KEY: 1%&glK;<UA}aIxJVc53-4G(rTi0vg@J]
|
||||
CACHE_REDIS_URL: ${{ matrix.cache_redis }}
|
||||
timeout-minutes: 60
|
||||
|
||||
mysql-test:
|
||||
@ -142,7 +150,7 @@ jobs:
|
||||
MYSQL_DATABASE: nocobase
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
redis:
|
||||
image: redis:latest
|
||||
image: redis/redis-stack-server:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@ -188,7 +196,7 @@ jobs:
|
||||
MARIADB_DATABASE: nocobase
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
redis:
|
||||
image: redis:latest
|
||||
image: redis/redis-stack-server:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
|
@ -103,7 +103,9 @@ export class BaseAuth extends Auth {
|
||||
try {
|
||||
user = await this.validate();
|
||||
} catch (err) {
|
||||
this.ctx.throw(err.status || 401, err.message);
|
||||
this.ctx.throw(err.status || 401, err.message, {
|
||||
...err,
|
||||
});
|
||||
}
|
||||
if (!user) {
|
||||
this.ctx.throw(401, 'Unauthorized');
|
||||
|
1
packages/core/cache/package.json
vendored
1
packages/core/cache/package.json
vendored
@ -6,6 +6,7 @@
|
||||
"main": "./lib/index.js",
|
||||
"types": "./lib/index.d.ts",
|
||||
"dependencies": {
|
||||
"@nocobase/lock-manager": "1.6.0-alpha.6",
|
||||
"bloom-filters": "^3.0.1",
|
||||
"cache-manager": "^5.2.4",
|
||||
"cache-manager-redis-yet": "^4.1.2"
|
||||
|
@ -41,3 +41,41 @@ describe('bloomFilter', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
127
packages/core/cache/src/__tests__/counter.test.ts
vendored
Normal file
127
packages/core/cache/src/__tests__/counter.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
@ -21,27 +21,30 @@ export class RedisBloomFilter implements BloomFilter {
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
getStore() {
|
||||
private get store() {
|
||||
return this.cache.store.store as RedisStore;
|
||||
}
|
||||
|
||||
async reserve(key: string, errorRate: number, capacity: number) {
|
||||
const store = this.getStore();
|
||||
await store.client.bf.reserve(key, errorRate, capacity);
|
||||
try {
|
||||
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) {
|
||||
const store = this.getStore();
|
||||
await store.client.bf.add(key, value);
|
||||
await this.store.client.bf.add(key, value);
|
||||
}
|
||||
|
||||
async mAdd(key: string, values: string[]) {
|
||||
const store = this.getStore();
|
||||
await store.client.bf.mAdd(key, values);
|
||||
await this.store.client.bf.mAdd(key, values);
|
||||
}
|
||||
|
||||
async exists(key: string, value: string) {
|
||||
const store = this.getStore();
|
||||
return await store.client.bf.exists(key, value);
|
||||
return this.store.client.bf.exists(key, value);
|
||||
}
|
||||
}
|
||||
|
40
packages/core/cache/src/cache-manager.ts
vendored
40
packages/core/cache/src/cache-manager.ts
vendored
@ -15,6 +15,8 @@ import deepmerge from 'deepmerge';
|
||||
import { MemoryBloomFilter } from './bloom-filter/memory-bloom-filter';
|
||||
import { BloomFilter } from './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 = {
|
||||
store?: 'memory' | FactoryStore<Store, any>;
|
||||
@ -28,10 +30,12 @@ export type CacheManagerOptions = Partial<{
|
||||
stores: {
|
||||
[storeType: string]: StoreOptions;
|
||||
};
|
||||
prefix: string;
|
||||
}>;
|
||||
|
||||
export class CacheManager {
|
||||
defaultStore: string;
|
||||
prefix?: string;
|
||||
private stores = new Map<
|
||||
string,
|
||||
{
|
||||
@ -69,8 +73,9 @@ export class CacheManager {
|
||||
},
|
||||
};
|
||||
const cacheOptions = deepmerge(defaultOptions, options || {});
|
||||
const { defaultStore = 'memory', stores } = cacheOptions;
|
||||
const { defaultStore = 'memory', stores, prefix } = cacheOptions;
|
||||
this.defaultStore = defaultStore;
|
||||
this.prefix = prefix;
|
||||
for (const [name, store] of Object.entries(stores)) {
|
||||
const { store: s, ...globalConfig } = store;
|
||||
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 }) {
|
||||
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') {
|
||||
const newStore = await this.createStore({ name, storeType: store, ...config });
|
||||
return this.newCache({ name, prefix, store: newStore });
|
||||
@ -161,4 +168,33 @@ export class CacheManager {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
25
packages/core/cache/src/counter/index.ts
vendored
Normal file
25
packages/core/cache/src/counter/index.ts
vendored
Normal 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';
|
52
packages/core/cache/src/counter/lock-counter.ts
vendored
Normal file
52
packages/core/cache/src/counter/lock-counter.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
74
packages/core/cache/src/counter/memory-counter.ts
vendored
Normal file
74
packages/core/cache/src/counter/memory-counter.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
61
packages/core/cache/src/counter/redis-counter.ts
vendored
Normal file
61
packages/core/cache/src/counter/redis-counter.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
1
packages/core/cache/src/index.ts
vendored
1
packages/core/cache/src/index.ts
vendored
@ -10,3 +10,4 @@
|
||||
export * from './cache-manager';
|
||||
export * from './cache';
|
||||
export * from './bloom-filter';
|
||||
export * from './counter';
|
||||
|
@ -111,7 +111,7 @@ export class APIClient extends APIClientSDK {
|
||||
this.auth.setRole(null);
|
||||
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);
|
||||
}
|
||||
if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_FOR_USER')) {
|
||||
|
@ -889,7 +889,7 @@
|
||||
"Data source": "数据源",
|
||||
"DataSource": "数据源",
|
||||
"Data model": "数据模型",
|
||||
"Security": "认证与安全",
|
||||
"Security": "安全性",
|
||||
"Action": "操作",
|
||||
"System": "系统管理",
|
||||
"Other": "其他",
|
||||
@ -1036,8 +1036,8 @@
|
||||
"Bulk enable": "批量激活",
|
||||
"Search plugin...": "搜索插件...",
|
||||
"Package name": "包名",
|
||||
"Associate":"关联",
|
||||
"Please add or select record":"请添加或选择数据",
|
||||
"No data":"暂无数据",
|
||||
"Associate": "关联",
|
||||
"Please add or select record": "请添加或选择数据",
|
||||
"No data": "暂无数据",
|
||||
"Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用"
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import { Button, Modal, Result, Spin } from 'antd';
|
||||
import React, { FC } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { ACLPlugin } from '../acl';
|
||||
import { useAPIClient } from '../api-client';
|
||||
import { Application } from '../application';
|
||||
import { Plugin } from '../application/Plugin';
|
||||
import { BlockSchemaComponentPlugin } from '../block-provider';
|
||||
@ -33,6 +32,7 @@ import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
||||
import { SystemSettingsPlugin } from '../system-settings';
|
||||
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
|
||||
import { LocalePlugin } from './plugins/LocalePlugin';
|
||||
import { tval } from '@nocobase/utils/client';
|
||||
|
||||
const AppSpin = () => {
|
||||
return (
|
||||
@ -285,6 +285,11 @@ export class NocoBaseBuildInPlugin extends Plugin {
|
||||
|
||||
this.app.use(CurrentUserProvider);
|
||||
this.app.use(CurrentUserSettingsMenuProvider);
|
||||
|
||||
this.app.pluginSettingsManager.add('security', {
|
||||
title: tval('Security'),
|
||||
icon: 'SafetyOutlined',
|
||||
});
|
||||
}
|
||||
|
||||
addRoutes() {
|
||||
|
@ -32,12 +32,12 @@ export const useActionContext = () => {
|
||||
async onOk() {
|
||||
ctx.setFormValueChanged(false);
|
||||
ctx.setVisible?.(false);
|
||||
form?.reset?.();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
ctx.setVisible?.(false);
|
||||
}
|
||||
form?.reset?.();
|
||||
} else {
|
||||
ctx.setVisible?.(visible);
|
||||
}
|
||||
|
@ -28,9 +28,7 @@ const useCloseAction = () => {
|
||||
return {
|
||||
async run() {
|
||||
setVisible(false);
|
||||
form.submit((values) => {
|
||||
console.log(values);
|
||||
});
|
||||
await form.reset();
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -60,7 +58,7 @@ const schema: ISchema = {
|
||||
'x-decorator': 'Form',
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-component-props': {
|
||||
zIndex: 10000,
|
||||
zIndex: 2000,
|
||||
},
|
||||
type: 'void',
|
||||
title: '{{t("Change password")}}',
|
||||
@ -78,6 +76,7 @@ const schema: ISchema = {
|
||||
required: true,
|
||||
'x-component': 'Password',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-validator': { password: true },
|
||||
'x-component-props': { checkStrength: true, style: {} },
|
||||
'x-reactions': [
|
||||
{
|
||||
|
@ -581,7 +581,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
}
|
||||
|
||||
async createCacheManager() {
|
||||
this._cacheManager = await createCacheManager(this, this.options.cacheManager);
|
||||
this._cacheManager = await createCacheManager(this, {
|
||||
prefix: this.name,
|
||||
...this.options.cacheManager,
|
||||
});
|
||||
return this._cacheManager;
|
||||
}
|
||||
|
||||
|
@ -55,6 +55,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
|
||||
title: '{{t("Password")}}',
|
||||
'x-component': 'Password',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-validator': { password: true },
|
||||
'x-component-props': { checkStrength: true, style: {} },
|
||||
'x-reactions': [
|
||||
{
|
||||
@ -73,6 +74,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
|
||||
'x-component': 'Password',
|
||||
'x-decorator': 'FormItem',
|
||||
title: '{{t("Confirm password")}}',
|
||||
'x-validator': { password: true },
|
||||
'x-component-props': { style: {} },
|
||||
'x-reactions': [
|
||||
{
|
||||
|
@ -7,4 +7,4 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export { AuthModel, BasicAuth, default } from './server';
|
||||
export { AuthModel, BasicAuth, default, presetAuthType } from './server';
|
||||
|
@ -46,7 +46,10 @@ export class BasicAuth extends BaseAuth {
|
||||
const field = this.userCollection.getField<PasswordField>('password');
|
||||
const valid = await field.verify(password, user.password);
|
||||
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;
|
||||
}
|
||||
|
@ -9,5 +9,6 @@
|
||||
|
||||
export { BasicAuth } from './basic-auth';
|
||||
export { AuthModel } from './model/authenticator';
|
||||
export { presetAuthType } from '../preset';
|
||||
|
||||
export { default } from './plugin';
|
||||
|
@ -23,6 +23,7 @@ export class PluginAuthServer extends Plugin {
|
||||
cache: Cache;
|
||||
|
||||
afterAdd() {}
|
||||
|
||||
async beforeLoad() {
|
||||
this.app.db.registerModels({ AuthModel });
|
||||
}
|
||||
@ -33,8 +34,7 @@ export class PluginAuthServer extends Plugin {
|
||||
prefix: 'auth',
|
||||
store: 'memory',
|
||||
});
|
||||
|
||||
// Set up auth manager and register preset auth type
|
||||
// Set up auth manager
|
||||
const storer = new Storer({
|
||||
db: this.db,
|
||||
cache: this.cache,
|
||||
@ -45,7 +45,7 @@ export class PluginAuthServer extends Plugin {
|
||||
// If blacklist service is not set, should configure default blacklist service
|
||||
this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this));
|
||||
}
|
||||
|
||||
// register preset auth type
|
||||
this.app.authManager.registerTypes(presetAuthType, {
|
||||
auth: BasicAuth,
|
||||
title: tval('Password', { ns: namespace }),
|
||||
|
@ -30,11 +30,22 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
||||
// 0.1% error rate requires 14.4 bits per item
|
||||
// 14.4*1000000/8/1024/1024 = 1.72MB
|
||||
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);
|
||||
if (!tokens.length) {
|
||||
return;
|
||||
}
|
||||
await this.bloomFilter.mAdd(this.cacheKey, tokens);
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -34,6 +34,10 @@ describe('sub app', 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();
|
||||
});
|
||||
|
||||
|
@ -134,6 +134,10 @@ const defaultAppOptionsFactory = (appName: string, mainApp: Application) => {
|
||||
resourcer: {
|
||||
prefix: process.env.API_BASE_PATH,
|
||||
},
|
||||
cacheManager: {
|
||||
...mainApp.options.cacheManager,
|
||||
prefix: appName,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -9,12 +9,11 @@
|
||||
|
||||
import React from 'react';
|
||||
import { observer } from '@formily/reactive-react';
|
||||
|
||||
import { Schema } from '@formily/react';
|
||||
import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
|
||||
import {
|
||||
fetchChannels,
|
||||
selectedChannelNameObs,
|
||||
@ -25,11 +24,12 @@ import {
|
||||
channelStatusFilterObs,
|
||||
ChannelStatus,
|
||||
} from '../observables';
|
||||
|
||||
import MessageList from './MessageList';
|
||||
import FilterTab from './FilterTab';
|
||||
import { useApp } from '@nocobase/client';
|
||||
|
||||
const InnerInboxContent = () => {
|
||||
const app = useApp();
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useLocalTranslation();
|
||||
const channels = channelListObs.value;
|
||||
@ -82,6 +82,7 @@ const InnerInboxContent = () => {
|
||||
style={{ paddingBottom: '20px' }}
|
||||
loading={channels.length === 0 && isFetchingChannelsObs.value}
|
||||
renderItem={(item) => {
|
||||
const title = Schema.compile(item.title, { t: app.i18n.t });
|
||||
const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText;
|
||||
const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary;
|
||||
return (
|
||||
@ -116,8 +117,9 @@ const InnerInboxContent = () => {
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{item.title}
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
@ -9,7 +9,7 @@
|
||||
|
||||
import React, { useState, useCallback } from '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 { dayjs } from '@nocobase/utils/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -25,8 +25,10 @@ import {
|
||||
updateMessage,
|
||||
inboxVisible,
|
||||
} from '../observables';
|
||||
import { useApp } from '@nocobase/client';
|
||||
|
||||
const MessageList = observer(() => {
|
||||
const app = useApp();
|
||||
const { t } = useLocalTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = theme.useToken();
|
||||
@ -70,6 +72,8 @@ const MessageList = observer(() => {
|
||||
fetchMessages({ filter, limit: 30 });
|
||||
}, [messages, selectedChannelName]);
|
||||
|
||||
const title = Schema.compile(channelMapObs.value[selectedChannelName].title, { t: app.i18n.t });
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
@ -77,7 +81,7 @@ const MessageList = observer(() => {
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBottom: token.marginLG }}>
|
||||
{channelMapObs.value[selectedChannelName].title}
|
||||
{title}
|
||||
</Typography.Title>
|
||||
|
||||
{messages.length === 0 && isFecthingMessageObs.value ? (
|
||||
|
@ -15,7 +15,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||
import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables';
|
||||
import { Schema } from '@formily/react';
|
||||
import { useApp } from '@nocobase/client';
|
||||
|
||||
const InternalChannelList = () => {
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const channels = channelListObs.value;
|
||||
const listRef = useRef<ListRef>(null);
|
||||
@ -57,6 +61,7 @@ const InternalChannelList = () => {
|
||||
}}
|
||||
>
|
||||
{channelListObs.value.map((item) => {
|
||||
const channelTitle = Schema.compile(item.title, { t: app.i18n.t });
|
||||
return (
|
||||
<List.Item
|
||||
key={item.name}
|
||||
@ -84,7 +89,7 @@ const InternalChannelList = () => {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div> {item.title}</div>
|
||||
<div> {channelTitle}</div>
|
||||
<div style={{ color: 'var(--adm-color-weak)', fontSize: 'var(--adm-font-size-main)' }}>
|
||||
{dayjs(item.latestMsgReceiveTimestamp).fromNow(true)}
|
||||
</div>
|
||||
|
@ -11,7 +11,7 @@ import React, { useEffect, useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile';
|
||||
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 { dayjs } from '@nocobase/utils/client';
|
||||
|
||||
@ -33,7 +33,10 @@ import {
|
||||
} from '../../observables';
|
||||
import { useLocalTranslation } from '../../../locale';
|
||||
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||
import { Schema } from '@formily/react';
|
||||
|
||||
const MobileMessagePageInner = () => {
|
||||
const app = useApp();
|
||||
const { t } = useLocalTranslation();
|
||||
const navigate = useNavigate();
|
||||
const ctx = useCurrentUserContext();
|
||||
@ -95,7 +98,7 @@ const MobileMessagePageInner = () => {
|
||||
setFecthMsgStatus('failure');
|
||||
}
|
||||
}, [messages]);
|
||||
const title = selectedChannelObs.value?.title || t('Message');
|
||||
const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message');
|
||||
|
||||
return (
|
||||
<MobilePageProvider>
|
||||
|
@ -28,6 +28,13 @@ export const PasswordField: React.FC = () => {
|
||||
field.reset();
|
||||
}, [field, ctx.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!field.value) {
|
||||
return;
|
||||
}
|
||||
field.validate();
|
||||
}, [field.value]);
|
||||
|
||||
return (
|
||||
<Row gutter={10}>
|
||||
<Col span={18}>
|
||||
|
@ -327,6 +327,12 @@ export const usersSchema: ISchema = {
|
||||
'x-use-decorator-props': 'useEditFormProps',
|
||||
title: '{{t("Change password")}}',
|
||||
properties: {
|
||||
username: {
|
||||
'x-component': 'CollectionField',
|
||||
'x-component-props': {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
password: {
|
||||
'x-component': 'CollectionField',
|
||||
'x-component-props': {
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"Users & Permissions": "Users & Permissions",
|
||||
"Add users": "Add users",
|
||||
"Add user": "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",
|
||||
|
@ -1,7 +1,8 @@
|
||||
{
|
||||
"Users & Permissions": "ユーザーと権限",
|
||||
"Add users": "ユーザーを追加",
|
||||
"Add user": "ユーザーを追加",
|
||||
"Remove user": "ユーザーを削除",
|
||||
"Are you sure you want to remove it?": "本当に削除してよろしいですか?",
|
||||
"Random password": "ランダムパスワード"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"Users & Permissions": "用户和权限",
|
||||
"Add users": "添加用户",
|
||||
"Add user": "添加用户",
|
||||
"Remove user": "移除用户",
|
||||
"Are you sure you want to remove it?": "你确定要移除吗?",
|
||||
"Random password": "随机密码",
|
||||
|
@ -89,6 +89,7 @@ export default defineCollection({
|
||||
type: 'string',
|
||||
title: '{{t("Password")}}',
|
||||
'x-component': 'Password',
|
||||
'x-validator': { password: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -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() {}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user