diff --git a/.env.example b/.env.example index a00cc97..42ba4aa 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ MYSQL_ROOT_PASSWORD=change-me-local-password MYSQL_DATABASE=equipment_management SESSION_SECRET=change-me-session-secret +SESSION_COOKIE_SECURE=false +CORS_ALLOWED_ORIGINS=http://localhost:5173 AUTH_MODE=local PORT=3000 VITE_API_BASE_URL=/api @@ -32,3 +34,6 @@ YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_asset YYD_NOTIFICATION_CENTER_PROJECT_SECRET= YYD_NOTIFICATION_CENTER_TEST_USER_IDS= YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=false +YYD_NOTIFICATION_CENTER_LOGIN_ENABLED=false +YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS= +YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT=false diff --git a/backend/Dockerfile b/backend/Dockerfile index 0f5e00e..76d2978 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,7 @@ FROM node:20-alpine +ENV NODE_ENV=production + # 安装OpenSSL RUN apk add --no-cache openssl openssl-dev diff --git a/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts b/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts new file mode 100644 index 0000000..e403efd --- /dev/null +++ b/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import prisma from '../../utils/prisma' +import { loginWithYouyidianTicket } from '../dingtalkController' + +const originalFetch = global.fetch +const originalFindFirst = prisma.user.findFirst +const originalFindUnique = prisma.user.findUnique +const envKeys = [ + 'YYD_NOTIFICATION_CENTER_URL', + 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', + 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', + 'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED', + 'YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS', + 'YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT' +] as const +const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) + +const configureLogin = (testUserId = 'ding-user-1') => { + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = testUserId + process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' +} + +const createRequest = (ticket = 'A'.repeat(43)) => { + const session: any = { + regenerate(callback: (error?: Error) => void) { + callback() + }, + save(callback: (error?: Error) => void) { + callback() + } + } + return { body: { ticket }, session } +} + +const createResponse = () => { + const state = { + statusCode: 200, + headers: {} as Record, + type: '', + body: '', + redirect: null as null | { status: number; location: string } + } + const res = { + set(headers: Record) { + Object.assign(state.headers, headers) + return this + }, + status(code: number) { + state.statusCode = code + return this + }, + type(value: string) { + state.type = value + return this + }, + send(value: string) { + state.body = value + return this + }, + redirect(status: number, location: string) { + state.statusCode = status + state.redirect = { status, location } + return this + } + } + return { res, state } +} + +test.after(() => { + global.fetch = originalFetch + ;(prisma.user.findFirst as any) = originalFindFirst + ;(prisma.user.findUnique as any) = originalFindUnique + for (const key of envKeys) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } +}) + +test('一次性票据兑换成功后建立绑定userid的会话并303进入内部完成页', async () => { + configureLogin() + let capturedUrl = '' + global.fetch = (async (url: string | URL | Request) => { + capturedUrl = String(url) + return new Response(JSON.stringify({ + dingtalkUserId: 'ding-user-1', + redirectPath: '/workflows/disposals/53' + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + ;(prisma.user.findFirst as any) = async () => ({ id: 3 }) + ;(prisma.user.findUnique as any) = async () => ({ + id: 3, + username: 'magic-red', + name: '魔礼红', + email: null, + phone: null, + role: 'admin', + status: 'active', + person_id: 108, + person: { + id: 108, + real_name: '吴永杰', + nickname: '魔礼红', + status: 'active', + source_employee_status: '3' + } + }) + + const req = createRequest() + const { res, state } = createResponse() + await loginWithYouyidianTicket(req as any, res as any) + + assert.deepEqual(req.session.user, { + id: 3, + auth_source: 'dingtalk', + dingtalk_userid: 'ding-user-1' + }) + assert.deepEqual(state.redirect, { + status: 303, + location: '/dingtalk-entry?broker=complete&redirect=%2Fworkflows%2Fdisposals%2F53' + }) + assert.equal(state.headers['Cache-Control'], 'no-store') + assert.equal(capturedUrl, 'https://notify.example.com/v1/auth/tickets/redeem') + assert.equal(capturedUrl.includes('A'.repeat(43)), false) +}) + +test('票据属于灰度范围外用户时不建立会话也不查询系统账号', async () => { + configureLogin('ding-test-only') + global.fetch = (async () => new Response(JSON.stringify({ + dingtalkUserId: 'ding-user-1', + redirectPath: '/dashboard' + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch + let userLookups = 0 + ;(prisma.user.findFirst as any) = async () => { + userLookups += 1 + return { id: 3 } + } + + const req = createRequest('B'.repeat(43)) + const { res, state } = createResponse() + await loginWithYouyidianTicket(req as any, res as any) + + assert.equal(userLookups, 0) + assert.equal(req.session.user, undefined) + assert.equal(state.statusCode, 403) + assert.match(state.body, /不在本次免登灰度范围/) +}) + +test('通知中心返回外部跳转地址时拒绝建立会话', async () => { + configureLogin() + global.fetch = (async () => new Response(JSON.stringify({ + dingtalkUserId: 'ding-user-1', + redirectPath: 'https://evil.example/path' + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch + + const req = createRequest('C'.repeat(43)) + const { res, state } = createResponse() + await loginWithYouyidianTicket(req as any, res as any) + + assert.equal(req.session.user, undefined) + assert.equal(state.statusCode, 401) + assert.match(state.body, /登录凭证无效或已过期/) +}) diff --git a/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts b/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts index 085540c..fc2e458 100644 --- a/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts +++ b/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { normalizeInternalRedirect } from '../../utils/dingtalkRedirect' +import { + buildDingTalkBrokerCompletionPath, + normalizeInternalRedirect +} from '../../utils/dingtalkRedirect' test('钉钉免登只接受白名单内的系统路径', () => { assert.equal( @@ -16,3 +19,14 @@ test('钉钉免登拒绝外链、协议相对地址和非白名单路径', () => assert.throws(() => normalizeInternalRedirect('/admin/secrets'), /允许范围/) assert.throws(() => normalizeInternalRedirect('/workflows/disposals-evil'), /允许范围/) }) + +test('票据兑换后只通过无敏感信息的内部完成页恢复前端会话', () => { + assert.equal( + buildDingTalkBrokerCompletionPath('/workflows/disposals/123?source=ding'), + '/dingtalk-entry?broker=complete&redirect=%2Fworkflows%2Fdisposals%2F123%3Fsource%3Dding' + ) + assert.throws( + () => buildDingTalkBrokerCompletionPath('https://evil.example/path'), + /允许范围/ + ) +}) diff --git a/backend/src/controllers/dingtalkController.ts b/backend/src/controllers/dingtalkController.ts index 1a8efea..b4aca62 100644 --- a/backend/src/controllers/dingtalkController.ts +++ b/backend/src/controllers/dingtalkController.ts @@ -7,11 +7,81 @@ import { getDingTalkConfigStatus, getDingTalkPublicConfig } from '../services/dingtalkClient' -import { prepareNotificationCenterManualRetryPayload } from '../services/youyidianNotificationClient' -import { normalizeInternalRedirect } from '../utils/dingtalkRedirect' +import { + NotificationCenterError, + getNotificationCenterLoginConfigStatus, + isNotificationCenterLoginAllowedForUser, + prepareNotificationCenterManualRetryPayload, + redeemYouyidianLoginTicket +} from '../services/youyidianNotificationClient' +import { findEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService' +import { + buildDingTalkBrokerCompletionPath, + normalizeInternalRedirect +} from '../utils/dingtalkRedirect' -export const getPublicDingTalkConfig = (_req: Request, res: Response) => { - successResponse(res, getDingTalkPublicConfig(), '获取成功') +const establishUserSession = async ( + req: Request, + userId: number, + authSource?: 'dingtalk', + dingtalkUserId?: string +) => { + await new Promise((resolve, reject) => { + ;((req as any).session as any).regenerate((error?: Error) => { + if (error) return reject(error) + resolve() + }) + }) + ;((req as any).session as any).user = authSource + ? { id: userId, auth_source: authSource, dingtalk_userid: dingtalkUserId } + : { id: userId } + await new Promise((resolve, reject) => { + ;((req as any).session as any).save((error?: Error) => { + if (error) return reject(error) + resolve() + }) + }) +} + +const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}[character] || character)) + +const sendBrokerLoginError = (res: Response, status: number, message: string) => { + res.set({ + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff' + }) + return res.status(status).type('html').send(` + + + + + 固定资产免登失败 + + +

