cabloy 5.1.240 → 5.1.242
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/repo-docs/backend/i18n-guide.md +109 -11
- package/repo-docs/backend/openapi-guide.md +18 -0
- package/repo-docs/frontend/environment-config-guide.md +43 -0
- package/repo-docs/frontend/module-scope.md +88 -0
- package/repo-docs/frontend/system-startup-guide.md +31 -0
- package/repo-e2e/specs/home-user-account.spec.ts +53 -8
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +17 -142
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/dto/operatorContext.ts +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/service/operator.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/operatorAccess.test.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/accountCurrent.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/accountProfileUpdate.ts +9 -2
- package/vona/src/suite/a-home/modules/home-user/src/dto/passportUser.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +7 -3
- package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/test/account.test.ts +133 -8
- package/vona/src/suite/a-home/modules/home-user/test/accountOpenapi.test.ts +32 -5
- package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +159 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/bean.captcha.ts +271 -137
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/cacheRedis.captcha.ts +199 -2
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/lib/captchaLua.ts +90 -0
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captcha.ts +15 -1
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captchaProvider.ts +38 -1
- package/vona/src/suite-vendor/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +54 -8
- package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +12 -15
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +1 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/schemas.ts +68 -52
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/types.ts +530 -344
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/page/dashboard/controller.tsx +7 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +6 -1
- package/zova/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +1 -0
- package/zova/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +1 -0
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +1 -1
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +3 -1
- package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +4 -0
- package/zova/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +1 -0
- package/zova/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +1 -0
- package/zova/src/suite/a-home/modules/home-user/src/model/account.ts +2 -1
- package/zova/src/suite/a-home/modules/home-user/src/page/account/controller.tsx +17 -3
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +0 -247
|
@@ -1,12 +1,209 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { ServiceCacheRedisBase, CacheRedis } from 'vona-module-a-cache';
|
|
2
3
|
|
|
3
4
|
import type { ICaptchaDataCache } from '../types/captcha.ts';
|
|
5
|
+
import type { ICaptchaAuthorization } from '../types/captchaProvider.ts';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
captchaCasLua,
|
|
9
|
+
captchaConsumeLua,
|
|
10
|
+
captchaFinalizeLua,
|
|
11
|
+
captchaReserveLua,
|
|
12
|
+
} from '../lib/captchaLua.ts';
|
|
4
13
|
|
|
5
14
|
export type TCacheRedisCaptchaKey = string;
|
|
6
15
|
export type TCacheRedisCaptchaData = ICaptchaDataCache;
|
|
7
16
|
|
|
8
|
-
|
|
17
|
+
export interface ICaptchaSnapshot {
|
|
18
|
+
raw: string;
|
|
19
|
+
data: ICaptchaDataCache;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@CacheRedis({ disableTransactionCompensate: true, updateAgeOnGet: false })
|
|
9
23
|
export class CacheRedisCaptcha extends ServiceCacheRedisBase<
|
|
10
24
|
TCacheRedisCaptchaKey,
|
|
11
25
|
TCacheRedisCaptchaData
|
|
12
|
-
> {
|
|
26
|
+
> {
|
|
27
|
+
async snapshot(id: string): Promise<ICaptchaSnapshot | undefined> {
|
|
28
|
+
const { redis, keys } = this._redisKeys([id]);
|
|
29
|
+
const raw = await redis.get(keys[0]);
|
|
30
|
+
return raw ? { raw, data: JSON.parse(raw) } : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async createImage(id: string, data: ICaptchaDataCache, ttl: number) {
|
|
34
|
+
this._validateTtl(ttl);
|
|
35
|
+
const { redis, keys } = this._redisKeys([id]);
|
|
36
|
+
return (await redis.set(keys[0], JSON.stringify(data), 'PX', ttl, 'NX')) === 'OK';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async compareAndSet(
|
|
40
|
+
id: string,
|
|
41
|
+
snapshot: ICaptchaSnapshot,
|
|
42
|
+
data?: ICaptchaDataCache,
|
|
43
|
+
ttl?: number,
|
|
44
|
+
) {
|
|
45
|
+
if (ttl !== undefined) this._validateTtl(ttl);
|
|
46
|
+
const result = await this._eval(
|
|
47
|
+
captchaCasLua,
|
|
48
|
+
[id],
|
|
49
|
+
[snapshot.raw, data === undefined ? '' : JSON.stringify(data), ttl ?? 0],
|
|
50
|
+
);
|
|
51
|
+
return result === 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
boundPrefix() {
|
|
55
|
+
const instance = createHash('sha256').update(String(this.__getInstanceIdScope())).digest('hex');
|
|
56
|
+
return `{captcha:${instance}}:`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Opaque, instance-local cache keys, also available for precise test cleanup. */
|
|
60
|
+
boundKeys(
|
|
61
|
+
authorization: Pick<ICaptchaAuthorization, 'scope' | 'ip' | 'recipientLimits' | 'ipLimits'>,
|
|
62
|
+
) {
|
|
63
|
+
const prefix = this.boundPrefix();
|
|
64
|
+
return {
|
|
65
|
+
pointer: `${prefix}current:${authorization.scope}`,
|
|
66
|
+
cooldown: `${prefix}cooldown:${authorization.scope}`,
|
|
67
|
+
budgets: [
|
|
68
|
+
...authorization.recipientLimits.map(
|
|
69
|
+
limit => `${prefix}recipient:${authorization.scope}:${limit.windowMs}`,
|
|
70
|
+
),
|
|
71
|
+
...authorization.ipLimits.map(limit => `${prefix}ip:${authorization.ip}:${limit.windowMs}`),
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async reserveBound(id: string, data: ICaptchaDataCache, authorization: ICaptchaAuthorization) {
|
|
77
|
+
this._validateAuthorization(authorization);
|
|
78
|
+
this._validateBoundId(id);
|
|
79
|
+
const keys = this.boundKeys(authorization);
|
|
80
|
+
const policy = {
|
|
81
|
+
ttl: authorization.ttl,
|
|
82
|
+
cooldown: authorization.cooldown,
|
|
83
|
+
budgets: [...authorization.recipientLimits, ...authorization.ipLimits],
|
|
84
|
+
};
|
|
85
|
+
const result = (await this._eval(
|
|
86
|
+
captchaReserveLua,
|
|
87
|
+
[id, keys.pointer, keys.cooldown, ...keys.budgets],
|
|
88
|
+
[id, JSON.stringify(policy), JSON.stringify(data)],
|
|
89
|
+
)) as [number, string, number, number];
|
|
90
|
+
if (result[0] === -1) throw new Error('captcha identity collision');
|
|
91
|
+
if (result[0] === 0) {
|
|
92
|
+
return { reserved: false as const, retryAt: result[2], resendAt: result[3] };
|
|
93
|
+
}
|
|
94
|
+
if (result[0] !== 1 || !result[1]) throw new Error('invalid captcha reservation');
|
|
95
|
+
return {
|
|
96
|
+
reserved: true as const,
|
|
97
|
+
snapshot: { raw: result[1], data: JSON.parse(result[1]) as ICaptchaDataCache },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async finalizeBound(id: string, snapshot: ICaptchaSnapshot, data?: ICaptchaDataCache) {
|
|
102
|
+
this._validateBoundId(id);
|
|
103
|
+
const scope = snapshot.data.bound!.scope;
|
|
104
|
+
const pointer = `${this.boundPrefix()}current:${scope}`;
|
|
105
|
+
return (
|
|
106
|
+
(await this._eval(
|
|
107
|
+
captchaFinalizeLua,
|
|
108
|
+
[id, pointer],
|
|
109
|
+
[snapshot.raw, id, data === undefined ? '' : JSON.stringify(data)],
|
|
110
|
+
)) === 1
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** -1 asks the verifier to reread a competing wrong-attempt update. */
|
|
115
|
+
async consumeBound(id: string, snapshot: ICaptchaSnapshot, verified: boolean) {
|
|
116
|
+
this._validateBoundId(id);
|
|
117
|
+
const bound = snapshot.data.bound!;
|
|
118
|
+
const pointer = `${this.boundPrefix()}current:${bound.scope}`;
|
|
119
|
+
const next = {
|
|
120
|
+
...snapshot.data,
|
|
121
|
+
bound: {
|
|
122
|
+
...bound,
|
|
123
|
+
attemptsRemaining: bound.attemptsRemaining - 1,
|
|
124
|
+
version: bound.version + 1,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
return (await this._eval(
|
|
128
|
+
captchaConsumeLua,
|
|
129
|
+
[id, pointer],
|
|
130
|
+
[snapshot.raw, id, verified ? '1' : '0', JSON.stringify(next)],
|
|
131
|
+
)) as number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
validateAuthorization(authorization: ICaptchaAuthorization) {
|
|
135
|
+
this._validateAuthorization(authorization);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private _validateAuthorization(authorization: ICaptchaAuthorization) {
|
|
139
|
+
if (!authorization || typeof authorization !== 'object')
|
|
140
|
+
throw new Error('invalid captcha authorization');
|
|
141
|
+
const { binding, scope, ip, ttl, cooldown, maxAttempts, recipientLimits, ipLimits } =
|
|
142
|
+
authorization;
|
|
143
|
+
const text = (value: unknown) =>
|
|
144
|
+
typeof value === 'string' && value.length > 0 && value.length <= 512;
|
|
145
|
+
const hash = (value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
146
|
+
if (
|
|
147
|
+
!binding ||
|
|
148
|
+
!text(binding.recipient) ||
|
|
149
|
+
!text(binding.purpose) ||
|
|
150
|
+
(binding.subject !== undefined && !text(binding.subject)) ||
|
|
151
|
+
!hash(scope) ||
|
|
152
|
+
!hash(ip)
|
|
153
|
+
) {
|
|
154
|
+
throw new Error('invalid captcha binding');
|
|
155
|
+
}
|
|
156
|
+
this._validateTtl(ttl);
|
|
157
|
+
if (
|
|
158
|
+
!Number.isSafeInteger(cooldown) ||
|
|
159
|
+
cooldown < 0 ||
|
|
160
|
+
cooldown > Number.MAX_SAFE_INTEGER - Date.now() ||
|
|
161
|
+
maxAttempts !== 5
|
|
162
|
+
) {
|
|
163
|
+
throw new Error('invalid captcha policy');
|
|
164
|
+
}
|
|
165
|
+
for (const limits of [recipientLimits, ipLimits]) {
|
|
166
|
+
if (!Array.isArray(limits) || limits.length === 0 || limits.length > 16)
|
|
167
|
+
throw new Error('invalid captcha limits');
|
|
168
|
+
const windows = new Set<number>();
|
|
169
|
+
for (const limit of limits) {
|
|
170
|
+
if (
|
|
171
|
+
!limit ||
|
|
172
|
+
!Number.isSafeInteger(limit.limit) ||
|
|
173
|
+
limit.limit <= 0 ||
|
|
174
|
+
windows.has(limit.windowMs)
|
|
175
|
+
) {
|
|
176
|
+
throw new Error('invalid captcha limit');
|
|
177
|
+
}
|
|
178
|
+
this._validateTtl(limit.windowMs);
|
|
179
|
+
windows.add(limit.windowMs);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private _validateTtl(ttl: number) {
|
|
185
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > Number.MAX_SAFE_INTEGER - Date.now()) {
|
|
186
|
+
throw new Error('invalid captcha ttl');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private _validateBoundId(id: string) {
|
|
191
|
+
if (
|
|
192
|
+
!id.startsWith(this.boundPrefix()) ||
|
|
193
|
+
!/^[a-f0-9-]{36}$/.test(id.slice(this.boundPrefix().length))
|
|
194
|
+
) {
|
|
195
|
+
throw new Error('invalid bound captcha identity');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private _redisKeys(ids: string[]) {
|
|
200
|
+
const redis = this.__cacheInstance;
|
|
201
|
+
if (!redis) throw new Error('captcha cache not enabled');
|
|
202
|
+
return { redis, keys: ids.map(id => this.getRedisKey(id)!) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async _eval(lua: string, ids: string[], args: Array<string | number>) {
|
|
206
|
+
const { redis, keys } = this._redisKeys(ids);
|
|
207
|
+
return await redis.eval(lua, keys.length, ...keys, ...args);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// All keys are supplied through the existing captcha cache owner. Bound keys share
|
|
2
|
+
// one instance hash tag; image transitions use their original single key.
|
|
3
|
+
export const captchaCasLua = `
|
|
4
|
+
local current = redis.call('GET', KEYS[1])
|
|
5
|
+
if current ~= ARGV[1] then return 0 end
|
|
6
|
+
if ARGV[2] == '' then
|
|
7
|
+
redis.call('DEL', KEYS[1])
|
|
8
|
+
else
|
|
9
|
+
local ttl = tonumber(ARGV[3])
|
|
10
|
+
if ttl > 0 then
|
|
11
|
+
redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl)
|
|
12
|
+
else
|
|
13
|
+
redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
return 1
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
export const captchaReserveLua = `
|
|
20
|
+
local time = redis.call('TIME')
|
|
21
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
22
|
+
local policy = cjson.decode(ARGV[2])
|
|
23
|
+
local cooldown = tonumber(redis.call('GET', KEYS[3]) or '0')
|
|
24
|
+
local retryAt = cooldown
|
|
25
|
+
local counters = {}
|
|
26
|
+
for i, budget in ipairs(policy.budgets) do
|
|
27
|
+
local value = tonumber(redis.call('GET', KEYS[i + 3]) or '0')
|
|
28
|
+
local deadline = (math.floor(now / budget.windowMs) + 1) * budget.windowMs
|
|
29
|
+
local remaining = redis.call('PTTL', KEYS[i + 3])
|
|
30
|
+
if remaining <= 0 then value = 0 end
|
|
31
|
+
counters[i] = { value = value, deadline = deadline }
|
|
32
|
+
if value >= budget.limit and deadline > retryAt then retryAt = deadline end
|
|
33
|
+
end
|
|
34
|
+
if retryAt > now then return { 0, '', retryAt, cooldown } end
|
|
35
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then return { -1 } end
|
|
36
|
+
local data = cjson.decode(ARGV[3])
|
|
37
|
+
data.bound.expiresAt = now + policy.ttl
|
|
38
|
+
data.bound.resendAt = now + policy.cooldown
|
|
39
|
+
local raw = cjson.encode(data)
|
|
40
|
+
for i, counter in ipairs(counters) do
|
|
41
|
+
redis.call('SET', KEYS[i + 3], tostring(counter.value + 1))
|
|
42
|
+
redis.call('PEXPIREAT', KEYS[i + 3], counter.deadline)
|
|
43
|
+
end
|
|
44
|
+
redis.call('SET', KEYS[1], raw)
|
|
45
|
+
redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
|
|
46
|
+
redis.call('SET', KEYS[2], ARGV[1])
|
|
47
|
+
redis.call('PEXPIREAT', KEYS[2], math.max(data.bound.expiresAt, data.bound.resendAt))
|
|
48
|
+
if policy.cooldown > 0 then
|
|
49
|
+
redis.call('SET', KEYS[3], tostring(data.bound.resendAt))
|
|
50
|
+
redis.call('PEXPIREAT', KEYS[3], data.bound.resendAt)
|
|
51
|
+
end
|
|
52
|
+
return { 1, raw, data.bound.expiresAt, data.bound.resendAt }
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
export const captchaFinalizeLua = `
|
|
56
|
+
local current = redis.call('GET', KEYS[1])
|
|
57
|
+
if current ~= ARGV[1] or redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
|
|
58
|
+
local data = cjson.decode(current)
|
|
59
|
+
local time = redis.call('TIME')
|
|
60
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
61
|
+
if data.bound.state ~= 'pending' or data.bound.expiresAt <= now then return 0 end
|
|
62
|
+
if ARGV[3] == '' then
|
|
63
|
+
redis.call('DEL', KEYS[1])
|
|
64
|
+
else
|
|
65
|
+
redis.call('SET', KEYS[1], ARGV[3])
|
|
66
|
+
redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
|
|
67
|
+
end
|
|
68
|
+
return 1
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
// Verification happens in the provider. Only the caller holding this exact active
|
|
72
|
+
// snapshot can commit its verdict, so success cannot race refresh or another use.
|
|
73
|
+
export const captchaConsumeLua = `
|
|
74
|
+
local current = redis.call('GET', KEYS[1])
|
|
75
|
+
if current ~= ARGV[1] then return -1 end
|
|
76
|
+
if redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
|
|
77
|
+
local data = cjson.decode(current)
|
|
78
|
+
local time = redis.call('TIME')
|
|
79
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
80
|
+
if data.bound.state ~= 'active' or data.bound.expiresAt <= now or data.bound.attemptsRemaining <= 0 then
|
|
81
|
+
return 0
|
|
82
|
+
end
|
|
83
|
+
if ARGV[3] == '1' or data.bound.attemptsRemaining == 1 then
|
|
84
|
+
redis.call('DEL', KEYS[1])
|
|
85
|
+
else
|
|
86
|
+
redis.call('SET', KEYS[1], ARGV[4])
|
|
87
|
+
redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
|
|
88
|
+
end
|
|
89
|
+
return 1
|
|
90
|
+
`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ICaptchaProviderRecord } from './captchaProvider.ts';
|
|
1
|
+
import type { ICaptchaBinding, ICaptchaProviderRecord } from './captchaProvider.ts';
|
|
2
2
|
import type { ICaptchaSceneRecord } from './captchaScene.ts';
|
|
3
3
|
import 'vona';
|
|
4
4
|
|
|
@@ -7,6 +7,16 @@ export interface ICaptchaDataCache {
|
|
|
7
7
|
provider: keyof ICaptchaProviderRecord;
|
|
8
8
|
token?: unknown;
|
|
9
9
|
token2?: string;
|
|
10
|
+
revision?: string;
|
|
11
|
+
bound?: {
|
|
12
|
+
binding: ICaptchaBinding;
|
|
13
|
+
scope: string;
|
|
14
|
+
state: 'pending' | 'active';
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
resendAt: number;
|
|
17
|
+
attemptsRemaining: number;
|
|
18
|
+
version: number;
|
|
19
|
+
};
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
export interface ICaptchaData {
|
|
@@ -14,6 +24,10 @@ export interface ICaptchaData {
|
|
|
14
24
|
provider: keyof ICaptchaProviderRecord;
|
|
15
25
|
token?: unknown;
|
|
16
26
|
payload: unknown;
|
|
27
|
+
status?: 'accepted' | 'rejected' | 'unknown' | 'limited';
|
|
28
|
+
expiresAt?: number;
|
|
29
|
+
resendAt?: number;
|
|
30
|
+
retryAt?: number;
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
declare module 'vona' {
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import type { OmitNever } from 'vona';
|
|
2
2
|
import type { ServiceOnion, TypeOnionOptionsEnableSimple } from 'vona-module-a-onion';
|
|
3
3
|
|
|
4
|
+
import type { ICaptchaSceneRecord } from './captchaScene.ts';
|
|
5
|
+
|
|
4
6
|
export interface ICaptchaProviderRecord {}
|
|
5
7
|
|
|
8
|
+
export interface ICaptchaBinding {
|
|
9
|
+
recipient: string;
|
|
10
|
+
purpose: string;
|
|
11
|
+
subject?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ICaptchaLimit {
|
|
15
|
+
limit: number;
|
|
16
|
+
windowMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Server-authorized policy, never populated from generic captcha request JSON. */
|
|
20
|
+
export interface ICaptchaAuthorization {
|
|
21
|
+
binding: ICaptchaBinding;
|
|
22
|
+
scope: string;
|
|
23
|
+
ip: string;
|
|
24
|
+
ttl: number;
|
|
25
|
+
cooldown: number;
|
|
26
|
+
maxAttempts: number;
|
|
27
|
+
recipientLimits: ICaptchaLimit[];
|
|
28
|
+
ipLimits: ICaptchaLimit[];
|
|
29
|
+
}
|
|
30
|
+
|
|
6
31
|
export interface ICaptchaProviderExecute<TOKEN = unknown, PAYLOAD = unknown> {
|
|
7
|
-
|
|
32
|
+
readonly bound?: boolean;
|
|
33
|
+
authorize?(
|
|
34
|
+
context: unknown,
|
|
35
|
+
operation: 'issue' | 'consume',
|
|
36
|
+
sceneName: keyof ICaptchaSceneRecord,
|
|
37
|
+
): ICaptchaAuthorization | undefined | Promise<ICaptchaAuthorization | undefined>;
|
|
38
|
+
create(
|
|
39
|
+
options: IDecoratorCaptchaProviderOptions,
|
|
40
|
+
context?: unknown,
|
|
41
|
+
): Promise<ICaptchaProviderData<TOKEN, PAYLOAD>>;
|
|
8
42
|
verify(
|
|
9
43
|
token: TOKEN,
|
|
10
44
|
tokenInput: TOKEN,
|
|
@@ -15,6 +49,9 @@ export interface ICaptchaProviderExecute<TOKEN = unknown, PAYLOAD = unknown> {
|
|
|
15
49
|
export interface ICaptchaProviderData<TOKEN = unknown, PAYLOAD = unknown> {
|
|
16
50
|
token: TOKEN;
|
|
17
51
|
payload: PAYLOAD;
|
|
52
|
+
outcome?: 'accepted' | 'rejected' | 'unknown';
|
|
53
|
+
/** Bound providers alone decide whether their actual transport may expose the token. */
|
|
54
|
+
exposeToken?: boolean;
|
|
18
55
|
}
|
|
19
56
|
|
|
20
57
|
export interface IDecoratorCaptchaProviderOptions extends TypeOnionOptionsEnableSimple {
|
|
@@ -11,7 +11,7 @@ import type { ContextRoute, IRecordResourceNameToRoutePathItem } from 'vona-modu
|
|
|
11
11
|
import { appResource, BeanBase, beanFullNameFromOnionName } from 'vona';
|
|
12
12
|
import { Bean } from 'vona-module-a-bean';
|
|
13
13
|
import { Caching } from 'vona-module-a-caching';
|
|
14
|
-
import { clearRbacDecision,
|
|
14
|
+
import { clearRbacDecision, getRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
|
|
15
15
|
import {
|
|
16
16
|
composeGuards,
|
|
17
17
|
getCacheControllerRoutes,
|
|
@@ -107,6 +107,23 @@ export class BeanPermission extends BeanBase {
|
|
|
107
107
|
return typeof permission === 'boolean' ? permission : permission.allowed;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Checks current guards without permission events, result caches or matcher projection.
|
|
112
|
+
* A true result does not authorize individual records or prepare data scope/owner values.
|
|
113
|
+
* The target action's RBAC decision is not retained; scoped operations must authorize separately.
|
|
114
|
+
*/
|
|
115
|
+
public async checkPermissionActionGuards(
|
|
116
|
+
resource: keyof IResourceRecord,
|
|
117
|
+
actionKey: string,
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
if (!Object.hasOwn(recordResourceNameToRoutePath, resource)) return false;
|
|
120
|
+
const routePathInfo = recordResourceNameToRoutePath[resource];
|
|
121
|
+
if (!routePathInfo || !appResource.getBean(routePathInfo.controller)) return false;
|
|
122
|
+
const route = this._getControllerActionRoute(resource, actionKey);
|
|
123
|
+
if (!route?.route?.meta || !this._matchPassportMeta(route.route.meta)) return false;
|
|
124
|
+
return await this._evaluatePermissionAction(route, true);
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
public async retrievePermissionAction(
|
|
111
128
|
resource: keyof IResourceRecord,
|
|
112
129
|
actionKey: string,
|
|
@@ -201,12 +218,17 @@ export class BeanPermission extends BeanBase {
|
|
|
201
218
|
return true;
|
|
202
219
|
}
|
|
203
220
|
|
|
204
|
-
private async _evaluatePermissionAction(route: ContextRoute): Promise<
|
|
221
|
+
private async _evaluatePermissionAction(route: ContextRoute, guardsOnly: true): Promise<boolean>;
|
|
222
|
+
private async _evaluatePermissionAction(route: ContextRoute): Promise<IOpenapiPermissionAction>;
|
|
223
|
+
private async _evaluatePermissionAction(
|
|
224
|
+
route: ContextRoute,
|
|
225
|
+
guardsOnly = false,
|
|
226
|
+
): Promise<IOpenapiPermissionAction> {
|
|
205
227
|
const ctx = this.ctx as any;
|
|
206
|
-
const
|
|
228
|
+
const routeDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'route');
|
|
229
|
+
const innerAccessDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'innerAccess');
|
|
207
230
|
const innerAccessPrevious = ctx.innerAccess;
|
|
208
|
-
const
|
|
209
|
-
const rbacDecisionPrevious = ctx[SymbolRbacDecision];
|
|
231
|
+
const rbacDecisionDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, SymbolRbacDecision);
|
|
210
232
|
try {
|
|
211
233
|
ctx.route = route;
|
|
212
234
|
ctx.innerAccess = false;
|
|
@@ -214,19 +236,43 @@ export class BeanPermission extends BeanBase {
|
|
|
214
236
|
const result = await composeGuards(this.app, route)(this.ctx);
|
|
215
237
|
if (result === false) return false;
|
|
216
238
|
if (!route.route.meta?.[BeanFullNameGuardRbac]) return true;
|
|
239
|
+
if (guardsOnly) {
|
|
240
|
+
const action = this.bean.rbacCatalog.getAction(route);
|
|
241
|
+
const decision = getRbacDecision(this.ctx);
|
|
242
|
+
return Boolean(
|
|
243
|
+
action &&
|
|
244
|
+
Object.hasOwn(ctx, SymbolRbacDecision) &&
|
|
245
|
+
decision?.allowed === true &&
|
|
246
|
+
decision.actionKey === action.actionKey &&
|
|
247
|
+
decision.action?.actionKey === action.actionKey &&
|
|
248
|
+
decision.action.actionInheritKey === action.actionInheritKey &&
|
|
249
|
+
decision.action.controllerBeanFullName === action.controllerBeanFullName &&
|
|
250
|
+
decision.action.action === action.action,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
217
253
|
const rbacScopeCurrent = await this.bean.rbacScope.current();
|
|
218
254
|
return rbacScopeCurrent.permissionProjection();
|
|
219
255
|
} catch (err: any) {
|
|
220
256
|
if ([401, 403].includes(err?.code)) return false;
|
|
221
257
|
throw err;
|
|
222
258
|
} finally {
|
|
223
|
-
if (
|
|
224
|
-
|
|
259
|
+
if (rbacDecisionDescriptorPrevious) {
|
|
260
|
+
Object.defineProperty(ctx, SymbolRbacDecision, rbacDecisionDescriptorPrevious);
|
|
225
261
|
} else {
|
|
226
262
|
clearRbacDecision(ctx);
|
|
227
263
|
}
|
|
228
|
-
|
|
264
|
+
if (routeDescriptorPrevious) {
|
|
265
|
+
Object.defineProperty(ctx, 'route', routeDescriptorPrevious);
|
|
266
|
+
} else {
|
|
267
|
+
delete ctx.route;
|
|
268
|
+
}
|
|
269
|
+
// Vona's inherited innerAccess accessor owns its value in a context symbol.
|
|
229
270
|
ctx.innerAccess = innerAccessPrevious;
|
|
271
|
+
if (innerAccessDescriptorPrevious) {
|
|
272
|
+
Object.defineProperty(ctx, 'innerAccess', innerAccessDescriptorPrevious);
|
|
273
|
+
} else {
|
|
274
|
+
delete ctx.innerAccess;
|
|
275
|
+
}
|
|
230
276
|
}
|
|
231
277
|
}
|
|
232
278
|
}
|
|
@@ -62,10 +62,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
62
62
|
roleIds: ['role-a', 'role-b'],
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
const info = cacheInfo('
|
|
65
|
+
const info = cacheInfo('test:resource', 'select');
|
|
66
66
|
assert.equal(
|
|
67
67
|
(first as any).retrievePermissionActionByRolesCacheKey(info),
|
|
68
|
-
'action:
|
|
68
|
+
'action:test:resource:select:roles:role-a,role-b',
|
|
69
69
|
);
|
|
70
70
|
assert.equal(
|
|
71
71
|
(first as any).retrievePermissionActionByRolesCacheKey(info),
|
|
@@ -78,7 +78,7 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
78
78
|
userId: 'user-1',
|
|
79
79
|
roleIds: ['role-a'],
|
|
80
80
|
});
|
|
81
|
-
const info = cacheInfo('
|
|
81
|
+
const info = cacheInfo('test:resource', 'select');
|
|
82
82
|
|
|
83
83
|
assert.notEqual(
|
|
84
84
|
(permission as any).retrievePermissionActionByRolesCacheKey(info),
|
|
@@ -100,10 +100,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
100
100
|
roleIds: ['role-b'],
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
const info = cacheInfo('
|
|
103
|
+
const info = cacheInfo('test:resource', 'select');
|
|
104
104
|
assert.equal(
|
|
105
105
|
(first as any).retrievePermissionActionByUserCacheKey(info),
|
|
106
|
-
'action:
|
|
106
|
+
'action:test:resource:select:user:user-1:roles:role-a',
|
|
107
107
|
);
|
|
108
108
|
assert.notEqual(
|
|
109
109
|
(first as any).retrievePermissionActionByUserCacheKey(info),
|
|
@@ -152,24 +152,21 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
assert.equal(
|
|
155
|
-
await permission.retrievePermissionAction('
|
|
155
|
+
await permission.retrievePermissionAction('test:resource' as never, 'ordinary'),
|
|
156
156
|
false,
|
|
157
157
|
);
|
|
158
|
-
assert.equal(
|
|
159
|
-
await permission.retrievePermissionAction('training-student:student', 'rbac'),
|
|
160
|
-
true,
|
|
161
|
-
);
|
|
158
|
+
assert.equal(await permission.retrievePermissionAction('test:resource' as never, 'rbac'), true);
|
|
162
159
|
assert.deepEqual(calls, ['roles', 'user']);
|
|
163
160
|
});
|
|
164
161
|
|
|
165
162
|
it('isolates resource permission snapshots by user', () => {
|
|
166
163
|
const first = createPermission({ userId: 'user-1' });
|
|
167
164
|
const otherUser = createPermission({ userId: 'user-2' });
|
|
168
|
-
const info = cacheInfo('
|
|
165
|
+
const info = cacheInfo('test:resource');
|
|
169
166
|
|
|
170
167
|
assert.equal(
|
|
171
168
|
(first as any).retrievePermissionsCacheKey(info),
|
|
172
|
-
'user:
|
|
169
|
+
'user:test:resource:user:user-1',
|
|
173
170
|
);
|
|
174
171
|
assert.notEqual(
|
|
175
172
|
(first as any).retrievePermissionsCacheKey(info),
|
|
@@ -333,8 +330,8 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
333
330
|
retrievePermissionAction: { value: async () => true },
|
|
334
331
|
});
|
|
335
332
|
|
|
336
|
-
assert.equal(await allowed.checkPermissionAction('
|
|
337
|
-
assert.equal(await denied.checkPermissionAction('
|
|
338
|
-
assert.equal(await legacy.checkPermissionAction('
|
|
333
|
+
assert.equal(await allowed.checkPermissionAction('test:resource' as never, 'select'), true);
|
|
334
|
+
assert.equal(await denied.checkPermissionAction('test:resource' as never, 'select'), false);
|
|
335
|
+
assert.equal(await legacy.checkPermissionAction('test:resource' as never, 'select'), true);
|
|
339
336
|
});
|
|
340
337
|
});
|