feat: 增加统一钉钉免登票据

This commit is contained in:
WuYongJie 2026-07-15 21:06:36 +08:00
parent 69c7f28f45
commit 1a46152cc2
18 changed files with 1152 additions and 53 deletions

View File

@ -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

View File

@ -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` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。

View File

@ -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:

View File

@ -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"
},

View File

@ -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");

View File

@ -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])
}

View File

@ -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

65
scripts/run-tests.mjs Normal file
View File

@ -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

217
src/__tests__/auth.test.ts Normal file
View File

@ -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<void>((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<void>((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()
}
})

View File

@ -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 \}\)/)
})

View File

@ -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)

View File

@ -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、方法、路径和正文', () => {

View File

@ -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)
})

150
src/auth.ts Normal file
View File

@ -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, '\\u003c')
.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 = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>正在打开${escapeHtml(input.projectName)}</title>
<style nonce="${cspNonce}">
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;background:#f5f7fa;color:#172033;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}.wrap{min-height:100dvh;display:grid;place-items:center;padding:24px}.card{width:min(420px,100%);padding:28px;border:1px solid #e5eaf1;border-radius:20px;background:#fff;box-shadow:0 16px 48px rgba(23,32,51,.08)}h1{margin:0 0 12px;font-size:22px}p{margin:0;color:#687386;line-height:1.7}.bar{height:4px;margin-top:24px;overflow:hidden;border-radius:99px;background:#e8eef8}.bar:after{content:"";display:block;width:45%;height:100%;border-radius:inherit;background:#1677ff;animation:move 1.2s ease-in-out infinite alternate}@keyframes move{to{transform:translateX(122%)}}
</style>
</head>
<body>
<main class="wrap"><section class="card"><h1>正在打开${escapeHtml(input.projectName)}</h1><p id="status">正在验证钉钉身份,请稍候…</p><div class="bar"></div></section></main>
<script nonce="${cspNonce}" src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.39/dingtalk.open.js"></script>
<script nonce="${cspNonce}">
(() => {
const config = ${payload};
const status = document.getElementById('status');
const fail = () => { status.textContent = '暂时无法完成验证,请返回钉钉后重试。'; };
const submitTicket = (result) => {
if (!result || !result.ticket || !result.formPostUrl) throw new Error('INVALID_TICKET_RESPONSE');
const form = document.createElement('form');
form.method = 'POST'; form.action = result.formPostUrl; form.style.display = 'none';
const field = document.createElement('input');
field.type = 'hidden'; field.name = 'ticket'; field.value = result.ticket;
form.appendChild(field); document.body.appendChild(form); form.submit();
};
const exchange = async (code) => {
const response = await fetch(config.h5CodeUrl, {
method: 'POST', credentials: 'omit', cache: 'no-store',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({projectCode:config.projectCode,redirectPath:config.redirectPath,code})
});
if (!response.ok) throw new Error('H5_CODE_EXCHANGE_FAILED');
submitTicket(await response.json());
};
if (!/DingTalk/i.test(navigator.userAgent || '') || !window.dd?.ready || !window.dd?.runtime?.permission?.requestAuthCode) { fail(); return; }
window.dd.error(fail);
window.dd.ready(() => window.dd.runtime.permission.requestAuthCode({
clientId: config.clientId,
corpId: config.corpId,
onSuccess: (result) => result?.code ? exchange(String(result.code)).catch(fail) : fail(),
onFail: fail
}));
})();
</script>
</body>
</html>`
return { html, cspNonce }
}
const escapeHtml = (value: string) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')

View File

@ -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<string, unknown>
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<string, ProjectConfig>()
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<string, ProjectConfig>) => {
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')
}
}

View File

@ -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<string> | null = null
const requestJson = async (url: string, init?: RequestInit): Promise<DingTalkPayload> => {
const requestJson = async (
url: string,
init?: RequestInit,
options?: { deliveryOutcomeCanBeUnknown?: boolean },
): Promise<DingTalkPayload> => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
@ -29,7 +34,13 @@ const requestJson = async (url: string, init?: RequestInit): Promise<DingTalkPay
if (!response.ok) {
const code = clean(data.code) || `HTTP_${response.status}`
const message = clean(data.message) || `钉钉接口返回 HTTP ${response.status}`
throw new DingTalkError(message, code, response.status >= 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<DingTalkPay
return data
} catch (error: any) {
if (error instanceof DingTalkError) throw error
if (error?.name === 'AbortError') throw new DingTalkError('钉钉请求超时', 'TIMEOUT')
throw new DingTalkError(clean(error?.message) || '钉钉网络请求失败', 'NETWORK')
if (error?.name === 'AbortError') {
throw new DingTalkError('钉钉请求超时', 'TIMEOUT', false, Boolean(options?.deliveryOutcomeCanBeUnknown))
}
throw new DingTalkError(
clean(error?.message) || '钉钉网络请求失败',
'NETWORK',
false,
Boolean(options?.deliveryOutcomeCanBeUnknown),
)
} finally {
clearTimeout(timeout)
}
@ -70,6 +88,20 @@ const getToken = async () => {
}
}
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
}

View File

@ -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<string, ProjectConfig>) => {
type CreateServerDependencies = {
exchangeH5Code?: (code: string) => Promise<string>
}
export const createServer = (projects: Map<string, ProjectConfig>, 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<string, ProjectConfig>) => {
}
})
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<string, ProjectConfig>) => {
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<string, ProjectConfig>) => {
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<string, ProjectConfig>) => {
content,
actionTitle,
actionUrl,
maxAttempts: 5,
},
})
res.status(202).json(deliveryView(created))
@ -149,9 +258,50 @@ export const createServer = (projects: Map<string, ProjectConfig>) => {
}
})
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
}

View File

@ -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,
},
})
}