暂时无法进入固定资产系统

${escapeHtml(message)}

返回系统登录页
+`) +} + +export const getPublicDingTalkConfig = (req: Request, res: Response) => { + try { + const safeRedirect = normalizeInternalRedirect(req.query.redirectPath ?? req.query.redirect) + successResponse(res, getDingTalkPublicConfig(safeRedirect), '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取钉钉免登配置失败', 422) + } } export const loginWithDingTalkCode = async (req: Request, res: Response) => { @@ -23,31 +93,13 @@ export const loginWithDingTalkCode = async (req: Request, res: Response) => { const safeRedirect = normalizeInternalRedirect(redirectPath ?? redirect_path) const dingTalkUserId = await exchangeDingTalkLoginCode(code.trim()) - const user = await prisma.user.findFirst({ - where: { - status: 'active', - person_id: { not: null }, - person: { - is: { - source_userid: dingTalkUserId, - status: 'active' - } - } - }, - select: { id: true } - }) + const user = await findEligibleDingTalkLoginUser(dingTalkUserId) if (!user) { return errorResponse(res, '未找到启用且绑定在职员工档案的系统账号', 403) } const authUser = await resolveAuthUserById(user.id) - await new Promise((resolve, reject) => { - ;(req.session as any).regenerate((error?: Error) => { - if (error) return reject(error) - resolve() - }) - }) - ;(req.session as any).user = { id: user.id } + await establishUserSession(req, user.id, 'dingtalk', dingTalkUserId) successResponse(res, { user: authUser, @@ -60,6 +112,53 @@ export const loginWithDingTalkCode = async (req: Request, res: Response) => { } } +export const loginWithYouyidianTicket = async (req: Request, res: Response) => { + res.set({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff' + }) + try { + const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : '' + if (!/^[A-Za-z0-9_-]{32,256}$/.test(ticket)) { + return sendBrokerLoginError(res, 422, '登录凭证格式无效,请返回钉钉重新打开通知。') + } + + const redeemed = await redeemYouyidianLoginTicket(ticket) + const safeRedirect = normalizeInternalRedirect(redeemed.redirectPath) + if (!isNotificationCenterLoginAllowedForUser(redeemed.dingtalkUserId)) { + return sendBrokerLoginError(res, 403, '当前钉钉账号不在本次免登灰度范围内,请使用原登录方式。') + } + const user = await findEligibleDingTalkLoginUser(redeemed.dingtalkUserId) + if (!user) { + return sendBrokerLoginError(res, 403, '当前钉钉账号未绑定启用且在职的固定资产系统账号,请联系管理员。') + } + + await resolveAuthUserById(user.id) + await establishUserSession(req, user.id, 'dingtalk', redeemed.dingtalkUserId) + return res.redirect(303, buildDingTalkBrokerCompletionPath(safeRedirect)) + } catch (error: any) { + if (error instanceof NotificationCenterError) { + if (error.code === 'CONFIG_INCOMPLETE') { + return sendBrokerLoginError(res, 503, '统一通知登录服务尚未配置完整,请联系管理员。') + } + if (error.code === 'TIMEOUT') { + return sendBrokerLoginError(res, 504, '统一通知登录服务响应超时,请返回钉钉后重试。') + } + if (error.code === 'NETWORK') { + return sendBrokerLoginError(res, 502, '暂时无法连接统一通知登录服务,请稍后重试。') + } + return sendBrokerLoginError(res, 401, '登录凭证无效或已过期,请返回钉钉重新打开通知。') + } + const status = Number.isInteger(error?.status) ? error.status : 500 + return sendBrokerLoginError( + res, + status, + status === 403 ? '当前系统账号不可用,请联系管理员。' : '建立登录会话失败,请稍后重试。' + ) + } +} + export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => { try { const [pending, failed, sent, read, unread, errorGroups] = await Promise.all([ @@ -77,12 +176,18 @@ export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => }) ]) const config = getDingTalkConfigStatus() + const loginConfig = getNotificationCenterLoginConfigStatus() successResponse(res, { configured: config.configured, channel: config.channel, send_enabled: config.send_enabled, enabled: config.send_enabled, missing: config.missing, + login_configured: loginConfig.configured, + login_enabled: loginConfig.enabled, + login_rollout_mode: loginConfig.rollout_mode, + login_test_user_count: loginConfig.test_user_count, + login_missing: loginConfig.missing, pending, failed, sent, diff --git a/backend/src/index.ts b/backend/src/index.ts index 2791d63..24b4171 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,15 +38,25 @@ import { PERMISSIONS } from './utils/permissions' import { startHrSyncScheduler } from './services/hrSyncService' import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService' import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService' +import { isCorsOriginAllowed } from './utils/corsPolicy' +import { unsafeOriginGuard } from './middleware/originGuard' dotenv.config() const app = express() const PORT = process.env.PORT || 3000 const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret' +const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined + ? process.env.NODE_ENV === 'production' + : process.env.SESSION_COOKIE_SECURE === 'true' // Middleware -app.use(cors({ origin: true, credentials: true })) +app.set('trust proxy', 1) +app.use(cors({ + origin: (origin, callback) => callback(null, isCorsOriginAllowed(origin) ? (origin || true) : false), + credentials: true +})) +app.use(unsafeOriginGuard) app.use(express.json({ limit: '50mb' })) app.use(express.urlencoded({ extended: true, limit: '50mb' })) app.use( @@ -56,7 +66,9 @@ app.use( saveUninitialized: false, store: memoryStore, cookie: { - secure: false, + secure: SESSION_COOKIE_SECURE, + httpOnly: true, + sameSite: 'lax', maxAge: 1000 * 60 * 60 // 1 hour } }) diff --git a/backend/src/middleware/__tests__/auth.priority.test.ts b/backend/src/middleware/__tests__/auth.priority.test.ts index 56e2914..6da639a 100644 --- a/backend/src/middleware/__tests__/auth.priority.test.ts +++ b/backend/src/middleware/__tests__/auth.priority.test.ts @@ -148,3 +148,48 @@ test('local 模式即使带真实 Bearer 也只使用本地 session', async () = ;(prisma.user.findUnique as any) = originalFindUnique } }) + +test('钉钉会话在员工改绑后立即失效且不继续读取系统用户', async () => { + const originalMode = process.env.AUTH_MODE + const originalFindFirst = prisma.user.findFirst + const originalFindUnique = prisma.user.findUnique + let findUniqueCalls = 0 + + process.env.AUTH_MODE = 'local' + ;(prisma.user.findFirst as any) = async (args: any) => { + assert.equal(args.where.id, 41) + assert.equal(args.where.person.is.source_userid, 'ding-user-a') + return null + } + ;(prisma.user.findUnique as any) = async () => { + findUniqueCalls += 1 + return makeUser(41, 'rebound-user') + } + + try { + const middleware = protect() + const req = { + headers: {}, + session: { + user: { + id: 41, + auth_source: 'dingtalk', + dingtalk_userid: 'ding-user-a' + } + } + } + const { res, next, state } = createResponse() + await middleware(req as any, res as any, next as any) + + assert.equal(state.nextCount, 0) + assert.equal(state.statusCode, 401) + assert.equal(state.body.message, '钉钉绑定已失效,请重新登录或联系管理员') + assert.equal(req.session.user, undefined) + assert.equal(findUniqueCalls, 0) + } finally { + if (originalMode === undefined) delete process.env.AUTH_MODE + else process.env.AUTH_MODE = originalMode + ;(prisma.user.findFirst as any) = originalFindFirst + ;(prisma.user.findUnique as any) = originalFindUnique + } +}) diff --git a/backend/src/middleware/__tests__/originGuard.test.ts b/backend/src/middleware/__tests__/originGuard.test.ts new file mode 100644 index 0000000..e13075d --- /dev/null +++ b/backend/src/middleware/__tests__/originGuard.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { unsafeOriginGuard } from '../originGuard' + +const originalEnv = { + nodeEnv: process.env.NODE_ENV, + publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL, + centerUrl: process.env.YYD_NOTIFICATION_CENTER_URL, + corsOrigins: process.env.CORS_ALLOWED_ORIGINS +} + +test.before(() => { + process.env.NODE_ENV = 'production' + process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://sc.example.com/notification-center' + process.env.CORS_ALLOWED_ORIGINS = '' +}) + +test.after(() => { + for (const [key, value] of Object.entries({ + NODE_ENV: originalEnv.nodeEnv, + DINGTALK_PUBLIC_BASE_URL: originalEnv.publicBaseUrl, + YYD_NOTIFICATION_CENTER_URL: originalEnv.centerUrl, + CORS_ALLOWED_ORIGINS: originalEnv.corsOrigins + })) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +const runGuard = (input: { method: string; origin?: string; path: string }) => { + const state = { status: 200, body: null as any, nextCalls: 0 } + const req = { + method: input.method, + protocol: 'https', + path: input.path, + get(name: string) { + if (name === 'host') return 'asset.example.com' + if (name === 'origin') return input.origin + return undefined + } + } + const res = { + status(code: number) { + state.status = code + return this + }, + json(body: any) { + state.body = body + return this + } + } + unsafeOriginGuard(req as any, res as any, () => { + state.nextCalls += 1 + }) + return state +} + +test('恶意同站来源的资产写操作在进入业务路由前返回403', () => { + const result = runGuard({ + method: 'POST', + origin: 'https://sc.example.com', + path: '/api/equipment' + }) + assert.equal(result.status, 403) + assert.equal(result.nextCalls, 0) + assert.deepEqual(result.body, { code: 403, message: '请求来源不允许', data: null }) +}) + +test('通知中心来源只可进入一次性票据回调', () => { + assert.equal(runGuard({ + method: 'POST', + origin: 'https://sc.example.com', + path: '/api/dingtalk/broker-login' + }).nextCalls, 1) + assert.equal(runGuard({ + method: 'DELETE', + origin: 'https://sc.example.com', + path: '/api/equipment/1' + }).status, 403) +}) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index af430af..3cad3e8 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -6,6 +6,7 @@ import { resolveOrProvisionAuthUser } from '../utils/authIdentity' import { PERMISSIONS, type Permission } from '../utils/permissions' +import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService' const isLocalAuth = () => process.env.AUTH_MODE === 'local' @@ -22,14 +23,31 @@ export function protect(): RequestHandler { return async function authWrapper(req: Request, res: Response, next: NextFunction) { const authenticateSession = async () => { - const sessionUserId = Number((req as any).session?.user?.id) + const sessionUser = (req as any).session?.user + const sessionUserId = Number(sessionUser?.id) + const authSource = sessionUser?.auth_source === 'dingtalk' ? 'dingtalk' : undefined + const sessionDingTalkUserId = authSource === 'dingtalk' + ? String(sessionUser?.dingtalk_userid || '').trim() + : '' if (!Number.isInteger(sessionUserId) || sessionUserId <= 0) { return false } try { + if (authSource === 'dingtalk') { + const eligibleUser = await findEligibleDingTalkSessionUser(sessionUserId, sessionDingTalkUserId) + if (!eligibleUser) { + throw new AuthIdentityError('钉钉绑定已失效,请重新登录或联系管理员', 401) + } + } const currentUser = await resolveAuthUserById(sessionUserId) - ;(req as any).session.user = { id: currentUser.id } + ;(req as any).session.user = authSource + ? { + id: currentUser.id, + auth_source: authSource, + dingtalk_userid: sessionDingTalkUserId + } + : { id: currentUser.id } res.locals.user = currentUser next() return true diff --git a/backend/src/middleware/originGuard.ts b/backend/src/middleware/originGuard.ts new file mode 100644 index 0000000..0836c7a --- /dev/null +++ b/backend/src/middleware/originGuard.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from 'express' +import { isUnsafeRequestOriginAllowed } from '../utils/corsPolicy' + +export const unsafeOriginGuard: RequestHandler = (req, res, next) => { + const requestOrigin = `${req.protocol}://${req.get('host') || ''}` + if (!isUnsafeRequestOriginAllowed({ + method: req.method, + origin: req.get('origin'), + requestOrigin, + path: req.path + })) { + return res.status(403).json({ code: 403, message: '请求来源不允许', data: null }) + } + return next() +} diff --git a/backend/src/routes/dingtalk.ts b/backend/src/routes/dingtalk.ts index e8aefd0..c4ab552 100644 --- a/backend/src/routes/dingtalk.ts +++ b/backend/src/routes/dingtalk.ts @@ -4,6 +4,7 @@ import { getDingTalkDeliveryStatus, getPublicDingTalkConfig, loginWithDingTalkCode, + loginWithYouyidianTicket, retryDingTalkDelivery } from '../controllers/dingtalkController' import { protect, requirePermission } from '../middleware/auth' @@ -13,6 +14,7 @@ const router = Router() router.get('/public-config', getPublicDingTalkConfig) router.post('/h5-login', loginWithDingTalkCode) +router.post('/broker-login', loginWithYouyidianTicket) router.use('/deliveries', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS)) router.get('/deliveries/status', getDingTalkDeliveryStatus) diff --git a/backend/src/services/__tests__/dingtalkBrokerLoginService.test.ts b/backend/src/services/__tests__/dingtalkBrokerLoginService.test.ts new file mode 100644 index 0000000..965fd66 --- /dev/null +++ b/backend/src/services/__tests__/dingtalkBrokerLoginService.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + findEligibleDingTalkLoginUser, + findEligibleDingTalkSessionUser +} from '../dingtalkBrokerLoginService' + +test('钉钉免登只匹配启用账号、已绑定员工、在职档案和精确userid', async () => { + let captured: any + const client = { + user: { + findFirst: async (args: any) => { + captured = args + return { id: 3 } + } + } + } + const user = await findEligibleDingTalkLoginUser(' ding-user-1 ', client as any) + assert.deepEqual(user, { id: 3 }) + assert.deepEqual(captured, { + where: { + status: 'active', + person_id: { not: null }, + person: { + is: { + source_userid: 'ding-user-1', + status: 'active' + } + } + }, + select: { id: true } + }) +}) + +test('钉钉会话每次请求重新核验绑定和在职状态', async () => { + let captured: any + const client = { + user: { + findFirst: async (args: any) => { + captured = args + return { id: 3 } + } + } + } + const user = await findEligibleDingTalkSessionUser(3, 'ding-user-1', client as any) + assert.deepEqual(user, { id: 3 }) + assert.deepEqual(captured.where, { + id: 3, + status: 'active', + person_id: { not: null }, + person: { + is: { + source_userid: 'ding-user-1', + status: 'active' + } + } + }) +}) + +test('空userid和非法用户编号不会查询数据库', async () => { + let calls = 0 + const client = { + user: { + findFirst: async () => { + calls += 1 + return { id: 3 } + } + } + } + assert.equal(await findEligibleDingTalkLoginUser(' ', client as any), null) + assert.equal(await findEligibleDingTalkSessionUser(0, 'ding-user-1', client as any), null) + assert.equal(await findEligibleDingTalkSessionUser(3, '', client as any), null) + assert.equal(calls, 0) +}) diff --git a/backend/src/services/__tests__/dingtalkClient.config.test.ts b/backend/src/services/__tests__/dingtalkClient.config.test.ts index f2ca8a9..68999c9 100644 --- a/backend/src/services/__tests__/dingtalkClient.config.test.ts +++ b/backend/src/services/__tests__/dingtalkClient.config.test.ts @@ -9,7 +9,8 @@ test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => { 'DINGTALK_APP_SECRET', 'DINGTALK_AGENT_ID', 'DINGTALK_CORP_ID', - 'DINGTALK_PUBLIC_BASE_URL' + 'DINGTALK_PUBLIC_BASE_URL', + 'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED' ] as const const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) @@ -18,11 +19,13 @@ test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => { process.env.DINGTALK_AGENT_ID = '10001' process.env.DINGTALK_CORP_ID = 'ding-corp' process.env.DINGTALK_PUBLIC_BASE_URL = 'https://assets.example.com' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'false' try { const config = getDingTalkPublicConfig() assert.deepEqual(config, { configured: true, + mode: 'work_notice', corpId: 'ding-corp', clientId: 'ding-app-key' }) @@ -77,3 +80,56 @@ test('通知中心配置状态只返回灰度数量且不泄露密钥或员工us } } }) + +test('免登灰度不接管旧入口,只有全量开关才统一跳转通知中心', () => { + const keys = [ + 'DINGTALK_APP_KEY', + 'DINGTALK_APP_SECRET', + 'DINGTALK_AGENT_ID', + 'DINGTALK_CORP_ID', + 'DINGTALK_PUBLIC_BASE_URL', + 'YYD_NOTIFICATION_CENTER_URL', + 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', + 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', + 'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED', + 'YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS', + 'YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT' + ] as const + const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + + delete process.env.DINGTALK_APP_KEY + delete process.env.DINGTALK_APP_SECRET + delete process.env.DINGTALK_AGENT_ID + delete process.env.DINGTALK_CORP_ID + process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + + try { + assert.deepEqual(getDingTalkPublicConfig('/dashboard'), { + configured: false, + mode: 'work_notice', + corpId: '', + clientId: '' + }) + + process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'true' + const fullConfig = getDingTalkPublicConfig('/dashboard') + assert.equal(fullConfig.configured, true) + assert.equal(fullConfig.mode, 'notification_center') + assert.equal(fullConfig.rolloutMode, 'full') + assert.equal( + fullConfig.loginEntryUrl, + 'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fdashboard' + ) + } finally { + for (const key of keys) { + if (original[key] === undefined) delete process.env[key] + else process.env[key] = original[key] + } + } +}) diff --git a/backend/src/services/__tests__/youyidianNotificationClient.test.ts b/backend/src/services/__tests__/youyidianNotificationClient.test.ts index c0aee6f..17bb915 100644 --- a/backend/src/services/__tests__/youyidianNotificationClient.test.ts +++ b/backend/src/services/__tests__/youyidianNotificationClient.test.ts @@ -1,10 +1,14 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + buildYouyidianLoginEntryUrl, createNotificationCenterSignature, getNotificationCenterConfigStatus, + getNotificationCenterLoginConfigStatus, + isNotificationCenterLoginAllowedForUser, NotificationCenterError, prepareNotificationCenterManualRetryPayload, + redeemYouyidianLoginTicket, submitYouyidianNotification } from '../youyidianNotificationClient' @@ -13,7 +17,10 @@ const originalEnv = { url: process.env.YYD_NOTIFICATION_CENTER_URL, code: process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE, secret: process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET, - publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL + publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL, + loginEnabled: process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED, + loginTestUsers: process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS, + loginFullRollout: process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT } test.after(() => { @@ -26,6 +33,12 @@ test.after(() => { else process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = originalEnv.secret if (originalEnv.publicBaseUrl === undefined) delete process.env.DINGTALK_PUBLIC_BASE_URL else process.env.DINGTALK_PUBLIC_BASE_URL = originalEnv.publicBaseUrl + if (originalEnv.loginEnabled === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED + else process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = originalEnv.loginEnabled + if (originalEnv.loginTestUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS + else process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = originalEnv.loginTestUsers + if (originalEnv.loginFullRollout === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT + else process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = originalEnv.loginFullRollout }) test('HMAC签名固定覆盖POST正文与GET空正文', () => { @@ -83,7 +96,76 @@ test('固定资产通过独立项目签名提交到由壹点通知中心', async assert.equal(headers['x-yyd-project-code'], 'fixed_asset') assert.match(headers['x-yyd-signature'], /^[a-f0-9]{64}$/) const body = JSON.parse(String(captured.init?.body)) - assert.equal(body.actionUrl, 'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53') + assert.equal(body.actionUrl, 'https://asset.example.com/workflows/disposals/53') +}) + +test('统一免登默认关闭且灰度时只放行指定钉钉用户', () => { + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'false' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = '' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + assert.equal(getNotificationCenterLoginConfigStatus().configured, false) + + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user' + assert.equal(getNotificationCenterLoginConfigStatus().rollout_mode, 'test') + assert.equal(isNotificationCenterLoginAllowedForUser('ding-test-user'), true) + assert.equal(isNotificationCenterLoginAllowedForUser('other-user'), false) +}) + +test('统一免登入口只携带项目代码和白名单内路径', () => { + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + assert.equal( + buildYouyidianLoginEntryUrl('/workflows/disposals/53?source=ding'), + 'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fworkflows%2Fdisposals%2F53%3Fsource%3Dding' + ) + assert.throws( + () => buildYouyidianLoginEntryUrl('https://evil.example/path'), + /允许范围/ + ) +}) + +test('固定资产以项目HMAC兑换一次性票据并再次校验跳转地址', async () => { + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-user-1' + process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + let captured: { url?: string; init?: RequestInit } = {} + global.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + captured = { url: String(url), init } + return new Response(JSON.stringify({ + dingtalkUserId: 'ding-user-1', + redirectPath: '/workflows/disposals/53' + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + + const result = await redeemYouyidianLoginTicket('A'.repeat(43)) + assert.deepEqual(result, { + dingtalkUserId: 'ding-user-1', + redirectPath: '/workflows/disposals/53' + }) + assert.equal(captured.url, 'https://notify.example.com/v1/auth/tickets/redeem') + assert.deepEqual(JSON.parse(String(captured.init?.body)), { ticket: 'A'.repeat(43) }) + const headers = captured.init?.headers as Record + assert.match(headers['x-yyd-signature'], /^[a-f0-9]{64}$/) + + global.fetch = (async () => new Response(JSON.stringify({ + dingtalkUserId: 'ding-user-1', + redirectPath: 'https://evil.example/path' + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch + await assert.rejects( + redeemYouyidianLoginTicket('B'.repeat(43)), + (error: unknown) => error instanceof NotificationCenterError && error.code === 'INVALID_RESPONSE' + ) }) test('固定资产客户端拒绝误用其它项目代码', () => { diff --git a/backend/src/services/dingtalkBrokerLoginService.ts b/backend/src/services/dingtalkBrokerLoginService.ts new file mode 100644 index 0000000..ed0431d --- /dev/null +++ b/backend/src/services/dingtalkBrokerLoginService.ts @@ -0,0 +1,49 @@ +import prisma from '../utils/prisma' + +type UserLookupClient = Pick + +export const findEligibleDingTalkLoginUser = async ( + dingtalkUserId: string, + client: UserLookupClient = prisma +) => { + const normalizedUserId = String(dingtalkUserId || '').trim() + if (!normalizedUserId) return null + + return client.user.findFirst({ + where: { + status: 'active', + person_id: { not: null }, + person: { + is: { + source_userid: normalizedUserId, + status: 'active' + } + } + }, + select: { id: true } + }) +} + +export const findEligibleDingTalkSessionUser = async ( + userId: number, + dingtalkUserId: string, + client: UserLookupClient = prisma +) => { + const normalizedDingTalkUserId = String(dingtalkUserId || '').trim() + if (!Number.isInteger(userId) || userId <= 0 || !normalizedDingTalkUserId) return null + + return client.user.findFirst({ + where: { + id: userId, + status: 'active', + person_id: { not: null }, + person: { + is: { + source_userid: normalizedDingTalkUserId, + status: 'active' + } + } + }, + select: { id: true } + }) +} diff --git a/backend/src/services/dingtalkClient.ts b/backend/src/services/dingtalkClient.ts index b1ce195..356fad9 100644 --- a/backend/src/services/dingtalkClient.ts +++ b/backend/src/services/dingtalkClient.ts @@ -1,4 +1,8 @@ -import { getNotificationCenterConfigStatus } from './youyidianNotificationClient' +import { + buildYouyidianLoginEntryUrl, + getNotificationCenterConfigStatus, + getNotificationCenterLoginConfigStatus +} from './youyidianNotificationClient' type DingTalkResponse = { errcode?: number @@ -86,12 +90,25 @@ export const getDingTalkConfigStatus = () => { } } -export const getDingTalkPublicConfig = () => { +export const getDingTalkPublicConfig = (redirectPath: string = '/') => { const config = requiredConfig() + const notificationCenterLoginConfig = getNotificationCenterLoginConfigStatus() + if ( + notificationCenterLoginConfig.configured && + notificationCenterLoginConfig.rollout_mode === 'full' + ) { + return { + configured: true, + mode: 'notification_center' as const, + loginEntryUrl: buildYouyidianLoginEntryUrl(redirectPath), + rolloutMode: notificationCenterLoginConfig.rollout_mode + } + } return { configured: Boolean( config.appKey && config.appSecret && config.agentId && config.corpId && config.publicBaseUrl ), + mode: 'work_notice' as const, corpId: config.corpId, // requestAuthCode JSAPI 要求传入企业内部应用 AppKey;AppKey 是客户端标识,不包含 AppSecret。 clientId: config.appKey diff --git a/backend/src/services/youyidianNotificationClient.ts b/backend/src/services/youyidianNotificationClient.ts index 8523e64..b36b097 100644 --- a/backend/src/services/youyidianNotificationClient.ts +++ b/backend/src/services/youyidianNotificationClient.ts @@ -1,4 +1,5 @@ import crypto from 'crypto' +import { normalizeInternalRedirect } from '../utils/dingtalkRedirect' export type NotificationCenterStatus = 'pending' | 'processing' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed' @@ -33,6 +34,15 @@ const config = () => ({ secret: String(process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET || '').trim() }) +const envFlag = (name: string) => String(process.env[name] || '').trim().toLowerCase() === 'true' + +const notificationCenterLoginTestUserIds = () => Array.from(new Set( + String(process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +)) + export const getNotificationCenterConfigStatus = () => { const value = config() const missing: string[] = [] @@ -58,6 +68,31 @@ export const getNotificationCenterConfigStatus = () => { return { configured: missing.length === 0, missing } } +export const getNotificationCenterLoginConfigStatus = () => { + const enabled = envFlag('YYD_NOTIFICATION_CENTER_LOGIN_ENABLED') + const fullRollout = envFlag('YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT') + const testUserIds = notificationCenterLoginTestUserIds() + const missing = [...getNotificationCenterConfigStatus().missing] + if (!enabled) missing.push('YYD_NOTIFICATION_CENTER_LOGIN_ENABLED') + if (enabled && !fullRollout && testUserIds.length === 0) { + missing.push('YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS') + } + return { + configured: missing.length === 0, + enabled, + rollout_mode: fullRollout ? 'full' as const : testUserIds.length > 0 ? 'test' as const : 'disabled' as const, + test_user_count: testUserIds.length, + missing + } +} + +export const isNotificationCenterLoginAllowedForUser = (dingtalkUserId: string) => { + const status = getNotificationCenterLoginConfigStatus() + if (!status.configured) return false + if (status.rollout_mode === 'full') return true + return notificationCenterLoginTestUserIds().includes(String(dingtalkUserId || '').trim()) +} + const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex') export const createNotificationCenterSignature = (input: { @@ -163,8 +198,69 @@ const validateDelivery = (data: Record) => { const buildActionUrl = (actionUrl: string) => { const publicBaseUrl = String(process.env.DINGTALK_PUBLIC_BASE_URL || '').trim().replace(/\/$/, '') - const safePath = actionUrl.startsWith('/') && !actionUrl.startsWith('//') ? actionUrl : '/' - return `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(safePath)}` + const safePath = normalizeInternalRedirect(actionUrl) + let baseUrl: URL + try { + baseUrl = new URL(publicBaseUrl) + } catch { + throw new NotificationCenterError('固定资产公开地址格式无效', 'PUBLIC_BASE_URL_INVALID', true) + } + if ( + baseUrl.protocol !== 'https:' || + baseUrl.username || + baseUrl.password || + baseUrl.search || + baseUrl.hash + ) { + throw new NotificationCenterError('固定资产公开地址必须为不含凭证和查询参数的HTTPS地址', 'PUBLIC_BASE_URL_INVALID', true) + } + return new URL(safePath, `${baseUrl.toString().replace(/\/$/, '')}/`).toString() +} + +export const buildYouyidianLoginEntryUrl = (redirectPath: unknown) => { + const value = config() + const status = getNotificationCenterLoginConfigStatus() + if (!status.configured) { + throw new NotificationCenterError( + `由壹点通知中心配置不完整: ${status.missing.join(', ')}`, + 'CONFIG_INCOMPLETE', + true + ) + } + const safeRedirect = normalizeInternalRedirect(redirectPath) + const query = new URLSearchParams({ + projectCode: value.projectCode, + redirectPath: safeRedirect + }) + return `${value.baseUrl}/v1/auth/h5-entry?${query.toString()}` +} + +export type NotificationCenterLoginTicket = { + dingtalkUserId: string + redirectPath: string +} + +export const redeemYouyidianLoginTicket = async (ticket: string): Promise => { + const status = getNotificationCenterLoginConfigStatus() + if (!status.configured) { + throw new NotificationCenterError( + `由壹点通知中心免登配置未启用: ${status.missing.join(', ')}`, + 'CONFIG_INCOMPLETE', + true + ) + } + const data = await request('/v1/auth/tickets/redeem', 'POST', { ticket }) + const dingtalkUserId = String(data.dingtalkUserId || '').trim() + if (!dingtalkUserId) { + throw new NotificationCenterError('由壹点通知中心返回了无效的登录身份', 'INVALID_RESPONSE', true) + } + let redirectPath: string + try { + redirectPath = normalizeInternalRedirect(data.redirectPath) + } catch { + throw new NotificationCenterError('由壹点通知中心返回了无效的跳转地址', 'INVALID_RESPONSE', true) + } + return { dingtalkUserId, redirectPath } } export const submitYouyidianNotification = async (input: { diff --git a/backend/src/types/express-session.d.ts b/backend/src/types/express-session.d.ts index d0c9978..55f9462 100644 --- a/backend/src/types/express-session.d.ts +++ b/backend/src/types/express-session.d.ts @@ -8,6 +8,8 @@ declare module 'express-session' { store?: Store cookie?: { secure?: boolean + httpOnly?: boolean + sameSite?: boolean | 'lax' | 'strict' | 'none' maxAge?: number } } diff --git a/backend/src/utils/__tests__/corsPolicy.test.ts b/backend/src/utils/__tests__/corsPolicy.test.ts new file mode 100644 index 0000000..232a097 --- /dev/null +++ b/backend/src/utils/__tests__/corsPolicy.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isCorsOriginAllowed, isUnsafeRequestOriginAllowed } from '../corsPolicy' + +test('生产环境只允许固定资产源和显式白名单进行跨源凭证请求', () => { + const env = { + NODE_ENV: 'production', + DINGTALK_PUBLIC_BASE_URL: 'https://asset.example.com', + CORS_ALLOWED_ORIGINS: 'https://admin.example.com' + } + assert.equal(isCorsOriginAllowed(undefined, env), true) + assert.equal(isCorsOriginAllowed('https://asset.example.com', env), true) + assert.equal(isCorsOriginAllowed('https://admin.example.com', env), true) + assert.equal(isCorsOriginAllowed('https://sc.example.com', env), false) + assert.equal(isCorsOriginAllowed('https://evil.example.com', env), false) +}) + +test('开发环境默认允许本地Vite,但不反射任意来源', () => { + const env = { NODE_ENV: 'development' } + assert.equal(isCorsOriginAllowed('http://localhost:5173', env), true) + assert.equal(isCorsOriginAllowed('https://evil.example.com', env), false) +}) + +test('跨源写操作默认拒绝,只单独放行通知中心票据回调', () => { + const env = { + NODE_ENV: 'production', + DINGTALK_PUBLIC_BASE_URL: 'https://asset.example.com', + YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center' + } + assert.equal(isUnsafeRequestOriginAllowed({ + method: 'POST', + origin: 'https://asset.example.com', + requestOrigin: 'https://asset.example.com', + path: '/api/equipment' + }, env), true) + assert.equal(isUnsafeRequestOriginAllowed({ + method: 'POST', + origin: 'https://sc.example.com', + requestOrigin: 'https://asset.example.com', + path: '/api/equipment' + }, env), false) + assert.equal(isUnsafeRequestOriginAllowed({ + method: 'POST', + origin: 'https://sc.example.com', + requestOrigin: 'https://asset.example.com', + path: '/api/dingtalk/broker-login' + }, env), true) + assert.equal(isUnsafeRequestOriginAllowed({ + method: 'DELETE', + origin: 'https://evil.example.com', + requestOrigin: 'https://asset.example.com', + path: '/api/equipment/1' + }, env), false) +}) diff --git a/backend/src/utils/corsPolicy.ts b/backend/src/utils/corsPolicy.ts new file mode 100644 index 0000000..f91d611 --- /dev/null +++ b/backend/src/utils/corsPolicy.ts @@ -0,0 +1,49 @@ +const parseOrigin = (value: string) => { + try { + const url = new URL(value) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null + return url.origin + } catch { + return null + } +} + +const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']) + +export const getAllowedCorsOrigins = (env: NodeJS.ProcessEnv = process.env) => { + const configured = String(env.CORS_ALLOWED_ORIGINS || '') + .split(',') + .map((item) => parseOrigin(item.trim())) + .filter((item): item is string => Boolean(item)) + + const publicOrigin = parseOrigin(String(env.DINGTALK_PUBLIC_BASE_URL || '').trim()) + if (publicOrigin) configured.push(publicOrigin) + + if (String(env.NODE_ENV || '').trim().toLowerCase() !== 'production') { + configured.push('http://localhost:5173', 'http://127.0.0.1:5173') + } + return new Set(configured) +} + +export const isCorsOriginAllowed = ( + origin: string | undefined, + env: NodeJS.ProcessEnv = process.env +) => !origin || getAllowedCorsOrigins(env).has(origin) + +export const isUnsafeRequestOriginAllowed = (input: { + method: string + origin?: string + requestOrigin: string + path: string +}, env: NodeJS.ProcessEnv = process.env) => { + if (safeMethods.has(String(input.method || '').toUpperCase())) return true + if (!input.origin) return true + + const origin = parseOrigin(input.origin) + const requestOrigin = parseOrigin(input.requestOrigin) + if (!origin || !requestOrigin) return false + if (origin === requestOrigin || getAllowedCorsOrigins(env).has(origin)) return true + + const notificationCenterOrigin = parseOrigin(String(env.YYD_NOTIFICATION_CENTER_URL || '').trim()) + return input.path === '/api/dingtalk/broker-login' && origin === notificationCenterOrigin +} diff --git a/backend/src/utils/dingtalkRedirect.ts b/backend/src/utils/dingtalkRedirect.ts index efdd971..17c03e6 100644 --- a/backend/src/utils/dingtalkRedirect.ts +++ b/backend/src/utils/dingtalkRedirect.ts @@ -26,3 +26,12 @@ export const normalizeInternalRedirect = (value: unknown) => { } return `${url.pathname}${url.search}${url.hash}` } + +export const buildDingTalkBrokerCompletionPath = (redirectPath: unknown) => { + const safeRedirect = normalizeInternalRedirect(redirectPath) + const query = new URLSearchParams({ + broker: 'complete', + redirect: safeRedirect + }) + return `/dingtalk-entry?${query.toString()}` +} diff --git a/docker-compose.yml b/docker-compose.yml index 3624d7c..aa6cdcd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,8 @@ services: environment: DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4 SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} AUTH_MODE: ${AUTH_MODE:-local} PORT: ${PORT:-3000} SQLSERVER_HOST: ${SQLSERVER_HOST:-} @@ -63,6 +65,9 @@ services: YYD_NOTIFICATION_CENTER_PROJECT_SECRET: ${YYD_NOTIFICATION_CENTER_PROJECT_SECRET:-} YYD_NOTIFICATION_CENTER_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_TEST_USER_IDS:-} YYD_NOTIFICATION_CENTER_FULL_ROLLOUT: ${YYD_NOTIFICATION_CENTER_FULL_ROLLOUT:-false} + YYD_NOTIFICATION_CENTER_LOGIN_ENABLED: ${YYD_NOTIFICATION_CENTER_LOGIN_ENABLED:-false} + YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS:-} + YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT: ${YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT:-false} DOWNLOADS_DIR: /app/downloads depends_on: mysql: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index fcc63f3..8fc7fbf 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -50,6 +50,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } @@ -113,6 +114,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } @@ -176,6 +178,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } diff --git a/frontend/src/api/dingtalk.ts b/frontend/src/api/dingtalk.ts index 7cfd700..b9be4fd 100644 --- a/frontend/src/api/dingtalk.ts +++ b/frontend/src/api/dingtalk.ts @@ -3,6 +3,8 @@ import type { ApiResponse, User } from '@/types' export interface DingTalkPublicConfig { configured: boolean + mode?: 'work_notice' | 'notification_center' + loginEntryUrl?: string clientId?: string client_id?: string corpId?: string @@ -26,6 +28,11 @@ export interface DingTalkDeliveryStatus { send_enabled?: boolean enabled?: boolean missing?: string[] + login_configured?: boolean + login_enabled?: boolean + login_rollout_mode?: 'disabled' | 'test' | 'full' + login_test_user_count?: number + login_missing?: string[] pending: number failed: number sent?: number @@ -34,8 +41,10 @@ export interface DingTalkDeliveryStatus { error_summary?: string[] } -export const getDingTalkPublicConfig = async () => { - const response = await api.get>('/dingtalk/public-config') +export const getDingTalkPublicConfig = async (redirectPath: string) => { + const response = await api.get>('/dingtalk/public-config', { + params: { redirectPath } + }) return response.data } diff --git a/frontend/src/views/auth/DingTalkEntry.vue b/frontend/src/views/auth/DingTalkEntry.vue index fbdf692..1ac9afa 100644 --- a/frontend/src/views/auth/DingTalkEntry.vue +++ b/frontend/src/views/auth/DingTalkEntry.vue @@ -93,11 +93,23 @@ const startLogin = async () => { const requestedPath = sanitizeRedirectPath(route.query.redirect || route.query.redirectPath) try { + if (route.query.broker === 'complete') { + statusText.value = '身份已确认,正在进入系统...' + await authStore.refreshUser() + authStore.setToken('session') + await router.replace(requestedPath) + return + } if (!/DingTalk/i.test(navigator.userAgent)) { throw new Error('请从钉钉工作通知或固定资产微应用中打开此页面') } - const configResponse = await getDingTalkPublicConfig() + const configResponse = await getDingTalkPublicConfig(requestedPath) const config = configResponse.data + if (config?.mode === 'notification_center' && config.loginEntryUrl) { + statusText.value = '正在进入由壹点统一身份验证...' + window.location.replace(config.loginEntryUrl) + return + } const clientId = config?.clientId || config?.client_id const corpId = config?.corpId || config?.corp_id if (!config?.configured || !clientId || !corpId) throw new Error('固定资产钉钉应用尚未配置完整') diff --git a/frontend/src/views/settings/components/DingTalkSettings.vue b/frontend/src/views/settings/components/DingTalkSettings.vue index 7e5668b..25997d6 100644 --- a/frontend/src/views/settings/components/DingTalkSettings.vue +++ b/frontend/src/views/settings/components/DingTalkSettings.vue @@ -27,6 +27,13 @@

