@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yixinshuke/node-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "登录态 JWT 签发/验签 + 开放通道 HMAC 验签 + Fastify 鉴权中间件(双轨 JWT 设计)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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
+ }
@@ -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: "调用方无权执行该命令(被白/黑名单拒绝)" };