@blod-nm/user-kit 0.1.5 → 0.1.6
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/package.json +1 -1
- package/src/access-key.js +130 -0
- package/src/auth.js +54 -35
- package/src/index.js +43 -10
- package/src/schemas.js +85 -43
- package/src/token-broker.js +62 -0
- package/src/user.js +38 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blod-nm/user-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "CloudBase user-center kit: auth (wechat code / unionid / password / sms / email / signup) + crm_user_detail archive on @blod-nm/base-cb.",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* access-key.js — apikey 签发 / verify(+30s 进程缓存)/ key 域能力(spec §3.1 · §3.4 · §3.5 · §3.7)
|
|
3
|
+
*
|
|
4
|
+
* 形态: key='a1_'+b64u(randomBytes(32))(只在签发响应出现一次)· key_hash=sha256hex(key)(档案表主键,库内不存明文)
|
|
5
|
+
* key_id='k_'+key_hash[0:12](展示/吊销句柄)· key_prefix=key[0:11](识别用,不含敏感信息)
|
|
6
|
+
* expires_at=unix 秒(now + (ttl ?? cfg.akDefaultKeyTtlSec))
|
|
7
|
+
* 两张表: __user_token_cache(token 托管,broker 读写)/ __user_access_key(key 档案,本文件读写)
|
|
8
|
+
* verify: 一条 SQL 查 key_hash → 四分支(malformed/revoked/expired/ok);ok 结果进程内 Map 缓存 30s;
|
|
9
|
+
* revoke 用 RETURNING key_hash 主动清缓存;DB 异常 → 503(fail-closed,查不到=拒绝)。
|
|
10
|
+
* key 域: create(当前 key 换 uid → 签发新 key)/ list(不含明文与 hash)/ revoke(他人 key → 404)/ verify。
|
|
11
|
+
* 禁止: 任何官方网关调用 / HMAC / masterSecret(查表派:档案表是唯一真相)。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
const CACHE_TTL_MS = 30_000
|
|
16
|
+
const DEFAULT_TTL_SEC = 2592000
|
|
17
|
+
|
|
18
|
+
export const DDL_USER_TOKEN_CACHE = `CREATE TABLE IF NOT EXISTS __user_token_cache (
|
|
19
|
+
uid text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
|
|
20
|
+
expires_at bigint NOT NULL, updated_at timestamptz DEFAULT now() NOT NULL)`
|
|
21
|
+
|
|
22
|
+
export const DDL_USER_ACCESS_KEY = `CREATE TABLE IF NOT EXISTS __user_access_key (
|
|
23
|
+
key_hash text PRIMARY KEY, key_id text NOT NULL UNIQUE, uid text NOT NULL,
|
|
24
|
+
scene text NOT NULL DEFAULT '', key_prefix text NOT NULL,
|
|
25
|
+
created_at timestamptz DEFAULT now() NOT NULL, expires_at bigint NOT NULL,
|
|
26
|
+
revoked_at timestamptz, updated_at timestamptz DEFAULT now() NOT NULL)`
|
|
27
|
+
|
|
28
|
+
export const DDL_USER_ACCESS_KEY_INDEX = 'CREATE INDEX IF NOT EXISTS idx_user_access_key_uid ON __user_access_key(uid)'
|
|
29
|
+
|
|
30
|
+
const SQL_SELECT_KEY = 'SELECT key_id, uid, scene, key_prefix, revoked_at, expires_at FROM __user_access_key WHERE key_hash=$1'
|
|
31
|
+
const SQL_INSERT_KEY = 'INSERT INTO __user_access_key (key_hash, key_id, uid, scene, key_prefix, expires_at) VALUES ($1, $2, $3, $4, $5, $6)'
|
|
32
|
+
const SQL_LIST_KEYS = 'SELECT key_id, key_prefix, scene, created_at, expires_at, revoked_at FROM __user_access_key WHERE uid=$1 ORDER BY created_at DESC'
|
|
33
|
+
const SQL_REVOKE_KEY = 'UPDATE __user_access_key SET revoked_at=now(), updated_at=now() WHERE key_id=$1 AND uid=$2 AND revoked_at IS NULL RETURNING key_hash'
|
|
34
|
+
|
|
35
|
+
const err = (code, message) => Object.assign(new Error(message), { code })
|
|
36
|
+
const nowSec = () => Math.floor(Date.now() / 1000)
|
|
37
|
+
export const sha256hex = s => createHash('sha256').update(String(s)).digest('hex')
|
|
38
|
+
|
|
39
|
+
/** 进程内 verify 缓存(key_hash → { result, until });只缓存 ok 结果,写路径不读 */
|
|
40
|
+
export const createVerifyCache = (ttlMs = CACHE_TTL_MS) => {
|
|
41
|
+
const store = new Map()
|
|
42
|
+
return {
|
|
43
|
+
get(hash) {
|
|
44
|
+
const hit = store.get(hash)
|
|
45
|
+
if (!hit) return null
|
|
46
|
+
if (hit.until <= Date.now()) { store.delete(hash); return null }
|
|
47
|
+
return hit.result
|
|
48
|
+
},
|
|
49
|
+
set(hash, result) { store.set(hash, { result, until: Date.now() + ttlMs }) },
|
|
50
|
+
clear(hash) { store.delete(hash) },
|
|
51
|
+
size: () => store.size,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* key 域工厂:verify / issue 闭包共享缓存与 runSql 接缝;abilities 为 auth.key.* 四条注册项。
|
|
57
|
+
* runSql(sql, parameters) 是本文件唯一表读写入口(§3.7,pg 适配器由 index.js 注入)。
|
|
58
|
+
*/
|
|
59
|
+
export function createKeyAbilities({ cfg, runSql }) {
|
|
60
|
+
const cache = createVerifyCache()
|
|
61
|
+
const ttlSec = () => Number(cfg?.akDefaultKeyTtlSec ?? DEFAULT_TTL_SEC)
|
|
62
|
+
|
|
63
|
+
// 签发:生成明文 + 落档(只返回 hash 供内部使用,明文仅由调用方取走一次性响应)
|
|
64
|
+
async function issue(uid, { scene = '', ttl } = {}) {
|
|
65
|
+
const apikey = 'a1_' + randomBytes(32).toString('base64url')
|
|
66
|
+
const keyHash = sha256hex(apikey)
|
|
67
|
+
const keyId = 'k_' + keyHash.slice(0, 12)
|
|
68
|
+
const keyPrefix = apikey.slice(0, 11)
|
|
69
|
+
const expiresAt = nowSec() + Number(ttl ?? ttlSec())
|
|
70
|
+
await runSql(SQL_INSERT_KEY, [keyHash, keyId, uid, scene ?? '', keyPrefix, expiresAt])
|
|
71
|
+
return { apikey, key_hash: keyHash, key_id: keyId, key_prefix: keyPrefix, expires_at: expiresAt }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 唯一裁决路径:sha256(key) → 一行 SQL → 四分支;ok 结果 30s 缓存
|
|
75
|
+
async function verify(apikey, { useCache = true } = {}) {
|
|
76
|
+
const keyHash = sha256hex(apikey ?? '')
|
|
77
|
+
if (useCache) {
|
|
78
|
+
const hit = cache.get(keyHash)
|
|
79
|
+
if (hit) return hit
|
|
80
|
+
}
|
|
81
|
+
let rows
|
|
82
|
+
try { rows = await runSql(SQL_SELECT_KEY, [keyHash]) } catch { throw err(503, 'apikey 校验失败') }
|
|
83
|
+
const row = rows?.[0]
|
|
84
|
+
const result = !row ? { ok: false, reason: 'malformed' }
|
|
85
|
+
: row.revoked_at != null ? { ok: false, reason: 'revoked' }
|
|
86
|
+
: Number(row.expires_at) <= nowSec() ? { ok: false, reason: 'expired' }
|
|
87
|
+
: { ok: true, auth: { uidRef: row.uid, scene: row.scene ?? '', keyId: row.key_id, keyPrefix: row.key_prefix, expiresAt: Number(row.expires_at) } }
|
|
88
|
+
if (result.ok) cache.set(keyHash, result)
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 凭证类能力共用:verify → 401 / 返回 uid(内部调用无缓存,避免读到过期判定)
|
|
93
|
+
async function uidOf(apikey) {
|
|
94
|
+
const v = await verify(apikey, { useCache: false })
|
|
95
|
+
if (!v.ok) throw err(401, 'apikey 无效')
|
|
96
|
+
return v.auth.uidRef
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const abilities = [
|
|
100
|
+
['auth.key.create', { describe: '签发新 apikey(当前 key 换 uid;scene 只存不用)', run: async (args = {}) => {
|
|
101
|
+
const uid = await uidOf(args.apikey)
|
|
102
|
+
const scene = typeof args.scene === 'string' ? args.scene : ''
|
|
103
|
+
let issued
|
|
104
|
+
try { issued = await issue(uid, { scene }) } catch { throw err(500, 'apikey 签发失败') }
|
|
105
|
+
return { apikey: issued.apikey, key_id: issued.key_id, key_prefix: issued.key_prefix, expires_at: issued.expires_at }
|
|
106
|
+
} }],
|
|
107
|
+
|
|
108
|
+
['auth.key.list', { describe: '当前用户的 key 档案列表(不含明文/key_hash)', run: async (args = {}) => {
|
|
109
|
+
const uid = await uidOf(args.apikey)
|
|
110
|
+
let rows
|
|
111
|
+
try { rows = await runSql(SQL_LIST_KEYS, [uid]) } catch { throw err(503, '查询 key 失败') }
|
|
112
|
+
return (rows ?? []).map(r => ({ ...r, expires_at: Number(r.expires_at) }))
|
|
113
|
+
} }],
|
|
114
|
+
|
|
115
|
+
['auth.key.revoke', { describe: '吊销 key(key_id 必须属于当前用户;0 行 → 404)', run: async (args = {}) => {
|
|
116
|
+
const uid = await uidOf(args.apikey)
|
|
117
|
+
if (!args.key_id) throw err(400, 'key_id 必填')
|
|
118
|
+
let rows
|
|
119
|
+
try { rows = await runSql(SQL_REVOKE_KEY, [args.key_id, uid]) } catch { throw err(503, '吊销 key 失败') }
|
|
120
|
+
const keyHash = rows?.[0]?.key_hash
|
|
121
|
+
if (!keyHash) throw err(404, 'key 不存在或不属于当前用户')
|
|
122
|
+
cache.clear(keyHash) // 写路径不读缓存;成功即失效该 key
|
|
123
|
+
return { revoked: true, key_id: args.key_id }
|
|
124
|
+
} }],
|
|
125
|
+
|
|
126
|
+
['auth.key.verify', { describe: '校验 apikey(唯一裁决路径,返回 verify 结果对象)', run: async (args = {}) => verify(args.apikey) }],
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
return { verify, issue, uidOf, abilities, cache }
|
|
130
|
+
}
|
package/src/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* auth.js — auth 域能力目录(
|
|
2
|
+
* auth.js — auth 域能力目录(13 条)+ 编排(登录链路/错误翻译 · spec §3.1–3.5 · §3.7)
|
|
3
3
|
*
|
|
4
4
|
* 端点(主场实测确认,{origin}=https://<envId>.api.tcloudbasegateway.com):
|
|
5
5
|
* POST /auth/v1/verification { phone | email } → 发验证码,返回 verification_token(响应原样)
|
|
@@ -10,20 +10,22 @@
|
|
|
10
10
|
* POST /auth/v1/user/reauthenticate(Bearer);GET /auth/v1/token/introspect(Bearer)
|
|
11
11
|
*
|
|
12
12
|
* 语义:
|
|
13
|
-
* · 透传三种(password/sms/email):body
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* · 透传三种(password/sms/email):body 原样发网关;uid 提取:payload.sub → payload.user.id →
|
|
14
|
+
* decodeJWT(access_token).sub(不验签,token 刚由网关签发);都取不到 → 500「用户注册失败」。建档失败 → 同 500。
|
|
15
|
+
* · 登录收尾(§3.2):建档 → 官方 token 托管(broker.upsert,失败 500)→ 签发 apikey(失败 500);
|
|
16
|
+
* 响应只含 { apikey, expires_at }(wxcode 另有 config),官方 token/uid 字段不出域。
|
|
16
17
|
* · 网关非 2xx:原样透传 { code: 网关状态, payload },不改写错误码(INVALID_USERNAME_OR_PASSWORD 原样可见);
|
|
17
18
|
* 网络异常(无 status)→ 502「登录请求失败」。
|
|
18
19
|
* · 自定义链路:customSignin 非 2xx → 502「自定义登录请求失败」;code2Session errcode 非零 → 400{errcode,errmsg}。
|
|
19
20
|
* · loginByWxcode:uid=唯一 id(wxResolveUid 缺省 identity:uid=openid),unionid 字段=微信原始值|null,不可互换。
|
|
20
21
|
*
|
|
21
|
-
* §3.12 补全: auth.signup
|
|
22
|
+
* §3.12 补全: auth.signup(手机/邮箱验证码注册,建档+托管+签发 key)/ auth.signOut(不写档,apikey 入参)。
|
|
22
23
|
* 自定义登录只有两个入口(钉死):loginByWxcode(配置的微信小程序 appid/secret,code→openid→唯一 id→ticket→signin/custom)
|
|
23
24
|
* 与 loginByUnionid(外部传唯一 id→ticket→signin/custom);二者都必须建档;unionid=我们的唯一 id,与微信 openid/unionid 语义无关。
|
|
24
25
|
*
|
|
25
26
|
* §3.13 uid 提取顺序(统一所有登录/注册类): payload.sub → payload.user.id → decodeJWT(access_token).sub →
|
|
26
|
-
* 无则登录/注册类 500
|
|
27
|
+
* 无则登录/注册类 500「用户注册失败」。凭证类能力(signOut/reauthenticate/introspect)入参 apikey:
|
|
28
|
+
* verify 不通过 → 401「apikey 无效」,通过后 broker.get(uid) 换官方 Bearer。
|
|
27
29
|
*
|
|
28
30
|
* §3.8 增补(4 条): auth.sendVerification(phone/email 二选一;sendSmsCode/sendEmailCode 保留薄别名)/
|
|
29
31
|
* auth.verifyCode / auth.reauthenticate(verify_opt ∈ phone_code|email_code)/ auth.introspect ——
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
*/
|
|
36
38
|
import cloudbase from '@cloudbase/node-sdk'
|
|
37
39
|
import { code2Session as wxCode2Session } from './wechat.js'
|
|
38
|
-
import { decodeJWT, throwGatewayError, makeGateway
|
|
40
|
+
import { decodeJWT, throwGatewayError, makeGateway } from './user.js'
|
|
39
41
|
import { withSchemas } from './schemas.js'
|
|
40
42
|
|
|
41
43
|
export { decodeJWT } // 供消费方/测试从包主入口取(真身在 user.js)
|
|
@@ -69,9 +71,14 @@ async function tcbCreateTicket(cfg, uid) {
|
|
|
69
71
|
return tcbAppCache.auth().createTicket(uid, TICKET_TTL)
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
/* —— 能力目录 + 编排(ability 闭包持有 cfg/inject/kit)—— */
|
|
74
|
+
/* —— 能力目录 + 编排(ability 闭包持有 cfg/inject/kit/deps)—— */
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
// 刷新请求器:broker 单飞刷新使用(inject.refreshTokenRequest 可替换)
|
|
77
|
+
export const makeRefreshTokenRequest = (cfg, kit) => (cfg.inject || {}).refreshTokenRequest
|
|
78
|
+
|| (body => kit.req(`https://${cfg.envId}.${cfg.domain || DEFAULT_DOMAIN}/auth/v1/token`, { method: 'POST', body }))
|
|
79
|
+
|
|
80
|
+
// deps(index.js 注入): broker(token 托管)/ verify(apikey 裁决)/ issue(签发新 key)/ refreshTokenRequest
|
|
81
|
+
export function createAuthAbilities(cfg, kit, deps = {}) {
|
|
75
82
|
const inject = cfg.inject || {}
|
|
76
83
|
const origin = `https://${cfg.envId}.${cfg.domain || DEFAULT_DOMAIN}`
|
|
77
84
|
const sendVerification = inject.sendVerification || (body => kit.req(`${origin}/auth/v1/verification`, { method: 'POST', body }))
|
|
@@ -80,7 +87,6 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
80
87
|
const code2Session = inject.code2Session || (code => wxCode2Session(cfg, code))
|
|
81
88
|
const wxResolveUid = inject.wxResolveUid || (wx => wx.openid) // 缺省 identity:uid=openid(现网语义)
|
|
82
89
|
const createTicket = inject.createTicket || (uid => tcbCreateTicket(cfg, uid))
|
|
83
|
-
const refreshTokenRequest = inject.refreshTokenRequest || (body => kit.req(`${origin}/auth/v1/token`, { method: 'POST', body }))
|
|
84
90
|
const fetchPrefixConfig = inject.fetchPrefixConfig || (() => kit.req(`${origin}/v1/storages/object/store/public/wx_mp_conf/prefix_config.json`, { headers: { 'Cache-Control': 'no-cache' } }))
|
|
85
91
|
const gateway = makeGateway(cfg, kit)
|
|
86
92
|
const gw = async (method, path, opts) => { try { return await gateway(method, path, opts) } catch (e) { throwGatewayError(e) } }
|
|
@@ -92,7 +98,8 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
92
98
|
const ticket = await createTicket(uid)
|
|
93
99
|
let res
|
|
94
100
|
try { res = await customSignin(ticket) } catch { throw err(502, '自定义登录请求失败') }
|
|
95
|
-
|
|
101
|
+
const { access_token: at, refresh_token: rt, expires_in } = res
|
|
102
|
+
return { at, rt, expires_in } // 仅内部传递;官方 token 不出域
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
// 建档统一翻 500「用户注册失败」
|
|
@@ -103,7 +110,27 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
103
110
|
// uid 提取四级顺序(§3.13):payload.sub → payload.user.id → decodeJWT(access_token).sub
|
|
104
111
|
const uidOfPayload = p => p?.sub || p?.user?.id || decodeJWT(p?.access_token).sub || null
|
|
105
112
|
|
|
106
|
-
//
|
|
113
|
+
// 登录收尾(§3.2):官方 token 托管落库 → 签发 key(托管不落库就不发 key,fail-closed)
|
|
114
|
+
async function issueSession(uid, tokens) {
|
|
115
|
+
const expiresAt = Math.floor(Date.now() / 1000) + (Number(tokens?.expires_in) || 0)
|
|
116
|
+
const at = tokens?.at ?? tokens?.access_token
|
|
117
|
+
const rt = tokens?.rt ?? tokens?.refresh_token
|
|
118
|
+
try { await deps.broker.upsert(uid, at, rt, expiresAt) }
|
|
119
|
+
catch { throw err(500, '官方凭证托管失败') }
|
|
120
|
+
let issued
|
|
121
|
+
try { issued = await deps.issue(uid) } catch { throw err(500, 'apikey 签发失败') }
|
|
122
|
+
return { apikey: issued.apikey, expires_at: issued.expires_at }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 凭证类统一序(§3.3):verify(apikey) → 401 → broker.get(uid) → 官方 Bearer 调用
|
|
126
|
+
async function sessionOf(apikey) {
|
|
127
|
+
const v = await deps.verify(apikey)
|
|
128
|
+
if (!v.ok) throw err(401, 'apikey 无效')
|
|
129
|
+
const { access_token: token } = await deps.broker.get(v.auth.uidRef)
|
|
130
|
+
return { uid: v.auth.uidRef, token } // 官方 token 只在内部/网关 Bearer 使用
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 透传三种共用:body 原样发网关 → uid → 建档 → 托管 + 签发 key
|
|
107
134
|
async function passthroughLogin(body, required) {
|
|
108
135
|
for (const field of required) {
|
|
109
136
|
if (!body?.[field]) throw err(400, `${field} 必填`)
|
|
@@ -113,7 +140,7 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
113
140
|
const uid = uidOfPayload(payload)
|
|
114
141
|
if (!uid) throw err(500, '用户注册失败')
|
|
115
142
|
await upsertOr500({ uid, profile: {} })
|
|
116
|
-
return
|
|
143
|
+
return issueSession(uid, payload)
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
return withSchemas([
|
|
@@ -141,16 +168,15 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
141
168
|
} }],
|
|
142
169
|
|
|
143
170
|
['auth.reauthenticate', { describe: '重认证发码:POST /auth/v1/user/reauthenticate(Bearer;verify_opt ∈ phone_code|email_code)', run: async (args = {}) => {
|
|
144
|
-
const {
|
|
145
|
-
uidFromToken(access_token)
|
|
171
|
+
const { apikey, verify_opt } = args
|
|
146
172
|
if (!['phone_code', 'email_code'].includes(verify_opt)) throw err(400, 'verify_opt 必填(phone_code|email_code)')
|
|
147
|
-
const {
|
|
148
|
-
return gw('POST', '/auth/v1/user/reauthenticate', { body:
|
|
173
|
+
const { token } = await sessionOf(apikey)
|
|
174
|
+
return gw('POST', '/auth/v1/user/reauthenticate', { body: { verify_opt }, token })
|
|
149
175
|
} }],
|
|
150
176
|
|
|
151
|
-
['auth.introspect', { describe: 'token 校验:GET /auth/v1/token/introspect(Bearer),网关原样(含 sub)', run: async ({
|
|
152
|
-
|
|
153
|
-
return gw('GET', '/auth/v1/token/introspect', { token
|
|
177
|
+
['auth.introspect', { describe: 'token 校验:GET /auth/v1/token/introspect(Bearer),网关原样(含 sub)', run: async ({ apikey } = {}) => {
|
|
178
|
+
const { token } = await sessionOf(apikey)
|
|
179
|
+
return gw('GET', '/auth/v1/token/introspect', { token })
|
|
154
180
|
} }],
|
|
155
181
|
|
|
156
182
|
['auth.loginByPassword', { describe: '账号密码登录(透传+建档):POST /auth/v1/signin { username, password },响应原样补 uid', run: async (args = {}) => passthroughLogin({ username: args.username, password: args.password }, ['username', 'password']) }],
|
|
@@ -167,14 +193,15 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
167
193
|
if (wx.errcode) throw err(400, wx.errmsg || 'code2Session 失败', { errcode: wx.errcode, errmsg: wx.errmsg || 'code2Session 失败' })
|
|
168
194
|
const uid = await wxResolveUid(wx)
|
|
169
195
|
await upsertOr500({ uid, profile: { openid: wx.openid, unionid: wx.unionid || null } })
|
|
170
|
-
|
|
196
|
+
const session = await issueSession(uid, await finishLogin(uid))
|
|
197
|
+
return { ...session, config: await fetchPrefixConfig() }
|
|
171
198
|
} }],
|
|
172
199
|
|
|
173
|
-
['auth.loginByUnionid', { describe: '开放登录:唯一 id 注册登录(建档→ticket→signin/custom
|
|
200
|
+
['auth.loginByUnionid', { describe: '开放登录:唯一 id 注册登录(建档→ticket→signin/custom→托管+签发 key)', run: async (args = {}) => {
|
|
174
201
|
const { unionid } = args
|
|
175
202
|
if (!unionid) throw err(400, '缺少 unionid')
|
|
176
203
|
await upsertOr500({ uid: unionid, profile: {} })
|
|
177
|
-
return
|
|
204
|
+
return issueSession(unionid, await finishLogin(unionid))
|
|
178
205
|
} }],
|
|
179
206
|
|
|
180
207
|
['auth.signup', { describe: '注册(手机/邮箱/provider_token 三形态):POST /auth/v1/signup,响应原样+补 uid+建档', run: async (args = {}) => {
|
|
@@ -190,21 +217,13 @@ export function createAuthAbilities(cfg, kit) {
|
|
|
190
217
|
if (args.email !== undefined) profile.email = args.email
|
|
191
218
|
if (args.phone_number !== undefined) profile.phone = args.phone_number
|
|
192
219
|
await upsertOr500({ uid, profile })
|
|
193
|
-
return
|
|
220
|
+
return issueSession(uid, payload)
|
|
194
221
|
} }],
|
|
195
222
|
|
|
196
223
|
['auth.signOut', { describe: '登出:POST /auth/v1/user/signout(Bearer),不写档', run: async (args = {}) => {
|
|
197
|
-
const {
|
|
198
|
-
|
|
199
|
-
return gw('POST', '/auth/v1/user/signout', { body: rest, token
|
|
200
|
-
} }],
|
|
201
|
-
|
|
202
|
-
['auth.refresh', { describe: 'refresh_token 换新令牌(3 字段裸对象)', run: async (args = {}) => {
|
|
203
|
-
const { refresh_token } = args
|
|
204
|
-
if (!refresh_token) throw err(400, '缺少 refresh_token')
|
|
205
|
-
let res
|
|
206
|
-
try { res = await refreshTokenRequest({ grant_type: 'refresh_token', refresh_token, client_id: cfg.envId }) } catch { throw err(502, '令牌刷新请求失败') }
|
|
207
|
-
return { access_token: res.access_token, refresh_token: res.refresh_token, expires_in: res.expires_in }
|
|
224
|
+
const { apikey, ...rest } = args ?? {}
|
|
225
|
+
const { token } = await sessionOf(apikey)
|
|
226
|
+
return gw('POST', '/auth/v1/user/signout', { body: rest, token })
|
|
208
227
|
} }],
|
|
209
228
|
])
|
|
210
229
|
}
|
package/src/index.js
CHANGED
|
@@ -5,33 +5,66 @@
|
|
|
5
5
|
* import { createKit } from '@blod-nm/user-kit'
|
|
6
6
|
* const kit = createKit({ envId, apiKey, wxAppid, wxSecret, tcbAuthCredentials, env?, inject?, signinProviders?, …透传 base-cb })
|
|
7
7
|
* await kit.ctx.run('pg.exec', { sql: 'select 1' }) // base-cb 网关 14 能力原样可用
|
|
8
|
-
* await kit.ctx.run('auth.
|
|
9
|
-
* kit.
|
|
8
|
+
* await kit.ctx.run('auth.loginByPassword', { username, password }) // → { apikey, expires_at }
|
|
9
|
+
* await kit.ctx.run('user.me', { apikey }) // 凭证面统一 apikey
|
|
10
|
+
* kit.key.verify({ apikey }) / kit.broker.get(uid) // key 域 / token 托管便捷入口
|
|
11
|
+
* kit.describe() // 能力元数据
|
|
10
12
|
*
|
|
11
|
-
* 数据流: cfg fail-fast → createBaseKit(cfg)(base-cb req/gate/ctx)→
|
|
12
|
-
*
|
|
13
|
+
* 数据流: cfg fail-fast → createBaseKit(cfg)(base-cb req/gate/ctx)→ runSql 单接缝(pg.exec)
|
|
14
|
+
* → broker(token 托管)/ key 域(签发+verify+缓存)→ auth/user 域闭包持 deps → 注册 auth.cbToken.get(总线消费 broker)
|
|
15
|
+
* → kit { ctx, req, cfg, broker, key, describe }
|
|
13
16
|
*
|
|
14
17
|
* cfg 约定(spec §3.0):
|
|
15
18
|
* 必填 envId / apiKey / wxAppid / wxSecret / tcbAuthCredentials(缺一即抛,fail-fast)
|
|
16
|
-
* env 缺省 'prod'(建档 tag from_env);
|
|
17
|
-
* domain/proxy/proxyBase/timeoutMs/role 透传 base-cb
|
|
19
|
+
* env 缺省 'prod'(建档 tag from_env);akDefaultKeyTtlSec 缺省 2592000(30 天)
|
|
20
|
+
* inject.* 九口(含 gateway)原样存 kit.cfg(缺省 = 真实现);domain/proxy/proxyBase/timeoutMs/role 透传 base-cb
|
|
18
21
|
*/
|
|
19
22
|
import { createKit as createBaseKit } from '@blod-nm/base-cb'
|
|
20
|
-
import { createAuthAbilities } from './auth.js'
|
|
23
|
+
import { createAuthAbilities, makeRefreshTokenRequest } from './auth.js'
|
|
21
24
|
import { createUserAbilities } from './user.js'
|
|
25
|
+
import { createKeyAbilities } from './access-key.js'
|
|
26
|
+
import { createCbTokenBroker, makeRunSql } from './token-broker.js'
|
|
27
|
+
import { withSchemas } from './schemas.js'
|
|
22
28
|
|
|
23
29
|
const REQUIRED = ['envId', 'apiKey', 'wxAppid', 'wxSecret', 'tcbAuthCredentials']
|
|
30
|
+
const DEFAULT_KEY_TTL_SEC = 2592000
|
|
24
31
|
|
|
25
32
|
export function createKit(cfg = {}) {
|
|
26
33
|
for (const field of REQUIRED) {
|
|
27
34
|
if (!cfg?.[field]) throw new Error(`user-kit: cfg 缺 ${field}`)
|
|
28
35
|
}
|
|
29
|
-
const finalCfg =
|
|
36
|
+
const finalCfg = { ...cfg, env: cfg.env || 'prod', akDefaultKeyTtlSec: cfg.akDefaultKeyTtlSec ?? DEFAULT_KEY_TTL_SEC }
|
|
30
37
|
const base = createBaseKit(finalCfg)
|
|
31
|
-
|
|
38
|
+
|
|
39
|
+
// §3.7 存储接缝 + §3.6 token 托管 + §3.5 key 域(verify 唯一裁决路径)
|
|
40
|
+
const runSql = makeRunSql(base)
|
|
41
|
+
const refreshTokenRequest = makeRefreshTokenRequest(finalCfg, base)
|
|
42
|
+
const broker = createCbTokenBroker({ cfg: finalCfg, refreshTokenRequest, runSql })
|
|
43
|
+
const keyDomain = createKeyAbilities({ cfg: finalCfg, runSql })
|
|
44
|
+
const deps = { broker, runSql, verify: keyDomain.verify, issue: keyDomain.issue, refreshTokenRequest }
|
|
45
|
+
|
|
46
|
+
// 宿主经总线消费 broker 的唯一通道(透传前 uid → 托管官方 token)
|
|
47
|
+
const cbToken = {
|
|
48
|
+
describe: '托管官方 token 兑换:uid → { access_token, expires_at, from_cache }(宿主透传前取 Bearer)',
|
|
49
|
+
run: async (args = {}) => {
|
|
50
|
+
if (!args?.uid) throw Object.assign(new Error('缺少 uid'), { code: 400 })
|
|
51
|
+
return broker.get(args.uid)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const [key, ability] of [...createAuthAbilities(finalCfg, base, deps), ...createUserAbilities(finalCfg, base, deps), ...keyDomain.abilities, ...withSchemas([['auth.cbToken.get', cbToken]])]) {
|
|
32
56
|
base.ctx.register(key, ability)
|
|
33
57
|
}
|
|
34
|
-
|
|
58
|
+
|
|
59
|
+
// key 域便捷入口(同 ctx 能力)
|
|
60
|
+
const key = {
|
|
61
|
+
create: args => base.ctx.run('auth.key.create', args),
|
|
62
|
+
list: args => base.ctx.run('auth.key.list', args),
|
|
63
|
+
revoke: args => base.ctx.run('auth.key.revoke', args),
|
|
64
|
+
verify: args => base.ctx.run('auth.key.verify', args),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ctx: base.ctx, req: base.req, cfg: finalCfg, broker, key, describe: () => base.ctx.describe() }
|
|
35
68
|
}
|
|
36
69
|
|
|
37
70
|
export default createKit
|
package/src/schemas.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
schemas.js — 能力面元数据(纯数据 · 纯对象 JSON Schema,不引 zod)
|
|
3
3
|
|
|
4
|
-
职责:
|
|
4
|
+
职责: 22 条在册能力(DC-06:旧 token 刷新能力已注销)的 title / description / auth / inputSchema / outputSchema / annotations。
|
|
5
5
|
能力实现(auth.js / user.js)只负责 run;约束与开放策略集中在这里,挂 MCP 时原样透传。
|
|
6
6
|
|
|
7
|
-
auth
|
|
8
|
-
'public'
|
|
9
|
-
'user' 需要
|
|
10
|
-
'self' 同 user,且 uid
|
|
7
|
+
auth 分类(DC-06:对外凭证统一 apikey;官方令牌由服务端换托管凭证注入 Authorization: Bearer):
|
|
8
|
+
'public' 开放接口,不校验凭证
|
|
9
|
+
'user' 需要 apikey(schema 只暴露 apikey 入参,官方令牌字段不出域)
|
|
10
|
+
'self' 同 user,且 uid 由 apikey 档案解析(只能操作自己的档案)
|
|
11
11
|
内部能力标 mcp:false —— 不注册为 MCP 工具(登录自动建档 / 部署动作)
|
|
12
12
|
|
|
13
13
|
取用: withSchemas([[key, ability], ...])
|
|
@@ -23,13 +23,14 @@ const obj = (properties = {}, { required = [], anyOf, additionalProperties = tru
|
|
|
23
23
|
...(anyOf ? { anyOf } : {}),
|
|
24
24
|
additionalProperties,
|
|
25
25
|
})
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// DC-06 契约:所有登录/注册只出 apikey(官方令牌不出域)
|
|
27
|
+
const apikeyField = str('apikey(必填;a1_ 前缀)')
|
|
28
|
+
const apikeyIn = { apikey: apikeyField }
|
|
29
|
+
const sessionProps = {
|
|
30
|
+
apikey: str('新签发的 apikey(必返;明文只出现这一次)'),
|
|
31
|
+
expires_at: { type: 'number', description: 'apikey 过期时间(unix 秒,必返)' },
|
|
30
32
|
}
|
|
31
|
-
const
|
|
32
|
-
const uidOut = { type: 'object', properties: { uid: str('唯一用户 id(必返)'), access_token: str('访问令牌(必返)') }, required: ['uid', 'access_token'] }
|
|
33
|
+
const sessionOut = { type: 'object', properties: { ...sessionProps }, required: ['apikey', 'expires_at'], additionalProperties: false }
|
|
33
34
|
|
|
34
35
|
export const SCHEMAS = {
|
|
35
36
|
/* ---------- auth:自定义登录(public) ---------- */
|
|
@@ -39,12 +40,9 @@ export const SCHEMAS = {
|
|
|
39
40
|
description: '用 wx.login 的 code 登录:自动建档并返回会话令牌与下发配置',
|
|
40
41
|
inputSchema: obj({ code: str('wx.login 得到的 code(必填)') }, { required: ['code'], additionalProperties: false }),
|
|
41
42
|
outputSchema: obj({
|
|
42
|
-
|
|
43
|
-
openid: str('微信 openid(必返)'),
|
|
44
|
-
unionid: { type: ['string', 'null'], description: '微信 unionid,可能为 null(必返)' },
|
|
45
|
-
...tokens,
|
|
43
|
+
...sessionProps,
|
|
46
44
|
config: { type: 'object', description: 'prefix_config(必返,客户端强依赖)' },
|
|
47
|
-
}, { required: ['
|
|
45
|
+
}, { required: ['apikey', 'expires_at', 'config'], additionalProperties: false }),
|
|
48
46
|
annotations: { openWorldHint: true },
|
|
49
47
|
},
|
|
50
48
|
'auth.loginByUnionid': {
|
|
@@ -52,11 +50,7 @@ export const SCHEMAS = {
|
|
|
52
50
|
title: '唯一 id 登录',
|
|
53
51
|
description: '用业务唯一 id 直接登录:自动建档并返回会话令牌',
|
|
54
52
|
inputSchema: obj({ unionid: str('调用方唯一用户 id(必填)') }, { required: ['unionid'], additionalProperties: false }),
|
|
55
|
-
outputSchema:
|
|
56
|
-
uid: str('唯一用户 id(必返)'),
|
|
57
|
-
unionid: str('唯一用户 id(必返)'),
|
|
58
|
-
...tokens,
|
|
59
|
-
}, { required: ['uid', 'unionid', 'access_token', 'refresh_token', 'expires_in'], additionalProperties: false }),
|
|
53
|
+
outputSchema: sessionOut,
|
|
60
54
|
annotations: { openWorldHint: true },
|
|
61
55
|
},
|
|
62
56
|
|
|
@@ -64,25 +58,25 @@ export const SCHEMAS = {
|
|
|
64
58
|
'auth.loginByPassword': {
|
|
65
59
|
auth: 'public',
|
|
66
60
|
title: '账号密码登录',
|
|
67
|
-
description: '
|
|
61
|
+
description: '账号密码登录:建档 + 托管官方令牌并签发 apikey',
|
|
68
62
|
inputSchema: obj({ username: str('用户名(必填)'), password: str('密码(必填)') }, { required: ['username', 'password'] }),
|
|
69
|
-
outputSchema:
|
|
63
|
+
outputSchema: sessionOut,
|
|
70
64
|
annotations: { openWorldHint: true },
|
|
71
65
|
},
|
|
72
66
|
'auth.loginBySms': {
|
|
73
67
|
auth: 'public',
|
|
74
68
|
title: '短信验证码登录',
|
|
75
|
-
description: '用 verifyCode 换来的 verification_token
|
|
69
|
+
description: '用 verifyCode 换来的 verification_token 登录:建档 + 托管官方令牌并签发 apikey',
|
|
76
70
|
inputSchema: obj({ verification_token: str('verifyCode 得到的令牌(必填)') }, { required: ['verification_token'] }),
|
|
77
|
-
outputSchema:
|
|
71
|
+
outputSchema: sessionOut,
|
|
78
72
|
annotations: { openWorldHint: true },
|
|
79
73
|
},
|
|
80
74
|
'auth.loginByEmail': {
|
|
81
75
|
auth: 'public',
|
|
82
76
|
title: '邮箱验证码登录',
|
|
83
|
-
description: '用 verifyCode 换来的 verification_token
|
|
77
|
+
description: '用 verifyCode 换来的 verification_token 登录(邮箱):建档 + 托管官方令牌并签发 apikey',
|
|
84
78
|
inputSchema: obj({ verification_token: str('verifyCode 得到的令牌(必填)') }, { required: ['verification_token'] }),
|
|
85
|
-
outputSchema:
|
|
79
|
+
outputSchema: sessionOut,
|
|
86
80
|
annotations: { openWorldHint: true },
|
|
87
81
|
},
|
|
88
82
|
'auth.signup': {
|
|
@@ -103,7 +97,7 @@ export const SCHEMAS = {
|
|
|
103
97
|
{ required: ['provider_token'] },
|
|
104
98
|
],
|
|
105
99
|
}),
|
|
106
|
-
outputSchema:
|
|
100
|
+
outputSchema: sessionOut,
|
|
107
101
|
annotations: { openWorldHint: true },
|
|
108
102
|
},
|
|
109
103
|
|
|
@@ -150,33 +144,28 @@ export const SCHEMAS = {
|
|
|
150
144
|
title: '敏感操作二次验证',
|
|
151
145
|
description: '改密码/注销前的二次验证:向当前用户发送验证码',
|
|
152
146
|
inputSchema: obj({
|
|
147
|
+
...apikeyIn,
|
|
153
148
|
verify_opt: { type: 'string', enum: ['phone_code', 'email_code'], description: '验证方式(必填)' },
|
|
154
|
-
}, { required: ['verify_opt'], additionalProperties: false }),
|
|
149
|
+
}, { required: ['apikey', 'verify_opt'], additionalProperties: false }),
|
|
155
150
|
outputSchema: obj({ verification_id: str('验证码会话 id(必返)') }),
|
|
156
151
|
},
|
|
157
152
|
'auth.introspect': {
|
|
158
153
|
auth: 'user',
|
|
159
154
|
title: '校验令牌',
|
|
160
|
-
description: '校验当前
|
|
161
|
-
inputSchema: obj({}, { additionalProperties: false }),
|
|
155
|
+
description: '校验当前 apikey 对应的官方令牌是否有效,返回用户 sub',
|
|
156
|
+
inputSchema: obj({ ...apikeyIn }, { required: ['apikey'], additionalProperties: false }),
|
|
162
157
|
outputSchema: obj({ sub: str('令牌对应的用户 id'), active: { type: 'boolean', description: '是否有效' } }),
|
|
163
158
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
164
159
|
},
|
|
165
|
-
'auth.refresh': {
|
|
166
|
-
auth: 'public',
|
|
167
|
-
title: '刷新令牌',
|
|
168
|
-
description: '用 refresh_token 换一对新令牌',
|
|
169
|
-
inputSchema: obj({ refresh_token: str('刷新令牌(必填)') }, { required: ['refresh_token'], additionalProperties: false }),
|
|
170
|
-
outputSchema: tokenOut,
|
|
171
|
-
},
|
|
172
160
|
'auth.signOut': {
|
|
173
161
|
auth: 'user',
|
|
174
162
|
title: '退出登录',
|
|
175
163
|
description: '退出当前会话(令牌作废)',
|
|
176
164
|
inputSchema: obj({
|
|
165
|
+
...apikeyIn,
|
|
177
166
|
client_id: str('客户端 id(可选,默认环境 id)'),
|
|
178
167
|
x_device_id: str('设备 id(可选)'),
|
|
179
|
-
}, { additionalProperties: true }),
|
|
168
|
+
}, { required: ['apikey'], additionalProperties: true }),
|
|
180
169
|
outputSchema: { type: 'object', description: '网关响应(通常为空对象)' },
|
|
181
170
|
annotations: { destructiveHint: true },
|
|
182
171
|
},
|
|
@@ -244,7 +233,7 @@ export const SCHEMAS = {
|
|
|
244
233
|
auth: 'user',
|
|
245
234
|
title: '当前用户',
|
|
246
235
|
description: '读取网关侧当前用户信息(资料 / 绑定 / 角色)',
|
|
247
|
-
inputSchema: obj({}, { additionalProperties: false }),
|
|
236
|
+
inputSchema: obj({ ...apikeyIn }, { required: ['apikey'], additionalProperties: false }),
|
|
248
237
|
outputSchema: { type: 'object', description: '网关用户信息(原样)' },
|
|
249
238
|
annotations: { readOnlyHint: true },
|
|
250
239
|
},
|
|
@@ -253,6 +242,7 @@ export const SCHEMAS = {
|
|
|
253
242
|
title: '修改资料(含手机号/邮箱)',
|
|
254
243
|
description: '修改网关侧用户资料;改手机号/邮箱需先 reauthenticate 拿验证码令牌;成功后自动同步本地档案',
|
|
255
244
|
inputSchema: obj({
|
|
245
|
+
...apikeyIn,
|
|
256
246
|
nickname: str('昵称(可选)'),
|
|
257
247
|
username: str('用户名(可选)'),
|
|
258
248
|
phone: str('手机号(可选,修改需验证码)'),
|
|
@@ -261,7 +251,7 @@ export const SCHEMAS = {
|
|
|
261
251
|
gender: { type: 'string', enum: ['MALE', 'FEMALE'], description: '性别(可选)' },
|
|
262
252
|
description: str('个人简介(可选)'),
|
|
263
253
|
verification_token: str('手机号/邮箱变更的验证码令牌(可选,改手机/邮箱时需要)'),
|
|
264
|
-
}, { additionalProperties: true }),
|
|
254
|
+
}, { required: ['apikey'], additionalProperties: true }),
|
|
265
255
|
outputSchema: { type: 'object', description: '网关响应(原样)' },
|
|
266
256
|
},
|
|
267
257
|
'user.changePassword': {
|
|
@@ -269,9 +259,10 @@ export const SCHEMAS = {
|
|
|
269
259
|
title: '修改密码',
|
|
270
260
|
description: '修改当前用户密码(需先 reauthenticate 二次验证)',
|
|
271
261
|
inputSchema: obj({
|
|
262
|
+
...apikeyIn,
|
|
272
263
|
password: str('新密码(必填)'),
|
|
273
264
|
verification_token: str('二次验证令牌(可选,按网关要求)'),
|
|
274
|
-
}, { required: ['password'] }),
|
|
265
|
+
}, { required: ['apikey', 'password'] }),
|
|
275
266
|
outputSchema: { type: 'object', description: '网关响应(通常为空对象)' },
|
|
276
267
|
annotations: { destructiveHint: true },
|
|
277
268
|
},
|
|
@@ -280,11 +271,62 @@ export const SCHEMAS = {
|
|
|
280
271
|
title: '注销账号',
|
|
281
272
|
description: '注销当前用户(需先 reauthenticate 二次验证)',
|
|
282
273
|
inputSchema: obj({
|
|
274
|
+
...apikeyIn,
|
|
283
275
|
verification_token: str('二次验证令牌(可选,按网关要求)'),
|
|
284
|
-
}, { additionalProperties: true }),
|
|
276
|
+
}, { required: ['apikey'], additionalProperties: true }),
|
|
285
277
|
outputSchema: { type: 'object', description: '网关响应(通常为空对象)' },
|
|
286
278
|
annotations: { destructiveHint: true },
|
|
287
279
|
},
|
|
280
|
+
|
|
281
|
+
/* ---------- key 域(DC-06)+ cbToken(mcp:false:宿主能力,挂 MCP = 可冒充任意 uid 兑官方 token) ---------- */
|
|
282
|
+
'auth.key.create': {
|
|
283
|
+
auth: 'user',
|
|
284
|
+
title: '签发新 apikey',
|
|
285
|
+
description: '用当前 apikey 换 uid 再签发一个新 apikey(多设备/多场景);key 明文只在本次响应出现一次',
|
|
286
|
+
inputSchema: obj({ apikey: str('当前 apikey(必填)'), scene: str('场景标签(可选,缺省空串)') }, { required: ['apikey'], additionalProperties: false }),
|
|
287
|
+
outputSchema: obj({
|
|
288
|
+
apikey: str('新 apikey 明文(必返,仅此一次)'),
|
|
289
|
+
key_id: str('key id(吊销/list 句柄)'),
|
|
290
|
+
key_prefix: str('前 11 字符识别段(不含敏感信息)'),
|
|
291
|
+
expires_at: { type: 'number', description: '过期 unix 秒' },
|
|
292
|
+
}, { required: ['apikey', 'key_id', 'key_prefix', 'expires_at'], additionalProperties: false }),
|
|
293
|
+
},
|
|
294
|
+
'auth.key.list': {
|
|
295
|
+
auth: 'user',
|
|
296
|
+
title: '列出我的 apikey',
|
|
297
|
+
description: '当前 apikey 所属用户的全部 key 档案(不含 key 明文)',
|
|
298
|
+
inputSchema: obj({ apikey: str('当前 apikey(必填)') }, { required: ['apikey'], additionalProperties: false }),
|
|
299
|
+
outputSchema: { type: 'array', description: 'key 档案行数组(key_id/key_prefix/scene/created_at/expires_at/revoked_at)' },
|
|
300
|
+
annotations: { readOnlyHint: true },
|
|
301
|
+
},
|
|
302
|
+
'auth.key.revoke': {
|
|
303
|
+
auth: 'user',
|
|
304
|
+
title: '吊销 apikey',
|
|
305
|
+
description: '吊销本用户名下的指定 key(即时生效)',
|
|
306
|
+
inputSchema: obj({ apikey: str('当前 apikey(必填)'), key_id: str('要吊销的 key id(必填)') }, { required: ['apikey', 'key_id'], additionalProperties: false }),
|
|
307
|
+
outputSchema: obj({ revoked: { type: 'boolean', description: '是否成功' }, key_id: str('被吊销的 key id') }, { required: ['revoked', 'key_id'], additionalProperties: false }),
|
|
308
|
+
annotations: { destructiveHint: true },
|
|
309
|
+
},
|
|
310
|
+
'auth.key.verify': {
|
|
311
|
+
auth: 'public',
|
|
312
|
+
title: '验证 apikey',
|
|
313
|
+
description: '验证 apikey 合法性:通过返回 { ok, auth },失败返回 { ok:false, reason }',
|
|
314
|
+
inputSchema: obj({ apikey: str('待验证的 apikey(必填)') }, { required: ['apikey'], additionalProperties: false }),
|
|
315
|
+
outputSchema: { type: 'object', description: 'verify 结果(ok/auth 或 ok/reason)' },
|
|
316
|
+
annotations: { readOnlyHint: true },
|
|
317
|
+
},
|
|
318
|
+
'auth.cbToken.get': {
|
|
319
|
+
mcp: false,
|
|
320
|
+
auth: 'user',
|
|
321
|
+
title: '官方 token 托管兑换',
|
|
322
|
+
description: 'uid → 托管官方 access_token(宿主透传专用;不挂 MCP)',
|
|
323
|
+
inputSchema: obj({ uid: uidField }, { required: ['uid'], additionalProperties: false }),
|
|
324
|
+
outputSchema: obj({
|
|
325
|
+
access_token: str('官方 access_token'),
|
|
326
|
+
expires_at: { type: 'number', description: '过期 unix 秒' },
|
|
327
|
+
from_cache: { type: 'boolean', description: '是否命中托管缓存' },
|
|
328
|
+
}, { required: ['access_token', 'expires_at', 'from_cache'], additionalProperties: false }),
|
|
329
|
+
},
|
|
288
330
|
}
|
|
289
331
|
|
|
290
332
|
/** 把元数据合并进 [key, ability] 列表(能力对象原样保留 describe/run) */
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token-broker.js — 官方 token 托管(单飞刷新 · spec §3.6 · §3.7)
|
|
3
|
+
*
|
|
4
|
+
* 职责: get(uid) 查 __user_token_cache → 未过期直返;过期则单飞刷新(实例内 Map<uid, Promise>)→ 回写;
|
|
5
|
+
* upsert(uid, access_token, refresh_token, expiresAt) 供登录收尾落库;markInvalid(uid) 删行。
|
|
6
|
+
* 失败语义: 刷新失败/无行 → 删行 + 401「官方凭证已失效,请重新登录」(fail-closed,refresh_token 不出域)。
|
|
7
|
+
* 接缝: runSql=kit.ctx.run('pg.exec',…) 封装(§3.7 唯一表读写入口;未来换后端只换适配器)。
|
|
8
|
+
* 禁止: 档案表(__user_access_key)读写——那是 access-key.js 的地盘。
|
|
9
|
+
*/
|
|
10
|
+
const SQL_SELECT = 'SELECT uid, access_token, refresh_token, expires_at FROM __user_token_cache WHERE uid=$1'
|
|
11
|
+
const SQL_UPSERT = `INSERT INTO __user_token_cache (uid, access_token, refresh_token, expires_at)
|
|
12
|
+
VALUES ($1, $2, $3, $4)
|
|
13
|
+
ON CONFLICT (uid) DO UPDATE SET access_token = EXCLUDED.access_token,
|
|
14
|
+
refresh_token = EXCLUDED.refresh_token, expires_at = EXCLUDED.expires_at, updated_at = now()`
|
|
15
|
+
const SQL_DELETE = 'DELETE FROM __user_token_cache WHERE uid=$1'
|
|
16
|
+
|
|
17
|
+
const err = (code, message) => Object.assign(new Error(message), { code })
|
|
18
|
+
const nowSec = () => Math.floor(Date.now() / 1000)
|
|
19
|
+
|
|
20
|
+
/** pg 适配器:全包唯一表读写接缝(kit.ctx.run('pg.exec') + cloudbase_postgres 角色) */
|
|
21
|
+
export const makeRunSql = kit => (sql, parameters = []) =>
|
|
22
|
+
kit.ctx.run('pg.exec', { sql, parameters, role: 'cloudbase_postgres' })
|
|
23
|
+
|
|
24
|
+
export function createCbTokenBroker({ cfg, refreshTokenRequest, runSql }) {
|
|
25
|
+
const inflight = new Map() // uid → Promise(实例内单飞;跨实例靠 DB 幂等写兜底)
|
|
26
|
+
|
|
27
|
+
// 单飞刷新:失败删行 + 401;成功回写(expires_at=now+expires_in)
|
|
28
|
+
async function refresh(uid, row) {
|
|
29
|
+
let res
|
|
30
|
+
try {
|
|
31
|
+
res = await refreshTokenRequest({ grant_type: 'refresh_token', refresh_token: row.refresh_token, client_id: cfg.envId })
|
|
32
|
+
} catch {
|
|
33
|
+
await runSql(SQL_DELETE, [uid]).catch(() => {})
|
|
34
|
+
throw err(401, '官方凭证已失效,请重新登录')
|
|
35
|
+
}
|
|
36
|
+
const expiresAt = nowSec() + (Number(res?.expires_in) || 0)
|
|
37
|
+
await runSql(SQL_UPSERT, [uid, res.access_token, res.refresh_token, expiresAt])
|
|
38
|
+
return { access_token: res.access_token, expires_at: expiresAt, from_cache: false }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function get(uid) {
|
|
42
|
+
const rows = await runSql(SQL_SELECT, [uid])
|
|
43
|
+
const row = rows?.[0]
|
|
44
|
+
if (row && Number(row.expires_at) > nowSec()) {
|
|
45
|
+
return { access_token: row.access_token, expires_at: Number(row.expires_at), from_cache: true }
|
|
46
|
+
}
|
|
47
|
+
if (!row) throw err(401, '官方凭证已失效,请重新登录') // 无行 = 从未托管/已失效,无可刷新
|
|
48
|
+
if (inflight.has(uid)) return inflight.get(uid)
|
|
49
|
+
const task = refresh(uid, row).finally(() => inflight.delete(uid))
|
|
50
|
+
inflight.set(uid, task)
|
|
51
|
+
return task
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const upsert = async (uid, access_token, refresh_token, expiresAt) => {
|
|
55
|
+
await runSql(SQL_UPSERT, [uid, access_token, refresh_token, expiresAt])
|
|
56
|
+
return { uid, expires_at: expiresAt }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const markInvalid = async uid => { await runSql(SQL_DELETE, [uid]); return { uid } }
|
|
60
|
+
|
|
61
|
+
return { get, upsert, markInvalid }
|
|
62
|
+
}
|
package/src/user.js
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
*
|
|
15
15
|
* token 面(4 条,网关透传不裁剪;inject.gateway(method, path, { body, token }) 统一覆盖):
|
|
16
16
|
* user.me — GET;user.updateBasic — POST /auth/v1/user/basic/edit(body 只发传入字段,
|
|
17
|
-
* 成功 → patchProfile({ uid:
|
|
18
|
-
* user.changePassword PATCH /auth/v1/user/password;user.deleteMe DELETE /auth/v1/user/me
|
|
17
|
+
* 成功 → patchProfile({ uid: verify.uidRef, patch: 可入档字段 }),verification_token 不入档);
|
|
18
|
+
* user.changePassword PATCH /auth/v1/user/password;user.deleteMe DELETE /auth/v1/user/me。
|
|
19
19
|
*
|
|
20
|
-
* 硬规则(§3.
|
|
21
|
-
*
|
|
20
|
+
* 硬规则(§3.3): token 类能力入参 access_token → apikey;统一序 verify(apikey) → 401「apikey 无效」→
|
|
21
|
+
* broker.get(uid) → 官方 Bearer;**不接收调用方传 uid** 防越权。changePassword/deleteMe 不自动写档。
|
|
22
22
|
*
|
|
23
23
|
* 数据流: 入参(uid 校验/token 解码)→ gateway 网关原样 / build*Query → ctx.run('pg.exec',
|
|
24
24
|
* { sql, parameters, role:'cloudbase_postgres' });网关非 2xx → { code: 网关状态, payload } 原样,
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { withSchemas } from './schemas.js'
|
|
30
|
+
import { DDL_USER_TOKEN_CACHE, DDL_USER_ACCESS_KEY, DDL_USER_ACCESS_KEY_INDEX } from './access-key.js'
|
|
30
31
|
|
|
31
32
|
const DEFAULT_DOMAIN = 'api.tcloudbasegateway.com'
|
|
32
33
|
|
|
@@ -115,8 +116,8 @@ export function buildPatchProfileQuery(uid, patch) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
/* —— schema 面(§3.11
|
|
119
|
-
const PROBE_TABLE = `select to_regclass('public.crm_user_detail') is not null as "
|
|
119
|
+
/* —— schema 面(§3.11 建档表 + §3.1 两张新表;DDL 全 if not exists,先探后建)—— */
|
|
120
|
+
const PROBE_TABLE = `select to_regclass('public.crm_user_detail') is not null as "table", to_regclass('public.__user_token_cache') is not null as "token_cache", to_regclass('public.__user_access_key') is not null as "access_key"`
|
|
120
121
|
const PROBE_INDEXES = `select indexname from pg_indexes where schemaname='public' and tablename='crm_user_detail'`
|
|
121
122
|
const PROBE_PKEY = `select 1 from pg_constraint where conrelid='public.crm_user_detail'::regclass and contype='p'`
|
|
122
123
|
const DDL_TABLE = `create table if not exists public.crm_user_detail (id uuid default gen_random_uuid() not null, ns text[] not null, data jsonb default '{}'::jsonb not null, ext jsonb, tag text[] default '{}'::text[], status text default 'active' not null, created_at timestamptz default now() not null, updated_at timestamptz default now() not null, deleted_at timestamptz)`
|
|
@@ -133,16 +134,27 @@ const INDEX_ORDER = ['idx_crm_user_detail_ns_unique', 'idx_crm_user_detail_ns',
|
|
|
133
134
|
const EDIT_BODY = ['nickname', 'username', 'phone', 'email', 'avatar_url', 'gender', 'description', 'verification_token']
|
|
134
135
|
const ARCHIVABLE = ['nickname', 'username', 'phone', 'email', 'avatar_url', 'gender', 'description']
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
// deps(index.js 注入): verify(apikey 裁决)/ broker(token 托管,官方 Bearer 来源)
|
|
138
|
+
export function createUserAbilities(cfg, kit, deps = {}) {
|
|
137
139
|
const gateway = makeGateway(cfg, kit)
|
|
138
140
|
// 网关调用统一翻错(非 2xx 原样 / 网络 502)
|
|
139
141
|
const gw = async (method, path, opts) => { try { return await gateway(method, path, opts) } catch (e) { throwGatewayError(e) } }
|
|
142
|
+
// 凭证类统一序(§3.3):verify(apikey) → 401 → broker.get(uid) → 官方 Bearer
|
|
143
|
+
const sessionOf = async apikey => {
|
|
144
|
+
const v = await deps.verify(apikey)
|
|
145
|
+
if (!v.ok) throw Object.assign(new Error('apikey 无效'), { code: 401 })
|
|
146
|
+
const token = await deps.broker.get(v.auth.uidRef)
|
|
147
|
+
return { uid: v.auth.uidRef, access_token: token.access_token }
|
|
148
|
+
}
|
|
140
149
|
const pg = q => kit.ctx.run('pg.exec', { sql: q.sql, parameters: q.parameters, role: 'cloudbase_postgres' })
|
|
141
150
|
// 探测:表在不在 / 四索引在不在 / 主键在不在(只读,三次 pg.exec)
|
|
142
151
|
const probe = async () => {
|
|
143
152
|
const [t, i, k] = [await pg({ sql: PROBE_TABLE, parameters: [] }), await pg({ sql: PROBE_INDEXES, parameters: [] }), await pg({ sql: PROBE_PKEY, parameters: [] })]
|
|
144
153
|
const names = new Set(i.map(r => r.indexname))
|
|
145
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
table: !!t[0]?.table, tokenCache: !!t[0]?.token_cache, accessKey: !!t[0]?.access_key,
|
|
156
|
+
indexes: Object.fromEntries(INDEX_ORDER.map(n => [n, names.has(n)])), primaryKey: k.length > 0,
|
|
157
|
+
}
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
return withSchemas([
|
|
@@ -171,17 +183,17 @@ export function createUserAbilities(cfg, kit) {
|
|
|
171
183
|
throw Object.assign(new Error('用户不可用'), { code: 403 })
|
|
172
184
|
} }],
|
|
173
185
|
|
|
174
|
-
/* token
|
|
175
|
-
['user.me', { describe: '当前用户(资料/绑定/角色):GET /auth/v1/user/me,网关原样', run: async ({
|
|
176
|
-
|
|
186
|
+
/* token 面(网关透传;身份 = apikey → verify.uidRef → broker 托管 token) */
|
|
187
|
+
['user.me', { describe: '当前用户(资料/绑定/角色):GET /auth/v1/user/me,网关原样', run: async ({ apikey } = {}) => {
|
|
188
|
+
const { access_token } = await sessionOf(apikey)
|
|
177
189
|
return gw('GET', '/auth/v1/user/me', { token: access_token })
|
|
178
190
|
} }],
|
|
179
191
|
|
|
180
192
|
['user.updateBasic', { describe: '资料/手机/邮箱变更:POST /auth/v1/user/basic/edit(body 只发传入字段)→ 成功 patchProfile', run: async (args = {}) => {
|
|
181
|
-
const uid =
|
|
193
|
+
const { uid, access_token } = await sessionOf(args.apikey)
|
|
182
194
|
const body = {}
|
|
183
195
|
for (const k of EDIT_BODY) if (args[k] !== undefined) body[k] = args[k]
|
|
184
|
-
const payload = await gw('POST', '/auth/v1/user/basic/edit', { body, token:
|
|
196
|
+
const payload = await gw('POST', '/auth/v1/user/basic/edit', { body, token: access_token })
|
|
185
197
|
const patch = {}
|
|
186
198
|
for (const k of ARCHIVABLE) if (k in body) patch[k] = body[k] // verification_token 不入档
|
|
187
199
|
await kit.ctx.run('user.patchProfile', { uid, patch })
|
|
@@ -189,14 +201,14 @@ export function createUserAbilities(cfg, kit) {
|
|
|
189
201
|
} }],
|
|
190
202
|
|
|
191
203
|
['user.changePassword', { describe: '改密(前置 reauthenticate):PATCH /auth/v1/user/password,网关原样(不写档)', run: async (args = {}) => {
|
|
192
|
-
const {
|
|
193
|
-
|
|
204
|
+
const { apikey, ...rest } = args
|
|
205
|
+
const { access_token } = await sessionOf(apikey)
|
|
194
206
|
return gw('PATCH', '/auth/v1/user/password', { body: rest, token: access_token })
|
|
195
207
|
} }],
|
|
196
208
|
|
|
197
209
|
['user.deleteMe', { describe: '删号(前置 reauthenticate):DELETE /auth/v1/user/me,网关原样(不自行标删/不写档)', run: async (args = {}) => {
|
|
198
|
-
const {
|
|
199
|
-
|
|
210
|
+
const { apikey, ...rest } = args
|
|
211
|
+
const { access_token } = await sessionOf(apikey)
|
|
200
212
|
return gw('DELETE', '/auth/v1/user/me', { body: rest, token: access_token })
|
|
201
213
|
} }],
|
|
202
214
|
|
|
@@ -207,6 +219,8 @@ export function createUserAbilities(cfg, kit) {
|
|
|
207
219
|
const before = await probe()
|
|
208
220
|
const plan = []
|
|
209
221
|
if (!before.table) plan.push(DDL_TABLE)
|
|
222
|
+
if (!before.tokenCache) plan.push(DDL_USER_TOKEN_CACHE) // §3.1 token 托管表
|
|
223
|
+
if (!before.accessKey) plan.push(DDL_USER_ACCESS_KEY, DDL_USER_ACCESS_KEY_INDEX) // §3.1 key 档案表 + uid 索引
|
|
210
224
|
if (!before.primaryKey) plan.push(DDL_PKEY) // 主键仅缺失时补(alter 无 if not exists)
|
|
211
225
|
const missing = INDEX_ORDER.filter(n => !before.indexes[n])
|
|
212
226
|
for (const name of missing) plan.push(DDL_INDEXES[name])
|
|
@@ -215,7 +229,13 @@ export function createUserAbilities(cfg, kit) {
|
|
|
215
229
|
throw e?.status ? Object.assign(new Error(e.message), { code: e.status, payload: e.payload }) : e // 原样透传
|
|
216
230
|
}
|
|
217
231
|
}
|
|
218
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
table: before.table ? 'exists' : 'created',
|
|
234
|
+
tokenCache: before.tokenCache ? 'exists' : 'created',
|
|
235
|
+
accessKey: before.accessKey ? 'exists' : 'created',
|
|
236
|
+
indexes: { created: missing, existing: INDEX_ORDER.filter(n => !missing.includes(n)) },
|
|
237
|
+
status: await probe(),
|
|
238
|
+
}
|
|
219
239
|
} }],
|
|
220
240
|
])
|
|
221
241
|
}
|