diff --git a/.env.example b/.env.example index 099b047..35df98b 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,17 @@ DATABASE_URL=file:D:/AI Code/YouYiDianNotificationCenter/data/notifications.db DINGTALK_APP_KEY= DINGTALK_APP_SECRET= DINGTALK_ROBOT_CODE= +DINGTALK_CORP_ID= + +# 通知中心经公网访问的 HTTPS 基地址,可包含反向代理路径前缀。 +YYD_NOTIFICATION_PUBLIC_BASE_URL=https://sc.example.com/notification-center +# 登录票据只能设置为 30~60 秒;无效值会收紧为 60 秒。 +DINGTALK_LOGIN_TICKET_TTL_SECONDS=60 # 每个项目必须使用不同密钥;allowedHosts 用于限制卡片跳转域名。 +# login 省略或 enabled=false 时保持原直接跳转,不启用免登中转。 # 生产环境请使用足够长的随机密钥,不要把真实值提交到 Git。 -YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"]}] +YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"],"login":{"enabled":true,"callbackUrl":"https://asset.example.com/api/dingtalk/broker-login","allowedRedirectPathPrefixes":["/workflows/disposals","/dashboard"]}}] WORKER_ENABLED=true WORKER_INTERVAL_MS=1000 diff --git a/README.md b/README.md index 1ebeb05..3233f3d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ - 每个项目只能跳转到配置的 HTTPS 域名。 - `projectCode + idempotencyKey` 唯一,防止业务重试造成重复通知。 - 项目无法读取其它项目的投递记录。 +- 钉钉 `AppSecret` 只保存在通知中心;业务系统只用自己的项目 HMAC 密钥兑换登录票据。 +- 登录票据随机生成、只存 SHA-256 摘要、最长 60 秒、绑定项目和回跳域名并且只能原子消费一次。 +- 免登码和登录票据都只通过 POST 传递,不进入 URL;服务日志不记录二者和任何密钥。 ## 本地启动 @@ -22,6 +25,8 @@ 健康检查:`GET /health`。 +`npm test` 不读取本地 `.env`:测试入口会创建唯一临时 SQLite,先执行正式 Prisma 迁移,再串行运行测试,并在成功或失败后清理数据库、journal、WAL 和 SHM 文件。 + 生产环境建议只把容器端口绑定到 `127.0.0.1`,再由 Nginx 提供独立 HTTPS 域名。素材库和固定资产可能位于不同主机,因此不能把业务项目配置成通知中心的容器内网地址。 当前生产部署脚本位于: @@ -31,7 +36,7 @@ 脚本不包含任何密钥。统一机器人凭证从素材库生产环境安全复制到通知中心独立 `.env`,项目 HMAC 密钥在服务器生成并以 `0600` 权限保存。 -素材库生产 NAS 无法稳定访问 Docker Hub。正式发布应先在构建机或维护工作站构建 `youyidian-notification-center:latest`,将镜像安全传入生产机,再执行部署脚本;脚本检测到预载镜像后会使用 `--no-build` 启动。 +发布脚本会先一致性备份通知中心 SQLite、环境配置和源码,再从本次解压的源码强制构建镜像并 `force-recreate`,不会因为服务器上存在旧 `latest` 镜像而跳过新代码。素材库生产 NAS 无法稳定访问 Docker Hub,正式发布前应预载 `node:22-alpine` 基础镜像并确认 npm 依赖源可用;构建失败会在重建容器前终止,旧服务继续运行。 ## 签名算法 @@ -51,3 +56,51 @@ timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody) 发送接口:`POST /v1/notifications`。查询接口:`GET /v1/notifications/:id`。 通知中心只接受任务并异步发送,业务系统通过查询接口获取 `pending / sending / sent / unread / read / retrying / failed` 状态。 + +## 钉钉 H5 免登中转 + +项目的 `login` 配置是可选项;不配置时仍按原方式直接跳转,素材库等现有调用不受影响。启用后,通知中心会把业务传入的项目 HTTPS 地址转换为内部 `redirectPath`,并把卡片动作包装到统一入口: + +```text +GET /v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fworkflows%2Fdisposals%2F53 +``` + +入口页面向钉钉 JSAPI 同时传入公开的 `clientId`(即 AppKey)和 `corpId` 获取免登码,再以 JSON POST 调用: + +```text +POST /v1/auth/h5-code +{"projectCode":"fixed_asset","redirectPath":"/workflows/disposals/53","code":"..."} +``` + +中心换取钉钉 `userId` 后返回 `{ticket, formPostUrl, expiresIn}`,页面立即通过隐藏表单把 `ticket` POST 到项目配置的精确 `callbackUrl`。业务后端收到票据后,使用与通知接口相同的 HMAC 头调用: + +```text +POST /v1/auth/tickets/redeem +{"ticket":"..."} +``` + +成功响应只包含 `{dingtalkUserId, redirectPath}`。未知、过期、已消费或跨项目票据统一返回 `401 TICKET_INVALID_OR_EXPIRED`,不泄露票据是否存在。业务系统必须在创建 Session 前重新核验本地账号、员工绑定和在职状态。 + +项目配置示例: + +```json +{ + "code": "fixed_asset", + "name": "固定资产", + "secret": "独立随机项目密钥", + "allowedHosts": ["asset.jiansun.vip"], + "login": { + "enabled": true, + "callbackUrl": "https://asset.jiansun.vip/api/dingtalk/broker-login", + "allowedRedirectPathPrefixes": ["/workflows/disposals", "/dashboard"] + } +} +``` + +新增环境变量: + +- `DINGTALK_CORP_ID`:统一钉钉应用所属企业 CorpId。 +- `YYD_NOTIFICATION_PUBLIC_BASE_URL`:通知中心公网 HTTPS 基地址,生产为反向代理后的 `/notification-center` 地址。 +- `DINGTALK_LOGIN_TICKET_TTL_SECONDS`:30~60 秒,默认 60 秒。 + +新建任务最多投递 5 次,明确的限流等未受理结果按 1、5、15、30 分钟退避。发送调用若在请求已发出后发生网络断开、超时、HTTP 408/5xx、钉钉成功响应缺少 `processQueryKey`,或进程在发送中途崩溃并遗留过期租约,会以 `DELIVERY_OUTCOME_UNKNOWN` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。 diff --git a/docker-compose.yml b/docker-compose.yml index 62ff31e..634fc50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ services: DINGTALK_APP_KEY: ${DINGTALK_APP_KEY} DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET} DINGTALK_ROBOT_CODE: ${DINGTALK_ROBOT_CODE} + DINGTALK_CORP_ID: ${DINGTALK_CORP_ID:-} + YYD_NOTIFICATION_PUBLIC_BASE_URL: ${YYD_NOTIFICATION_PUBLIC_BASE_URL:-} + DINGTALK_LOGIN_TICKET_TTL_SECONDS: ${DINGTALK_LOGIN_TICKET_TTL_SECONDS:-60} YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON} WORKER_ENABLED: ${WORKER_ENABLED:-true} volumes: diff --git a/package.json b/package.json index 911bc0b..5ec6c6c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "tsx watch src/index.ts", "build": "prisma generate && tsc", "start": "node dist/index.js", - "test": "tsx --test src/__tests__/*.test.ts", + "test": "node scripts/run-tests.mjs", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate deploy" }, diff --git a/prisma/migrations/20260715_login_ticket/migration.sql b/prisma/migrations/20260715_login_ticket/migration.sql new file mode 100644 index 0000000..68ad5c3 --- /dev/null +++ b/prisma/migrations/20260715_login_ticket/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "DingtalkLoginTicket" ( + "id" TEXT NOT NULL PRIMARY KEY, + "projectCode" TEXT NOT NULL, + "audience" TEXT NOT NULL, + "ticketHash" TEXT NOT NULL, + "ticketNonce" TEXT NOT NULL, + "authCodeHash" TEXT NOT NULL, + "dingtalkUserId" TEXT NOT NULL, + "redirectPath" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "consumedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "DingtalkLoginTicket_ticketHash_key" ON "DingtalkLoginTicket"("ticketHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "DingtalkLoginTicket_ticketNonce_key" ON "DingtalkLoginTicket"("ticketNonce"); + +-- CreateIndex +CREATE UNIQUE INDEX "DingtalkLoginTicket_authCodeHash_key" ON "DingtalkLoginTicket"("authCodeHash"); + +-- CreateIndex +CREATE INDEX "DingtalkLoginTicket_projectCode_audience_expiresAt_idx" ON "DingtalkLoginTicket"("projectCode", "audience", "expiresAt"); + +-- CreateIndex +CREATE INDEX "DingtalkLoginTicket_expiresAt_consumedAt_idx" ON "DingtalkLoginTicket"("expiresAt", "consumedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e7c520..13e8e8b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,3 +48,20 @@ model RequestNonce { @@unique([projectCode, nonce]) @@index([expiresAt]) } + +model DingtalkLoginTicket { + id String @id @default(uuid()) + projectCode String + audience String + ticketHash String @unique + ticketNonce String @unique + authCodeHash String @unique + dingtalkUserId String + redirectPath String + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([projectCode, audience, expiresAt]) + @@index([expiresAt, consumedAt]) +} diff --git a/scripts/deploy-material-host.sh b/scripts/deploy-material-host.sh index 50deeec..75c4377 100644 --- a/scripts/deploy-material-host.sh +++ b/scripts/deploy-material-host.sh @@ -14,10 +14,39 @@ if tar -tzf "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then fi mkdir -p "$target" "$backup_root" +stamp="$(date +%Y%m%d%H%M%S)" if test -f "$target/package.json"; then - stamp="$(date +%Y%m%d%H%M%S)" tar -czf "$backup_root/source-$stamp.tar.gz" --exclude=.env --exclude=data -C "$target" . fi +if test -f "$target/.env"; then + cp -a "$target/.env" "$backup_root/env-$stamp" + chmod 600 "$backup_root/env-$stamp" +fi +if docker container inspect youyidian-notification-center >/dev/null 2>&1; then + was_running="$(docker inspect -f '{{.State.Running}}' youyidian-notification-center)" + if test "$was_running" = true; then + docker pause youyidian-notification-center >/dev/null + cleanup_unpause() { + docker unpause youyidian-notification-center >/dev/null 2>&1 || true + } + trap cleanup_unpause EXIT INT TERM + fi + data_backup="$backup_root/notification-data-$stamp" + mkdir -p "$data_backup" + # 整体复制可同时保存 notifications.db、-journal、-wal 与 -shm。 + if ! docker cp youyidian-notification-center:/app/data/. "$data_backup/" \ + || ! test -s "$data_backup/notifications.db"; then + if test "$was_running" = true; then + docker unpause youyidian-notification-center >/dev/null + fi + echo '通知中心 SQLite 备份失败,拒绝部署' >&2 + exit 1 + fi + if test "$was_running" = true; then + docker unpause youyidian-notification-center >/dev/null + trap - EXIT INT TERM + fi +fi tar -xzf "$archive" -C "$target" python3 - <<'PY' @@ -59,21 +88,44 @@ source_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env') target_path = Path('/mnt/PoolOne/apps/youyidian-notification-center/.env') source = parse_env(source_path) existing = parse_env(target_path) -app_key = ( +source_app_key = ( source.get('DINGTALK_WORK_NOTICE_CLIENT_ID') or source.get('DINGTALK_WORK_NOTICE_APP_KEY') or source.get('DINGTALK_CLIENT_ID') or source.get('DINGTALK_APP_KEY') ) -app_secret = ( +source_app_secret = ( source.get('DINGTALK_WORK_NOTICE_CLIENT_SECRET') or source.get('DINGTALK_WORK_NOTICE_APP_SECRET') or source.get('DINGTALK_CLIENT_SECRET') or source.get('DINGTALK_APP_SECRET') ) -robot_code = source.get('DINGTALK_ROBOT_CODE') -if not app_key or not app_secret or not robot_code: - raise SystemExit('素材库生产环境缺少统一机器人配置') +source_robot_code = source.get('DINGTALK_ROBOT_CODE') +source_corp_id = ( + source.get('DINGTALK_WORK_NOTICE_CORP_ID') + or source.get('DINGTALK_CORP_ID') + or source.get('DINGTALK_CORPID') + or '' +) +existing_credentials = [ + existing.get('DINGTALK_APP_KEY'), + existing.get('DINGTALK_APP_SECRET'), + existing.get('DINGTALK_ROBOT_CODE'), +] +if all(existing_credentials): + app_key, app_secret, robot_code = existing_credentials +elif any(existing_credentials): + raise SystemExit('通知中心已有钉钉配置不完整,拒绝从其它项目拼接覆盖') +else: + if not source_app_key or not source_app_secret or not source_robot_code: + raise SystemExit('通知中心首次启动缺少钉钉配置,且安全 bootstrap 来源不完整') + app_key, app_secret, robot_code = source_app_key, source_app_secret, source_robot_code + +corp_id = existing.get('DINGTALK_CORP_ID') or '' +if not corp_id and source_corp_id: + if source_app_key and source_app_key != app_key: + raise SystemExit('通知中心 AppKey 与 bootstrap 来源不一致,拒绝复制 CorpId') + corp_id = source_corp_id try: old_projects = json.loads(existing.get('YYD_NOTIFICATION_PROJECTS_JSON', '[]')) @@ -91,25 +143,52 @@ def secret_for(code): return old if len(old) >= 24 else secrets.token_hex(32) +def project_with_preserved_login(code, name, allowed_hosts): + project = { + 'code': code, + 'name': name, + 'secret': secret_for(code), + 'allowedHosts': allowed_hosts, + } + old_login = by_code.get(code, {}).get('login') + if isinstance(old_login, dict): + project['login'] = old_login + return project + + projects = [ - { - 'code': 'material_library', - 'name': '素材库', - 'secret': secret_for('material_library'), - 'allowedHosts': ['sc.jiansun.vip'], - }, - { - 'code': 'fixed_asset', - 'name': '固定资产', - 'secret': secret_for('fixed_asset'), - 'allowedHosts': ['asset.jiansun.vip', 'www.jsgdzc.cn', 'jsgdzc.cn'], - }, + project_with_preserved_login( + 'material_library', + '素材库', + ['sc.jiansun.vip'], + ), + project_with_preserved_login( + 'fixed_asset', + '固定资产', + ['asset.jiansun.vip', 'www.jsgdzc.cn', 'jsgdzc.cn'], + ), ] +if any(item.get('login', {}).get('enabled') is True for item in projects) and not corp_id: + raise SystemExit('已有项目启用了免登中转,但生产配置缺少 DINGTALK_CORP_ID') + +try: + ticket_ttl = int(existing.get('DINGTALK_LOGIN_TICKET_TTL_SECONDS', '60')) +except Exception: + ticket_ttl = 60 +if ticket_ttl < 30 or ticket_ttl > 60: + ticket_ttl = 60 + center_lines = [ 'PORT=3088', 'DINGTALK_APP_KEY=' + quoted(app_key), 'DINGTALK_APP_SECRET=' + quoted(app_secret), 'DINGTALK_ROBOT_CODE=' + quoted(robot_code), + 'DINGTALK_CORP_ID=' + quoted(corp_id), + 'YYD_NOTIFICATION_PUBLIC_BASE_URL=' + quoted( + existing.get('YYD_NOTIFICATION_PUBLIC_BASE_URL') + or 'https://sc.jiansun.vip/notification-center' + ), + 'DINGTALK_LOGIN_TICKET_TTL_SECONDS=' + str(ticket_ttl), 'YYD_NOTIFICATION_PROJECTS_JSON=' + quoted( json.dumps(projects, ensure_ascii=False, separators=(',', ':')) ), @@ -154,12 +233,8 @@ print('通知中心与素材库适配配置已写入') PY cd "$target" -image="youyidian-notification-center:${YYD_NOTIFICATION_IMAGE_TAG:-latest}" -if docker image inspect "$image" >/dev/null 2>&1; then - docker compose up -d --no-build -else - docker compose up -d --build -fi +docker compose build --pull=false +docker compose up -d --no-build --force-recreate healthy=0 for _ in $(seq 1 30); do diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..dcb4e1e --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,65 @@ +import { existsSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve, sep } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const testPrefix = 'yyd-notification-center-test-' +const testRoot = mkdtempSync(join(tmpdir(), testPrefix)) +const databasePath = join(testRoot, 'notifications.db') +const databaseUrl = `file:${databasePath}` +const childEnv = { ...process.env, DATABASE_URL: databaseUrl } +// Prisma 5.22 的 Windows schema-engine 在部分主机未初始化 Rust 日志时会无细节提前退出。 +// 只在 Windows 测试子进程中启用;Linux/Docker 不受影响,也不会记录数据库内容或密钥。 +if (process.platform === 'win32' && !childEnv.RUST_LOG) childEnv.RUST_LOG = 'trace' + +const runNode = (args) => { + const result = spawnSync(process.execPath, args, { + cwd: repoRoot, + env: childEnv, + stdio: 'inherit', + }) + if (result.error) throw result.error + return result.status ?? 1 +} + +const removeTestRoot = () => { + const resolvedTemp = realpathSync(tmpdir()) + const resolvedTestRoot = realpathSync(testRoot) + const isTempChild = resolvedTestRoot.startsWith(`${resolvedTemp}${sep}`) + if (!isTempChild || !basename(resolvedTestRoot).startsWith(testPrefix)) { + throw new Error(`拒绝清理非测试临时目录: ${resolvedTestRoot}`) + } + rmSync(resolvedTestRoot, { recursive: true, force: true }) + if (existsSync(resolvedTestRoot)) throw new Error('测试临时目录清理失败') +} + +let exitCode = 0 +try { + const prismaCli = resolve(repoRoot, 'node_modules', 'prisma', 'build', 'index.js') + const schema = resolve(repoRoot, 'prisma', 'schema.prisma') + exitCode = runNode([prismaCli, 'migrate', 'deploy', '--schema', schema]) + if (exitCode === 0) { + const tsxCli = resolve(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs') + const testsDirectory = resolve(repoRoot, 'src', '__tests__') + const testFiles = readdirSync(testsDirectory) + .filter((name) => name.endsWith('.test.ts')) + .sort() + .map((name) => resolve(testsDirectory, name)) + if (testFiles.length === 0) throw new Error('未找到测试文件') + exitCode = runNode([tsxCli, '--test', '--test-concurrency=1', ...testFiles]) + } +} catch (error) { + console.error('[test-runner]', error instanceof Error ? error.message : String(error)) + exitCode = 1 +} finally { + try { + removeTestRoot() + } catch (error) { + console.error('[test-runner-cleanup]', error instanceof Error ? error.message : String(error)) + exitCode = 1 + } +} + +process.exitCode = exitCode diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts new file mode 100644 index 0000000..bdd88d4 --- /dev/null +++ b/src/__tests__/auth.test.ts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import test from 'node:test' +import { AddressInfo } from 'node:net' +import { validateRedirectPath } from '../auth' +import { loadProjectConfigs } from '../config' +import prisma from '../prisma' +import { createServer } from '../server' +import { createCanonicalSignature, sha256 } from '../signature' + +const fixedSecret = 'fixed-asset-secret-1234567890' +const materialSecret = 'material-library-secret-123456' +const projects = loadProjectConfigs(JSON.stringify([ + { + code: 'fixed_asset', + name: '固定资产', + secret: fixedSecret, + allowedHosts: ['asset.example.com'], + login: { + enabled: true, + callbackUrl: 'https://asset.example.com/api/dingtalk/broker-login', + allowedRedirectPathPrefixes: ['/workflows/disposals', '/dashboard'], + }, + }, + { + code: 'material_library', + name: '素材库', + secret: materialSecret, + allowedHosts: ['material.example.com'], + }, +])) + +test('内部回跳路径拒绝外链、编码斜杠和相似前缀绕过', () => { + const project = projects.get('fixed_asset')! + assert.equal( + validateRedirectPath('/workflows/disposals/53?tab=detail#items', project), + '/workflows/disposals/53?tab=detail#items', + ) + for (const value of [ + '//evil.example.com/path', + '/%2f%2fevil.example.com/path', + '/%5cevil.example.com/path', + '/workflows/disposals-evil/53', + '/workflows/disposals/../../settings', + 'https://evil.example.com/path', + ]) { + assert.throws(() => validateRedirectPath(value, project)) + } +}) + +const signedHeaders = (input: { + projectCode: string + secret: string + method: string + path: string + body: string + nonce?: string + timestamp?: string +}) => { + const timestamp = input.timestamp || String(Math.floor(Date.now() / 1000)) + const nonce = input.nonce || crypto.randomBytes(18).toString('base64url') + return { + 'Content-Type': 'application/json', + 'x-yyd-project-code': input.projectCode, + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': createCanonicalSignature({ + secret: input.secret, + timestamp, + nonce, + method: input.method, + path: input.path, + body: input.body, + }), + } +} + +test('H5免登票据按项目绑定、60秒内原子兑换且不可重放', async () => { + const oldPublicBaseUrl = process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL + const oldCorpId = process.env.DINGTALK_CORP_ID + const oldAppKey = process.env.DINGTALK_APP_KEY + const oldTtl = process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = 'https://sc.example.com/notification-center' + process.env.DINGTALK_CORP_ID = 'ding-test-corp' + process.env.DINGTALK_APP_KEY = 'test-client-id' + process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS = '60' + + await prisma.requestNonce.deleteMany() + await prisma.notificationDelivery.deleteMany() + await prisma.dingtalkLoginTicket.deleteMany() + const server = createServer(projects, { + exchangeH5Code: async (code) => code.startsWith('valid-code-') ? 'ding-user-1' : Promise.reject(new Error('invalid')), + }).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + const baseUrl = `http://127.0.0.1:${port}` + + const issue = async (code: string, redirectPath = '/workflows/disposals/53?tab=detail') => { + const response = await fetch(`${baseUrl}/v1/auth/h5-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectCode: 'fixed_asset', redirectPath, code }), + }) + return { response, payload: await response.json() as any } + } + const redeem = async (ticket: string, projectCode = 'fixed_asset', secret = fixedSecret) => { + const path = '/v1/auth/tickets/redeem' + const body = JSON.stringify({ ticket }) + return fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: signedHeaders({ projectCode, secret, method: 'POST', path, body }), + body, + }) + } + + try { + const notificationPath = '/v1/notifications' + const notificationBody = JSON.stringify({ + idempotencyKey: 'auth-entry:test:1', recipientUserId: 'ding-user-1', title: '待审批报废', content: '测试', + actionTitle: '查看详情', actionUrl: 'https://asset.example.com/workflows/disposals/53?tab=detail', + }) + const notification = await fetch(`${baseUrl}${notificationPath}`, { + method: 'POST', + headers: signedHeaders({ projectCode: 'fixed_asset', secret: fixedSecret, method: 'POST', path: notificationPath, body: notificationBody }), + body: notificationBody, + }) + assert.equal(notification.status, 202) + const storedDelivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { idempotencyKey: 'auth-entry:test:1' } }) + const entryUrl = new URL(storedDelivery.actionUrl) + assert.equal(entryUrl.origin, 'https://sc.example.com') + assert.equal(entryUrl.pathname, '/notification-center/v1/auth/h5-entry') + assert.equal(entryUrl.searchParams.get('projectCode'), 'fixed_asset') + assert.equal(entryUrl.searchParams.get('redirectPath'), '/workflows/disposals/53?tab=detail') + assert.equal(entryUrl.searchParams.has('ticket'), false) + assert.equal(entryUrl.searchParams.has('code'), false) + + const entry = await fetch(`${baseUrl}/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=${encodeURIComponent('/workflows/disposals/53')}`) + assert.equal(entry.status, 200) + assert.match(entry.headers.get('content-security-policy') || '', /frame-ancestors 'none'/) + assert.equal(entry.headers.get('cache-control')?.includes('no-store'), true) + const entryHtml = await entry.text() + assert.match(entryHtml, /ding-test-corp/) + assert.match(entryHtml, /test-client-id/) + assert.match(entryHtml, /clientId: config\.clientId/) + assert.doesNotMatch(entryHtml, new RegExp(fixedSecret)) + + const issued = await issue('valid-code-one') + assert.equal(issued.response.status, 201) + assert.equal(issued.payload.formPostUrl, 'https://asset.example.com/api/dingtalk/broker-login') + assert.equal(issued.payload.expiresIn, 60) + assert.match(issued.payload.ticket, /^[a-zA-Z0-9_-]{43}$/) + const storedTicket = await prisma.dingtalkLoginTicket.findUniqueOrThrow({ where: { authCodeHash: sha256('valid-code-one') } }) + assert.equal(storedTicket.ticketHash, sha256(issued.payload.ticket)) + assert.notEqual(storedTicket.ticketHash, issued.payload.ticket) + assert.equal(storedTicket.projectCode, 'fixed_asset') + assert.equal(storedTicket.audience, 'https://asset.example.com') + + const duplicateCode = await issue('valid-code-one') + assert.equal(duplicateCode.response.status, 401) + assert.deepEqual(duplicateCode.payload, { error: 'H5_AUTH_FAILED' }) + + const firstRedeem = await redeem(issued.payload.ticket) + assert.equal(firstRedeem.status, 200) + assert.deepEqual(await firstRedeem.json(), { + dingtalkUserId: 'ding-user-1', + redirectPath: '/workflows/disposals/53?tab=detail', + }) + const replay = await redeem(issued.payload.ticket) + assert.equal(replay.status, 401) + assert.deepEqual(await replay.json(), { error: 'TICKET_INVALID_OR_EXPIRED' }) + + const crossProjectTicket = await issue('valid-code-two') + assert.equal(crossProjectTicket.response.status, 201) + const crossProject = await redeem(crossProjectTicket.payload.ticket, 'material_library', materialSecret) + assert.equal(crossProject.status, 401) + assert.deepEqual(await crossProject.json(), { error: 'TICKET_INVALID_OR_EXPIRED' }) + assert.equal((await redeem(crossProjectTicket.payload.ticket)).status, 200) + + const expiredTicket = await issue('valid-code-three') + await prisma.dingtalkLoginTicket.update({ + where: { authCodeHash: sha256('valid-code-three') }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }) + assert.equal((await redeem(expiredTicket.payload.ticket)).status, 401) + + const nonceTicket = await issue('valid-code-five') + const redeemPath = '/v1/auth/tickets/redeem' + const redeemBody = JSON.stringify({ ticket: nonceTicket.payload.ticket }) + const replayedHeaders = signedHeaders({ + projectCode: 'fixed_asset', secret: fixedSecret, method: 'POST', path: redeemPath, body: redeemBody, + nonce: 'same_nonce_1234567890', + }) + assert.equal((await fetch(`${baseUrl}${redeemPath}`, { method: 'POST', headers: replayedHeaders, body: redeemBody })).status, 200) + const replayedRequest = await fetch(`${baseUrl}${redeemPath}`, { method: 'POST', headers: replayedHeaders, body: redeemBody }) + assert.equal(replayedRequest.status, 409) + assert.deepEqual(await replayedRequest.json(), { error: 'NONCE_REPLAYED' }) + + const expiredRequestHeaders = signedHeaders({ + projectCode: 'fixed_asset', secret: fixedSecret, method: 'POST', path: redeemPath, body: redeemBody, + timestamp: String(Math.floor(Date.now() / 1000) - 301), + }) + const expiredRequest = await fetch(`${baseUrl}${redeemPath}`, { method: 'POST', headers: expiredRequestHeaders, body: redeemBody }) + assert.equal(expiredRequest.status, 401) + assert.deepEqual(await expiredRequest.json(), { error: 'TIMESTAMP_EXPIRED' }) + + const unsafeRedirect = await issue('valid-code-four', '/settings') + assert.equal(unsafeRedirect.response.status, 422) + assert.deepEqual(unsafeRedirect.payload, { error: 'REDIRECT_PATH_INVALID' }) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = oldPublicBaseUrl + process.env.DINGTALK_CORP_ID = oldCorpId + process.env.DINGTALK_APP_KEY = oldAppKey + process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS = oldTtl + await prisma.$disconnect() + } +}) diff --git a/src/__tests__/deployment.test.ts b/src/__tests__/deployment.test.ts new file mode 100644 index 0000000..6ec4a7a --- /dev/null +++ b/src/__tests__/deployment.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +test('生产发布面透传免登配置并保留已有项目 login', () => { + const compose = readFileSync('docker-compose.yml', 'utf8') + const example = readFileSync('.env.example', 'utf8') + const deploy = readFileSync('scripts/deploy-material-host.sh', 'utf8') + const gitDeploy = readFileSync('scripts/git-build-deploy.ps1', 'utf8') + const testRunner = readFileSync('scripts/run-tests.mjs', 'utf8') + const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) + for (const key of [ + 'DINGTALK_CORP_ID', + 'YYD_NOTIFICATION_PUBLIC_BASE_URL', + 'DINGTALK_LOGIN_TICKET_TTL_SECONDS', + ]) { + assert.match(compose, new RegExp(key)) + assert.match(example, new RegExp(key)) + assert.match(deploy, new RegExp(key)) + } + assert.match(deploy, /old_login = by_code\.get\(code, \{\}\)\.get\('login'\)/) + assert.match(deploy, /project\['login'\] = old_login/) + assert.match(deploy, /docker pause youyidian-notification-center/) + assert.match(deploy, /docker cp youyidian-notification-center:\/app\/data\/\. "\$data_backup\/"/) + assert.match(deploy, /test -s "\$data_backup\/notifications\.db"/) + assert.match(deploy, /docker compose build --pull=false/) + assert.match(deploy, /docker compose up -d --no-build --force-recreate/) + assert.doesNotMatch(deploy, /docker image inspect/) + assert.match(gitDeploy, /docker pause youyidian-notification-center/) + assert.match(gitDeploy, /docker cp youyidian-notification-center:\/app\/data\/\. "\$data_backup\/"/) + assert.match(gitDeploy, /notification-data-before-git-release-\$stamp/) + assert.match(gitDeploy, /test -s "\$data_backup\/notifications\.db"/) + assert.match(gitDeploy, /SQLite 备份失败,拒绝重建生产容器/) + assert.ok( + gitDeploy.indexOf('docker cp youyidian-notification-center:/app/data/.') < + gitDeploy.indexOf('docker compose up -d --no-build --force-recreate'), + ) + assert.equal(packageJson.scripts.test, 'node scripts/run-tests.mjs') + assert.match(testRunner, /mkdtempSync/) + assert.match(testRunner, /DATABASE_URL: databaseUrl/) + assert.match(testRunner, /'migrate', 'deploy'/) + assert.match(testRunner, /finally/) + assert.match(testRunner, /rmSync\(resolvedTestRoot, \{ recursive: true, force: true \}\)/) +}) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 655443c..63ee2a2 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -44,6 +44,8 @@ test('通知中心验证签名、项目域名和幂等键', async () => { assert.equal(first.status, 202) const firstPayload = await first.json() as any assert.equal(firstPayload.title, '【由壹点】固定资产·待审批报废') + const stored = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: firstPayload.id } }) + assert.equal(stored.maxAttempts, 5) const second = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body }) assert.equal(second.status, 200) diff --git a/src/__tests__/signature.test.ts b/src/__tests__/signature.test.ts index 859a38b..cdb5994 100644 --- a/src/__tests__/signature.test.ts +++ b/src/__tests__/signature.test.ts @@ -11,6 +11,36 @@ test('每个项目必须使用独立且足够长的密钥', () => { assert.throws(() => loadProjectConfigs(JSON.stringify([ { code: 'bad', name: '错误项目', secret: 'short', allowedHosts: ['example.com'] }, ]))) + assert.throws(() => loadProjectConfigs(JSON.stringify([ + { code: 'bad', name: '错误项目', secret: '123456789012345678901234', allowedHosts: ['example.com:444'] }, + ]))) +}) + +test('免登配置必须使用项目域名、标准 HTTPS 回调和内部路径白名单', () => { + const project = loadProjectConfigs(JSON.stringify([{ + code: 'fixed_asset', + name: '固定资产', + secret: '123456789012345678901234', + allowedHosts: ['asset.example.com'], + login: { + enabled: true, + callbackUrl: 'https://asset.example.com/api/dingtalk/broker-login', + allowedRedirectPathPrefixes: ['/workflows/disposals/', '/dashboard'], + }, + }])).get('fixed_asset') + assert.deepEqual(project?.login?.allowedRedirectPathPrefixes, ['/workflows/disposals', '/dashboard']) + + for (const callbackUrl of [ + 'http://asset.example.com/api/dingtalk/broker-login', + 'https://evil.example.com/api/dingtalk/broker-login', + 'https://asset.example.com:444/api/dingtalk/broker-login', + 'https://user:pass@asset.example.com/api/dingtalk/broker-login', + ]) { + assert.throws(() => loadProjectConfigs(JSON.stringify([{ + code: 'fixed_asset', name: '固定资产', secret: '123456789012345678901234', allowedHosts: ['asset.example.com'], + login: { enabled: true, callbackUrl, allowedRedirectPathPrefixes: ['/workflows/disposals'] }, + }]))) + } }) test('签名包含项目密钥、时间、nonce、方法、路径和正文', () => { diff --git a/src/__tests__/worker.test.ts b/src/__tests__/worker.test.ts new file mode 100644 index 0000000..ff04c5f --- /dev/null +++ b/src/__tests__/worker.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DingTalkError, sendRobotMessage } from '../dingtalk' +import prisma from '../prisma' +import { classifyDeliveryFailure, quarantineExpiredSendingLeases } from '../worker' + +test('发送请求超时属于结果不确定并停止自动重试', async () => { + const originalFetch = global.fetch + const oldAppKey = process.env.DINGTALK_APP_KEY + const oldAppSecret = process.env.DINGTALK_APP_SECRET + const oldRobotCode = process.env.DINGTALK_ROBOT_CODE + process.env.DINGTALK_APP_KEY = 'test-app-key' + process.env.DINGTALK_APP_SECRET = 'test-app-secret' + process.env.DINGTALK_ROBOT_CODE = 'test-robot-code' + let calls = 0 + global.fetch = (async () => { + calls += 1 + if (calls === 1) return new Response(JSON.stringify({ errcode: 0, access_token: 'test-token', expires_in: 7200 }), { status: 200 }) + throw new DOMException('aborted', 'AbortError') + }) as typeof fetch + + try { + let caught: unknown + try { + await sendRobotMessage({ recipientUserId: 'ding-user-1', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' }) + } catch (error) { + caught = error + } + assert.ok(caught instanceof DingTalkError) + assert.equal(caught.outcomeUnknown, true) + assert.deepEqual(classifyDeliveryFailure({ error: caught, attemptCount: 1, maxAttempts: 4 }), { + failed: true, + errorCode: 'DELIVERY_OUTCOME_UNKNOWN', + errorMessage: '钉钉发送结果不确定,请先在钉钉侧人工核验,系统不会自动重试', + }) + } finally { + global.fetch = originalFetch + process.env.DINGTALK_APP_KEY = oldAppKey + process.env.DINGTALK_APP_SECRET = oldAppSecret + process.env.DINGTALK_ROBOT_CODE = oldRobotCode + } +}) + +test('明确限流可安全重试,确定性错误和耗尽次数则停止', () => { + assert.equal(classifyDeliveryFailure({ + error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 1, maxAttempts: 4, + }).failed, false) + assert.equal(classifyDeliveryFailure({ + error: new DingTalkError('用户无效', 'INVALID_USER', true), attemptCount: 1, maxAttempts: 4, + }).failed, true) + assert.equal(classifyDeliveryFailure({ + error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 4, maxAttempts: 5, + }).failed, false) + assert.equal(classifyDeliveryFailure({ + error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 5, maxAttempts: 5, + }).failed, true) +}) + +test('进程崩溃遗留的过期 sending 租约转人工核验而不是再次发送', async () => { + await prisma.notificationDelivery.deleteMany({ where: { id: 'expired-sending-test' } }) + await prisma.notificationDelivery.create({ + data: { + id: 'expired-sending-test', + projectCode: 'fixed_asset', + idempotencyKey: 'expired-sending-test', + requestHash: 'hash', + recipientUserId: 'ding-user-1', + businessTitle: '测试', + renderedTitle: '测试', + content: '测试', + actionTitle: '查看', + actionUrl: 'https://asset.example.com/dashboard', + status: 'sending', + attemptCount: 1, + leaseOwner: 'crashed-worker', + leaseExpiresAt: new Date(Date.now() - 1000), + }, + }) + const result = await quarantineExpiredSendingLeases() + assert.equal(result.count, 1) + const row = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: 'expired-sending-test' } }) + assert.equal(row.status, 'failed') + assert.equal(row.lastErrorCode, 'DELIVERY_OUTCOME_UNKNOWN') + assert.equal(row.attemptCount, 1) + assert.equal(row.leaseOwner, null) + assert.equal(row.leaseExpiresAt, null) +}) diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..012b79f --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,150 @@ +import crypto from 'crypto' +import type { ProjectConfig } from './config' + +const clean = (value: unknown) => String(value || '').trim() + +export class LoginInputError extends Error { + constructor(message: string) { + super(message) + this.name = 'LoginInputError' + } +} + +const matchesPrefix = (pathname: string, prefix: string) => + prefix === '/' || pathname === prefix || pathname.startsWith(`${prefix}/`) + +export const validateRedirectPath = (value: unknown, project: ProjectConfig) => { + if (!project.login) throw new LoginInputError('该项目未启用钉钉免登中转') + const raw = clean(value) + if ( + !raw || + raw.length > 2048 || + !raw.startsWith('/') || + raw.startsWith('//') || + raw.includes('\\') || + /[\u0000-\u001f\u007f]/.test(raw) + ) { + throw new LoginInputError('redirectPath 不是有效站内路径') + } + + let parsed: URL + try { + parsed = new URL(raw, 'https://internal.invalid') + } catch { + throw new LoginInputError('redirectPath 不是有效站内路径') + } + if (parsed.origin !== 'https://internal.invalid') { + throw new LoginInputError('redirectPath 不是有效站内路径') + } + try { + const decodedPath = decodeURIComponent(parsed.pathname) + if (decodedPath.startsWith('//') || decodedPath.includes('\\')) { + throw new LoginInputError('redirectPath 不是有效站内路径') + } + } catch (error) { + if (error instanceof LoginInputError) throw error + throw new LoginInputError('redirectPath 包含无效编码') + } + + if (!project.login.allowedRedirectPathPrefixes.some((prefix) => matchesPrefix(parsed.pathname, prefix))) { + throw new LoginInputError('redirectPath 不在项目白名单内') + } + return `${parsed.pathname}${parsed.search}${parsed.hash}` +} + +export const getRedirectPathFromActionUrl = (actionUrl: string, project: ProjectConfig) => { + const url = new URL(actionUrl) + return validateRedirectPath(`${url.pathname}${url.search}${url.hash}`, project) +} + +export const buildH5EntryUrl = (input: { + publicBaseUrl: string + project: ProjectConfig + redirectPath: string +}) => { + const url = new URL(`${input.publicBaseUrl}/v1/auth/h5-entry`) + url.searchParams.set('projectCode', input.project.code) + url.searchParams.set('redirectPath', validateRedirectPath(input.redirectPath, input.project)) + return url.toString() +} + +const jsonForHtml = (value: unknown) => JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029') + +export const createH5EntryPage = (input: { + clientId: string + corpId: string + projectCode: string + projectName: string + redirectPath: string + h5CodeUrl: string +}) => { + const cspNonce = crypto.randomBytes(18).toString('base64url') + const payload = jsonForHtml({ + clientId: input.clientId, + corpId: input.corpId, + projectCode: input.projectCode, + redirectPath: input.redirectPath, + h5CodeUrl: input.h5CodeUrl, + }) + const html = ` + + + + + + 正在打开${escapeHtml(input.projectName)} + + + +

