feat: 增加统一钉钉免登票据
This commit is contained in:
parent
69c7f28f45
commit
1a46152cc2
@ -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
|
||||
|
||||
55
README.md
55
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` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
29
prisma/migrations/20260715_login_ticket/migration.sql
Normal file
29
prisma/migrations/20260715_login_ticket/migration.sql
Normal 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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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
65
scripts/run-tests.mjs
Normal 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
217
src/__tests__/auth.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
44
src/__tests__/deployment.test.ts
Normal file
44
src/__tests__/deployment.test.ts
Normal 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 \}\)/)
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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、方法、路径和正文', () => {
|
||||
|
||||
87
src/__tests__/worker.test.ts
Normal file
87
src/__tests__/worker.test.ts
Normal 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
150
src/auth.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
102
src/config.ts
102
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<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')
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
164
src/server.ts
164
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<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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user