失败

{{ status?.failed || 0 }}

+
+

钉钉免登

+

+ {{ status?.login_configured ? '可用' : '关闭' }} +

+

{{ getLoginText() }}

+
@@ -152,9 +159,11 @@ const loadData = async () => { } const retry = async (item: any) => { - const message = item.provider_type === 'notification_center' - ? '将通过通知中心创建一次新的人工补发;已送达的旧消息不会撤回。确定继续吗?' - : '确定重新投递这条钉钉通知吗?' + const message = item.last_error_code === 'DELIVERY_OUTCOME_UNKNOWN' + ? '钉钉是否已经收到这条消息无法自动确认。请先到收件人的钉钉单聊中核验;确认未收到后再补发,否则可能产生重复消息。仍要补发吗?' + : item.provider_type === 'notification_center' + ? '将通过通知中心创建一次新的人工补发;已送达的旧消息不会撤回。确定继续吗?' + : '确定重新投递这条钉钉通知吗?' if (!confirm(message)) return try { await retryDingTalkDelivery(Number(item.id)) @@ -184,6 +193,12 @@ const getChannelText = () => { return '通知中心 · 灰度未启用' } +const getLoginText = () => { + if (status.value?.login_rollout_mode === 'full') return '通知中心 · 全量' + if (status.value?.login_rollout_mode === 'test') return `通知中心 · 灰度 ${status.value.login_test_user_count || 0} 人` + return '独立开关未启用' +} + const getDeliveryBadgeClass = (value?: string) => { if (value === 'failed') return 'badge-error' if (value === 'sent' || value === 'read') return 'badge-success'