正在打开${escapeHtml(input.projectName)}

正在验证钉钉身份,请稍候…

+ + + +` + return { html, cspNonce } +} + +const escapeHtml = (value: string) => value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') diff --git a/src/config.ts b/src/config.ts index 5605d94..72b639b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,10 +3,76 @@ export type ProjectConfig = { name: string secret: string allowedHosts: string[] + login?: ProjectLoginConfig +} + +export type ProjectLoginConfig = { + enabled: true + callbackUrl: string + allowedRedirectPathPrefixes: string[] } const clean = (value: unknown) => String(value || '').trim() +const normalizeAllowedRedirectPrefix = (value: unknown, projectCode: string) => { + const prefix = clean(value) + if (!prefix.startsWith('/') || prefix.startsWith('//') || prefix.includes('\\') || /[\u0000-\u001f\u007f]/.test(prefix)) { + throw new Error(`项目 ${projectCode} 的登录回跳路径前缀不合法: ${prefix || '(空)'}`) + } + let parsed: URL + try { + parsed = new URL(prefix, 'https://internal.invalid') + } catch { + throw new Error(`项目 ${projectCode} 的登录回跳路径前缀不合法: ${prefix}`) + } + if (parsed.origin !== 'https://internal.invalid' || parsed.search || parsed.hash) { + throw new Error(`项目 ${projectCode} 的登录回跳路径前缀只能包含站内路径: ${prefix}`) + } + return parsed.pathname.length > 1 ? parsed.pathname.replace(/\/+$/, '') : '/' +} + +const parseProjectLogin = ( + value: unknown, + projectCode: string, + allowedHosts: string[], +): ProjectLoginConfig | undefined => { + if (value === undefined || value === null) return undefined + if (typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`项目 ${projectCode} 的 login 必须是对象`) + } + const row = value as Record + if (row.enabled === false) return undefined + if (row.enabled !== true) throw new Error(`项目 ${projectCode} 的 login.enabled 必须显式设为 true 或 false`) + + const callbackUrl = clean(row.callbackUrl) + let callback: URL + try { + callback = new URL(callbackUrl) + } catch { + throw new Error(`项目 ${projectCode} 的 login.callbackUrl 不是有效 URL`) + } + if ( + callback.protocol !== 'https:' || + callback.port || + callback.username || + callback.password || + callback.search || + callback.hash || + !allowedHosts.includes(callback.hostname.toLowerCase()) + ) { + throw new Error(`项目 ${projectCode} 的 login.callbackUrl 必须是 allowedHosts 内使用标准 443 端口的完整 HTTPS 地址且不能包含凭据、查询或片段`) + } + + const prefixes = Array.isArray(row.allowedRedirectPathPrefixes) + ? row.allowedRedirectPathPrefixes.map((item) => normalizeAllowedRedirectPrefix(item, projectCode)) + : [] + const allowedRedirectPathPrefixes = [...new Set(prefixes)] + if (allowedRedirectPathPrefixes.length === 0) { + throw new Error(`项目 ${projectCode} 至少需要一个 login.allowedRedirectPathPrefixes`) + } + return { enabled: true, callbackUrl: callback.toString(), allowedRedirectPathPrefixes } +} + export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_JSON || '') => { if (!raw.trim()) return new Map() let parsed: unknown @@ -32,8 +98,20 @@ export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_J if (!name) throw new Error(`项目 ${code} 缺少 name`) if (secret.length < 24) throw new Error(`项目 ${code} 的 secret 至少需要 24 个字符`) if (allowedHosts.length === 0) throw new Error(`项目 ${code} 至少需要一个 allowedHosts`) + for (const host of allowedHosts) { + let parsedHost: URL + try { + parsedHost = new URL(`https://${host}`) + } catch { + throw new Error(`项目 ${code} 的 allowedHosts 包含无效主机名: ${host}`) + } + if (parsedHost.hostname.toLowerCase() !== host || parsedHost.port || parsedHost.pathname !== '/') { + throw new Error(`项目 ${code} 的 allowedHosts 只能填写不含协议、端口和路径的主机名: ${host}`) + } + } if (projects.has(code)) throw new Error(`项目 code 重复: ${code}`) - projects.set(code, { code, name, secret, allowedHosts }) + const login = parseProjectLogin(item.login, code, allowedHosts) + projects.set(code, { code, name, secret, allowedHosts, login }) } return projects } @@ -42,11 +120,31 @@ export const getDingTalkConfig = () => ({ appKey: clean(process.env.DINGTALK_APP_KEY), appSecret: clean(process.env.DINGTALK_APP_SECRET), robotCode: clean(process.env.DINGTALK_ROBOT_CODE), + corpId: clean(process.env.DINGTALK_CORP_ID), }) +export const getPublicBaseUrl = () => { + const raw = clean(process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL) + if (!raw) return '' + let url: URL + try { + url = new URL(raw) + } catch { + throw new Error('YYD_NOTIFICATION_PUBLIC_BASE_URL 不是有效 URL') + } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error('YYD_NOTIFICATION_PUBLIC_BASE_URL 必须是 HTTPS 地址且不能包含凭据、查询或片段') + } + return url.toString().replace(/\/$/, '') +} + export const assertRuntimeConfig = (projects: Map) => { if (projects.size === 0) throw new Error('未配置任何通知项目') const dingTalk = getDingTalkConfig() - const missing = Object.entries(dingTalk).filter(([, value]) => !value).map(([key]) => key) + const missing = (['appKey', 'appSecret', 'robotCode'] as const).filter((key) => !dingTalk[key]) if (missing.length > 0) throw new Error(`钉钉配置缺失: ${missing.join(', ')}`) + if ([...projects.values()].some((project) => project.login)) { + if (!dingTalk.corpId) throw new Error('启用钉钉免登中转时必须配置 DINGTALK_CORP_ID') + if (!getPublicBaseUrl()) throw new Error('启用钉钉免登中转时必须配置 YYD_NOTIFICATION_PUBLIC_BASE_URL') + } } diff --git a/src/dingtalk.ts b/src/dingtalk.ts index e45d5c8..6a89d67 100644 --- a/src/dingtalk.ts +++ b/src/dingtalk.ts @@ -7,6 +7,7 @@ export class DingTalkError extends Error { message: string, public readonly code: string, public readonly deterministic = false, + public readonly outcomeUnknown = false, ) { super(message) this.name = 'DingTalkError' @@ -16,7 +17,11 @@ export class DingTalkError extends Error { let tokenCache: { token: string; expiresAt: number } | null = null let tokenPromise: Promise | null = null -const requestJson = async (url: string, init?: RequestInit): Promise => { +const requestJson = async ( + url: string, + init?: RequestInit, + options?: { deliveryOutcomeCanBeUnknown?: boolean }, +): Promise => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10_000) try { @@ -29,7 +34,13 @@ const requestJson = async (url: string, init?: RequestInit): Promise= 400 && response.status < 500 && response.status !== 429) + const outcomeUnknown = Boolean(options?.deliveryOutcomeCanBeUnknown && (response.status === 408 || response.status >= 500)) + throw new DingTalkError( + message, + code, + response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429, + outcomeUnknown, + ) } if (data.errcode !== undefined && Number(data.errcode) !== 0) { throw new DingTalkError(clean(data.errmsg) || '钉钉接口调用失败', String(data.errcode), isDeterministic(data.errcode, data.errmsg)) @@ -37,8 +48,15 @@ const requestJson = async (url: string, init?: RequestInit): Promise { } } +export const exchangeH5AuthCodeForUserId = async (code: string) => { + const token = await getToken() + const data = await requestJson( + `https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?access_token=${encodeURIComponent(token)}`, + { + method: 'POST', + body: JSON.stringify({ code }), + }, + ) + const userId = clean(data.result?.userid || data.result?.userId || data.userid || data.userId) + if (!userId) throw new DingTalkError('钉钉免登接口未返回 userId', 'H5_USER_ID_MISSING', true) + return userId +} + export const sendRobotMessage = async (input: { recipientUserId: string title: string @@ -96,7 +128,7 @@ export const sendRobotMessage = async (input: { text: input.content.slice(0, 4800), }), }), - }) + }, { deliveryOutcomeCanBeUnknown: true }) const invalid = stringList(data.invalidStaffIdList) const filtered = stringList(data.filteredStaffIdList) @@ -105,7 +137,9 @@ export const sendRobotMessage = async (input: { if (filtered.includes(input.recipientUserId)) throw new DingTalkError('收件人不在机器人可见范围内', 'FILTERED_USER', true) if (flowControlled.includes(input.recipientUserId)) throw new DingTalkError('钉钉机器人发送被限流', 'FLOW_CONTROLLED') const processQueryKey = clean(data.processQueryKey) - if (!processQueryKey) throw new DingTalkError('钉钉未返回 processQueryKey', 'PROCESS_QUERY_KEY_MISSING') + if (!processQueryKey) { + throw new DingTalkError('钉钉未返回 processQueryKey,发送结果需要人工核验', 'PROCESS_QUERY_KEY_MISSING', false, true) + } return processQueryKey } diff --git a/src/server.ts b/src/server.ts index 2922007..b05a4f8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,18 @@ import crypto from 'crypto' import express, { NextFunction, Request, Response } from 'express' -import type { ProjectConfig } from './config' +import { buildH5EntryUrl, createH5EntryPage, getRedirectPathFromActionUrl, LoginInputError, validateRedirectPath } from './auth' +import { getDingTalkConfig, getPublicBaseUrl, type ProjectConfig } from './config' +import { DingTalkError, exchangeH5AuthCodeForUserId } from './dingtalk' import prisma from './prisma' import { createCanonicalSignature, safeEqual, sha256 } from './signature' type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig } const clean = (value: unknown) => String(value || '').trim() +const ticketTtlSeconds = () => { + const configured = Number(process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS || 60) + return Number.isInteger(configured) && configured >= 30 && configured <= 60 ? configured : 60 +} const deliveryView = (row: any) => ({ id: row.id, projectCode: row.projectCode, @@ -29,14 +35,21 @@ const validateActionUrl = (value: string, project: ProjectConfig) => { } catch { throw new Error('actionUrl 必须是完整 HTTPS 地址') } - if (url.protocol !== 'https:') throw new Error('actionUrl 只允许 HTTPS') + if (url.protocol !== 'https:' || url.username || url.password || url.port) { + throw new Error('actionUrl 只允许不含凭据且使用标准 443 端口的 HTTPS 地址') + } if (!project.allowedHosts.includes(url.hostname.toLowerCase())) { throw new Error(`actionUrl 域名不在项目白名单内: ${url.hostname}`) } return url.toString() } -export const createServer = (projects: Map) => { +type CreateServerDependencies = { + exchangeH5Code?: (code: string) => Promise +} + +export const createServer = (projects: Map, dependencies: CreateServerDependencies = {}) => { + const exchangeH5Code = dependencies.exchangeH5Code || exchangeH5AuthCodeForUserId const app = express() app.disable('x-powered-by') app.use(express.json({ @@ -53,6 +66,93 @@ export const createServer = (projects: Map) => { } }) + app.get('/v1/auth/h5-entry', (req, res) => { + const project = projects.get(clean(req.query.projectCode)) + if (!project?.login) return res.status(404).send('Not Found') + try { + const redirectPath = validateRedirectPath(req.query.redirectPath, project) + const publicBaseUrl = getPublicBaseUrl() + if (!publicBaseUrl) throw new Error('通知中心公网地址未配置') + const { appKey, corpId } = getDingTalkConfig() + if (!appKey || !corpId) throw new Error('钉钉免登客户端标识或 CorpId 未配置') + const page = createH5EntryPage({ + clientId: appKey, + corpId, + projectCode: project.code, + projectName: project.name, + redirectPath, + h5CodeUrl: `${publicBaseUrl}/v1/auth/h5-code`, + }) + res.set({ + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${page.cspNonce}' https://g.alicdn.com; style-src 'nonce-${page.cspNonce}'; connect-src 'self' ${new URL(publicBaseUrl).origin}; form-action ${new URL(project.login.callbackUrl).origin}; base-uri 'none'; frame-ancestors 'none'`, + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + }) + res.type('html').send(page.html) + } catch (error) { + if (error instanceof LoginInputError) return res.status(422).send('Invalid redirect path') + return res.status(503).send('Login broker unavailable') + } + }) + + app.post('/v1/auth/h5-code', async (req, res, next) => { + const project = projects.get(clean(req.body?.projectCode)) + if (!project?.login) return res.status(404).json({ error: 'PROJECT_LOGIN_NOT_FOUND' }) + const code = clean(req.body?.code) + if (code.length < 8 || code.length > 2048 || /[\u0000-\u001f\u007f]/.test(code)) { + return res.status(422).json({ error: 'H5_CODE_INVALID' }) + } + let redirectPath: string + try { + redirectPath = validateRedirectPath(req.body?.redirectPath, project) + } catch { + return res.status(422).json({ error: 'REDIRECT_PATH_INVALID' }) + } + + let dingtalkUserId: string + try { + dingtalkUserId = clean(await exchangeH5Code(code)) + if (!dingtalkUserId) throw new Error('EMPTY_USER_ID') + } catch (error) { + const status = error instanceof DingTalkError && error.deterministic ? 401 : 502 + return res.status(status).json({ error: 'H5_AUTH_FAILED' }) + } + + const ticket = crypto.randomBytes(32).toString('base64url') + const ticketNonce = crypto.randomBytes(24).toString('base64url') + const now = new Date() + try { + await prisma.dingtalkLoginTicket.deleteMany({ + where: { expiresAt: { lt: new Date(now.getTime() - 24 * 60 * 60_000) } }, + }) + await prisma.dingtalkLoginTicket.create({ + data: { + id: crypto.randomUUID(), + projectCode: project.code, + audience: new URL(project.login.callbackUrl).origin, + ticketHash: sha256(ticket), + ticketNonce, + authCodeHash: sha256(code), + dingtalkUserId, + redirectPath, + expiresAt: new Date(now.getTime() + ticketTtlSeconds() * 1000), + }, + }) + } catch (error: any) { + if (clean(error?.code) === 'P2002') return res.status(401).json({ error: 'H5_AUTH_FAILED' }) + return next(error) + } + + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + res.status(201).json({ + ticket, + formPostUrl: project.login.callbackUrl, + expiresIn: ticketTtlSeconds(), + }) + }) + const authenticate = async (req: SignedRequest, res: Response, next: NextFunction) => { try { const projectCode = clean(req.header('x-yyd-project-code')) @@ -97,9 +197,17 @@ export const createServer = (projects: Map) => { const title = clean(req.body?.title) const content = clean(req.body?.content) const actionTitle = clean(req.body?.actionTitle) || '查看详情' + let destinationActionUrl: string let actionUrl: string try { - actionUrl = validateActionUrl(clean(req.body?.actionUrl), project) + destinationActionUrl = validateActionUrl(clean(req.body?.actionUrl), project) + actionUrl = project.login + ? buildH5EntryUrl({ + publicBaseUrl: getPublicBaseUrl(), + project, + redirectPath: getRedirectPathFromActionUrl(destinationActionUrl, project), + }) + : destinationActionUrl } catch (error: any) { return res.status(422).json({ error: 'ACTION_URL_INVALID', message: clean(error?.message) }) } @@ -109,7 +217,7 @@ export const createServer = (projects: Map) => { if (title.startsWith('【由壹点')) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' }) const renderedTitle = `【由壹点】${project.name}·${title}` - const normalized = { recipientUserId, title, content, actionTitle, actionUrl } + const normalized = { recipientUserId, title, content, actionTitle, actionUrl: destinationActionUrl } const requestHash = sha256(JSON.stringify(normalized)) const existing = await prisma.notificationDelivery.findUnique({ where: { projectCode_idempotencyKey: { projectCode: project.code, idempotencyKey } }, @@ -131,6 +239,7 @@ export const createServer = (projects: Map) => { content, actionTitle, actionUrl, + maxAttempts: 5, }, }) res.status(202).json(deliveryView(created)) @@ -149,9 +258,50 @@ export const createServer = (projects: Map) => { } }) + app.post('/v1/auth/tickets/redeem', authenticate, async (req: SignedRequest, res, next) => { + try { + const project = req.project! + const ticket = clean(req.body?.ticket) + if (!/^[a-zA-Z0-9_-]{43}$/.test(ticket)) { + return res.status(401).json({ error: 'TICKET_INVALID_OR_EXPIRED' }) + } + const ticketHash = sha256(ticket) + const audience = project.login ? new URL(project.login.callbackUrl).origin : '' + const now = new Date() + const redeemed = await prisma.$transaction(async (tx) => { + const consumed = await tx.dingtalkLoginTicket.updateMany({ + where: { + ticketHash, + projectCode: project.code, + audience, + consumedAt: null, + expiresAt: { gt: now }, + }, + data: { consumedAt: now }, + }) + if (consumed.count !== 1) return null + const row = await tx.dingtalkLoginTicket.findUnique({ + where: { ticketHash }, + select: { dingtalkUserId: true, redirectPath: true }, + }) + if (!row) throw new Error('票据消费后读取失败') + return row + }) + if (!redeemed) return res.status(401).json({ error: 'TICKET_INVALID_OR_EXPIRED' }) + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + res.json(redeemed) + } catch (error) { + next(error) + } + }) + app.use((error: any, _req: Request, res: Response, _next: NextFunction) => { - console.error('[NotificationCenter]', error) - res.status(500).json({ error: 'INTERNAL_ERROR', message: clean(error?.message) || '服务异常' }) + console.error('[NotificationCenter]', { + name: clean(error?.name) || 'Error', + code: clean(error?.code) || undefined, + message: clean(error?.message).slice(0, 500) || '服务异常', + }) + res.status(500).json({ error: 'INTERNAL_ERROR', message: '服务异常' }) }) return app } diff --git a/src/worker.ts b/src/worker.ts index 51c526b..c2d1c19 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6,14 +6,50 @@ const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).s const backoffMinutes = [1, 5, 15, 30] let busy = false let readBusy = false +const outcomeUnknownMessage = '钉钉发送结果不确定,请先在钉钉侧人工核验,系统不会自动重试' + +export const quarantineExpiredSendingLeases = async (now = new Date()) => prisma.notificationDelivery.updateMany({ + where: { + status: 'sending', + leaseExpiresAt: { lte: now }, + }, + data: { + status: 'failed', + leaseOwner: null, + leaseExpiresAt: null, + lastErrorCode: 'DELIVERY_OUTCOME_UNKNOWN', + lastErrorMessage: outcomeUnknownMessage, + }, +}) + +export const classifyDeliveryFailure = (input: { + error: unknown + attemptCount: number + maxAttempts: number +}) => { + const error = input.error + if (error instanceof DingTalkError && error.outcomeUnknown) { + return { + failed: true, + errorCode: 'DELIVERY_OUTCOME_UNKNOWN', + errorMessage: outcomeUnknownMessage, + } + } + const deterministic = error instanceof DingTalkError && error.deterministic + const exhausted = input.attemptCount >= input.maxAttempts + return { + failed: deterministic || exhausted, + errorCode: error instanceof DingTalkError ? error.code : 'UNKNOWN', + errorMessage: String((error as any)?.message || '通知发送失败').slice(0, 2000), + } +} const claimNext = async () => { const now = new Date() + await quarantineExpiredSendingLeases(now) const claimable = { - OR: [ - { status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } }, - { status: 'sending', leaseExpiresAt: { lte: now } }, - ], + status: { in: ['pending', 'retrying'] }, + nextAttemptAt: { lte: now }, } const candidate = await prisma.notificationDelivery.findFirst({ where: claimable, orderBy: [{ nextAttemptAt: 'asc' }, { createdAt: 'asc' }] }) if (!candidate) return null @@ -55,19 +91,21 @@ const processOne = async () => { }, }) } catch (error: any) { - const deterministic = error instanceof DingTalkError && error.deterministic - const exhausted = delivery.attemptCount >= delivery.maxAttempts - const failed = deterministic || exhausted + const decision = classifyDeliveryFailure({ + error, + attemptCount: delivery.attemptCount, + maxAttempts: delivery.maxAttempts, + }) const delay = backoffMinutes[Math.min(Math.max(0, delivery.attemptCount - 1), backoffMinutes.length - 1)] await prisma.notificationDelivery.updateMany({ where: { id: delivery.id, status: 'sending', leaseOwner: workerId }, data: { - status: failed ? 'failed' : 'retrying', - nextAttemptAt: failed ? delivery.nextAttemptAt : new Date(Date.now() + delay * 60_000), + status: decision.failed ? 'failed' : 'retrying', + nextAttemptAt: decision.failed ? delivery.nextAttemptAt : new Date(Date.now() + delay * 60_000), leaseOwner: null, leaseExpiresAt: null, - lastErrorCode: error instanceof DingTalkError ? error.code : 'UNKNOWN', - lastErrorMessage: String(error?.message || '通知发送失败').slice(0, 2000), + lastErrorCode: decision.errorCode, + lastErrorMessage: decision.errorMessage, }, }) }