@yixinshuke/node-auth 0.1.0 → 0.1.1
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/auth-context.ts +19 -2
- package/src/index.ts +3 -0
- package/src/login-origin.ts +35 -0
- package/src/open-client.ts +36 -0
- package/src/request-sign.ts +9 -1
package/package.json
CHANGED
package/src/auth-context.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bearerOf, parseCookies, ACCESS_TOKEN_COOKIE, setAuthCookie, clearAuthCookie, setRefreshCookie, clearRefreshCookie, verifyTokenDom, setTokenCookieNames } from "./jwt.js";
|
|
2
2
|
import { isPublicPrefix } from "./config.js";
|
|
3
|
+
import { runWithAuditContext, setAuditRemark } from "./login-origin.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 鉴权中间件(Fastify 应用级 hook)—— **强制鉴权** + 把「可信身份」原样注入请求体。
|
|
@@ -33,6 +34,12 @@ export interface ServiceIdentity {
|
|
|
33
34
|
appId: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** 保留验签与授权的具体拒绝原因,避免被宿主统一压成“签名无效”。 */
|
|
38
|
+
export interface ServiceAuthFailure {
|
|
39
|
+
code: number;
|
|
40
|
+
error: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
/**
|
|
37
44
|
* 路径是否命中某条服务通道前缀(精确匹配或以 `前缀/` 开头)。
|
|
38
45
|
* 导出是为了让宿主/测试能复用同一套匹配语义,避免各自写 startsWith 走偏。
|
|
@@ -50,6 +57,10 @@ export function isServicePath(path: string, prefixes: string[]): boolean {
|
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export interface AuthHookOptions {
|
|
60
|
+
/** 仅用已验签 JWT 生成审计备注,组件不预设业务字段。 */
|
|
61
|
+
auditRemarkOf?: (payload: Record<string, unknown>) => string;
|
|
62
|
+
/** 宿主可显式信任指定服务传递的审计备注;只记来源,不授予用户权限。 */
|
|
63
|
+
serviceAuditRemarkOf?: (service: ServiceIdentity, signedBody: any) => string;
|
|
53
64
|
/**
|
|
54
65
|
* **强写字段名数组(通用化核心)**:声明本服务要把 token 里哪些字段「原样拷进请求体、覆盖客户端传值」。
|
|
55
66
|
* 字段数量与名称完全由调用方决定,本包不硬编码任何业务列名。
|
|
@@ -69,7 +80,7 @@ export interface AuthHookOptions {
|
|
|
69
80
|
* 服务通道验签器:成功返回服务身份,失败返回 null。
|
|
70
81
|
* 宿主通常用它自己拼装 request-sign 的 `verifySignedRequest`。
|
|
71
82
|
*/
|
|
72
|
-
resolveService?: (req: any) => Promise<ServiceIdentity | null>;
|
|
83
|
+
resolveService?: (req: any) => Promise<ServiceIdentity | ServiceAuthFailure | null>;
|
|
73
84
|
/** 大屏观测端点 /_sys 是否需授权(默认 false 公开) */
|
|
74
85
|
obsRequireAuth?: boolean;
|
|
75
86
|
/**
|
|
@@ -129,6 +140,7 @@ export function isPublicPath(method: string, url: string, cmd: string, obsRequir
|
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
export function registerAuthHook(app: any, opts: AuthHookOptions): void {
|
|
143
|
+
app.addHook("onRequest", (_request: any, _reply: any, done: () => void) => runWithAuditContext(done));
|
|
132
144
|
const obsRequireAuth = opts.obsRequireAuth ?? false;
|
|
133
145
|
// 调试面板 GET 公开开关:dev/test 放开 GET 便于地址栏调试;prod 强制 GET 鉴权
|
|
134
146
|
allowPublicApiGet = opts.publicApiGet === true;
|
|
@@ -187,6 +199,10 @@ export function registerAuthHook(app: any, opts: AuthHookOptions): void {
|
|
|
187
199
|
try {
|
|
188
200
|
const svc = opts.resolveService ? await opts.resolveService(req) : null;
|
|
189
201
|
if (!svc) return deny(401, "服务调用鉴权失败:AppId 或签名无效");
|
|
202
|
+
if ("error" in svc) {
|
|
203
|
+
return deny(svc.code, `服务调用鉴权失败:path=${reqPath} cmd=${cmd} appId=${String(body?.appId ?? "")};${svc.error}`);
|
|
204
|
+
}
|
|
205
|
+
setAuditRemark(opts.serviceAuditRemarkOf?.(svc, body) ?? "");
|
|
190
206
|
// 挂到 request 上,供 apirouter 装载器透传给业务 handler 的 ctx.service(绕过 zod strip)
|
|
191
207
|
(req as any).__service = { appId: String(svc.appId ?? "") };
|
|
192
208
|
if (body && typeof body === "object") {
|
|
@@ -201,7 +217,7 @@ export function registerAuthHook(app: any, opts: AuthHookOptions): void {
|
|
|
201
217
|
} catch (e) {
|
|
202
218
|
// fail-closed:验签链路异常(密钥库不可用等)无法确认身份 → 拒绝,绝不放行
|
|
203
219
|
console.error("[auth] 服务调用鉴权异常,已拒绝请求:", (e as Error).message);
|
|
204
|
-
return deny(503,
|
|
220
|
+
return deny(503, `服务调用鉴权异常:path=${reqPath} cmd=${cmd};${(e as Error).message}`);
|
|
205
221
|
}
|
|
206
222
|
}
|
|
207
223
|
|
|
@@ -215,6 +231,7 @@ export function registerAuthHook(app: any, opts: AuthHookOptions): void {
|
|
|
215
231
|
if (!v || v.payload.typ !== "access") return deny(401, "登录已过期或令牌无效,请重新登录");
|
|
216
232
|
const payload = v.payload;
|
|
217
233
|
const dom = v.dom;
|
|
234
|
+
setAuditRemark(opts.auditRemarkOf?.(payload) ?? "");
|
|
218
235
|
|
|
219
236
|
if (body && typeof body === "object") {
|
|
220
237
|
// 通用强写:token payload 中同名 key 原样覆盖客户端传值,防冒充(本包不识任何业务列名)
|
package/src/index.ts
CHANGED
|
@@ -5,3 +5,6 @@ export * from "./request-sign.js";
|
|
|
5
5
|
export { configureAuth, authConfig, canSign, canVerify } from "./config.js";
|
|
6
6
|
export type { AuthConfigInput } from "./config.js";
|
|
7
7
|
export * from "./permissions.js";
|
|
8
|
+
export * from "./open-client.js";
|
|
9
|
+
export { loginOriginOf, splitDelegatedCredential, runWithAuditContext, setAuditRemark, getAuditRemark, appendAuditRemark, type LoginOrigin } from "./login-origin.js";
|
|
10
|
+
export { withinDelegatedScope } from "./login-origin.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/** 已验证的会话来源快照;不参与当前用户的授权。业务含义及备注由签发方提供。 */
|
|
4
|
+
export interface LoginOrigin {
|
|
5
|
+
actorId: number;
|
|
6
|
+
actorLogin: string;
|
|
7
|
+
verifiedAt: number;
|
|
8
|
+
remark: string;
|
|
9
|
+
}
|
|
10
|
+
export function loginOriginOf(payload: any): LoginOrigin | undefined {
|
|
11
|
+
const o = payload?.loginOrigin;
|
|
12
|
+
return o && Number.isSafeInteger(o.actorId) && o.actorId > 0 && typeof o.actorLogin === "string"
|
|
13
|
+
&& typeof o.verifiedAt === "number" && typeof o.remark === "string" ? o : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** 只拆分,不尝试普通密码回落。账号格式由宿主检查。 */
|
|
16
|
+
export function splitDelegatedCredential(password: string): { actorLogin: string; password: string } | null {
|
|
17
|
+
if (!password.includes("@")) return null;
|
|
18
|
+
const parts = password.split("@");
|
|
19
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("代登录密码格式必须为:验证账号@密码,且只能包含一个 @");
|
|
20
|
+
return { actorLogin: parts[0], password: parts[1] };
|
|
21
|
+
}
|
|
22
|
+
/** 有效完整路径的前缀匹配;空路径和中间缺级均拒绝。 */
|
|
23
|
+
export function withinDelegatedScope(target: number[], scopes: number[][], includeSelf: boolean): boolean {
|
|
24
|
+
const valid = (p: number[]) => p.length > 0 && p.length <= 6 && p.every(n => Number.isSafeInteger(n) && n > 0);
|
|
25
|
+
return valid(target) && scopes.some(p => valid(p) && (includeSelf ? target.length >= p.length : target.length > p.length)
|
|
26
|
+
&& p.every((id, index) => target[index] === id));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const audit = new AsyncLocalStorage<{ remark: string }>();
|
|
30
|
+
export function runWithAuditContext<T>(fn: () => T): T { return audit.run({ remark: "" }, fn); }
|
|
31
|
+
export function setAuditRemark(remark: string): void { const ctx = audit.getStore(); if (ctx) ctx.remark = remark; }
|
|
32
|
+
export function getAuditRemark(): string { return audit.getStore()?.remark ?? ""; }
|
|
33
|
+
export function appendAuditRemark(existing: unknown, remark = getAuditRemark()): string {
|
|
34
|
+
return [String(existing ?? ""), remark].filter(Boolean).join(";");
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { attachSignature } from "./request-sign.js";
|
|
3
|
+
import type { OpenSecretBundle } from "./openSecret.js";
|
|
4
|
+
|
|
5
|
+
export interface OpenCallOptions {
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 只收口签名与报文派发,宿主注入应用身份、凭据查询和 HTTP 实现。 */
|
|
11
|
+
export function createOpenClient(opts: {
|
|
12
|
+
appId: string;
|
|
13
|
+
resolveSecret: (appId: string) => Promise<OpenSecretBundle | null>;
|
|
14
|
+
allowHttp: boolean;
|
|
15
|
+
/** 宿主限定此客户端可调用的路径,防止业务误用其他模块协议。 */
|
|
16
|
+
paths?: readonly string[];
|
|
17
|
+
post: (url: string, body: Record<string, unknown>, options: OpenCallOptions) => Promise<unknown>;
|
|
18
|
+
}) {
|
|
19
|
+
return async function callOpen<T = unknown>(url: string, body: Record<string, unknown>, options: OpenCallOptions = {}): Promise<T> {
|
|
20
|
+
const target = new URL(url);
|
|
21
|
+
if (target.username || target.password || target.search || target.hash || !/^\/open(?:\/|$)/.test(target.pathname)) {
|
|
22
|
+
throw new Error(`开放调用地址必须是不含凭据、查询串的 /open 路径:${target.origin}${target.pathname}`);
|
|
23
|
+
}
|
|
24
|
+
if (target.protocol !== "https:" && !(opts.allowHttp && target.protocol === "http:")) throw new Error("开放调用必须使用 HTTPS");
|
|
25
|
+
if (opts.paths && !opts.paths.includes(target.pathname)) throw new Error(`开放调用路径未授权:${target.pathname}`);
|
|
26
|
+
const credential = await opts.resolveSecret(opts.appId);
|
|
27
|
+
if (!credential?.secret) throw new Error(`开放调用凭据不存在或已停用:appId=${opts.appId}`);
|
|
28
|
+
// 签名基于实际 JSON 线上报文(剔除 undefined、归一 Date),避免发送端与接收端摘要不一致。
|
|
29
|
+
const wireBody = JSON.parse(JSON.stringify({ ...body, requestId: body.requestId || randomUUID() })) as Record<string, unknown>;
|
|
30
|
+
const signed = attachSignature(credential.secret, {
|
|
31
|
+
method: "POST", path: target.pathname, appId: opts.appId,
|
|
32
|
+
timestamp: Math.floor(Date.now() / 1000), nonce: randomUUID(), body: wireBody,
|
|
33
|
+
});
|
|
34
|
+
return await opts.post(target.toString(), signed, options) as T;
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/request-sign.ts
CHANGED
|
@@ -174,6 +174,10 @@ export function attachSignature(
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
export interface VerifyInput {
|
|
177
|
+
/** 宿主指定此入口允许的调用方;不配置时按该模块原有协议授权。 */
|
|
178
|
+
allowedAppIds?: readonly string[];
|
|
179
|
+
/** 宿主可要求 nonce 必传,用于内部请求防重放。 */
|
|
180
|
+
requireNonce?: boolean;
|
|
177
181
|
method: string;
|
|
178
182
|
path: string;
|
|
179
183
|
/**
|
|
@@ -221,6 +225,7 @@ export async function verifySignedRequest(input: VerifyInput): Promise<VerifyRes
|
|
|
221
225
|
if (!sig) {
|
|
222
226
|
return { ok: false, code: 401, reason: "缺少签名要素(提交数据需带 appId / timestamp / sign)" };
|
|
223
227
|
}
|
|
228
|
+
if (input.requireNonce && !sig.nonce) return { ok: false, code: 401, reason: "缺少签名要素 nonce" };
|
|
224
229
|
if (input.requireBody && input.body == null) {
|
|
225
230
|
return { ok: false, code: 401, reason: "缺少请求体" };
|
|
226
231
|
}
|
|
@@ -266,7 +271,10 @@ export async function verifySignedRequest(input: VerifyInput): Promise<VerifyRes
|
|
|
266
271
|
if (!safeEqual(expect, sig.sign)) {
|
|
267
272
|
return { ok: false, code: 401, reason: "签名校验失败:无效的调用凭据" };
|
|
268
273
|
}
|
|
269
|
-
|
|
274
|
+
if (input.allowedAppIds && !input.allowedAppIds.includes(sig.appId)) {
|
|
275
|
+
return { ok: false, code: 403, reason: `调用方未获准访问此入口:appId=${sig.appId}` };
|
|
276
|
+
}
|
|
277
|
+
// 命令白/黑名单放通:合法调用方进一步受限于其白/黑名单。
|
|
270
278
|
const cmd = extractCmd(input.body);
|
|
271
279
|
if (!isCommandAllowed(cmd, whiteList, blackList)) {
|
|
272
280
|
return { ok: false, code: 403, reason: "调用方无权执行该命令(被白/黑名单拒绝)" };
|