feat(钉钉): 增加统一通知中心免登灰度
This commit is contained in:
parent
a1ac722317
commit
0a0145210d
@ -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
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 安装OpenSSL
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
|
||||
|
||||
@ -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<string, string>,
|
||||
type: '',
|
||||
body: '',
|
||||
redirect: null as null | { status: number; location: string }
|
||||
}
|
||||
const res = {
|
||||
set(headers: Record<string, string>) {
|
||||
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, /登录凭证无效或已过期/)
|
||||
})
|
||||
@ -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'),
|
||||
/允许范围/
|
||||
)
|
||||
})
|
||||
|
||||
@ -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<void>((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<void>((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(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>固定资产免登失败</title>
|
||||
<style>
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0f14;color:#f3f4f6;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;padding:20px;box-sizing:border-box}
|
||||
main{width:min(420px,100%);padding:24px;border:1px solid #253041;border-radius:14px;background:#111821;box-sizing:border-box}
|
||||
h1{font-size:20px;margin:0 0 12px}p{font-size:14px;line-height:1.7;color:#aab4c3;margin:0 0 20px}
|
||||
a{display:block;text-align:center;padding:10px 14px;border:1px solid #334155;border-radius:9px;color:#e5e7eb;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body><main><h1>暂时无法进入固定资产系统</h1><p>${escapeHtml(message)}</p><a href="/login">返回系统登录页</a></main></body>
|
||||
</html>`)
|
||||
}
|
||||
|
||||
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<void>((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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
81
backend/src/middleware/__tests__/originGuard.test.ts
Normal file
81
backend/src/middleware/__tests__/originGuard.test.ts
Normal file
@ -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)
|
||||
})
|
||||
@ -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
|
||||
|
||||
15
backend/src/middleware/originGuard.ts
Normal file
15
backend/src/middleware/originGuard.ts
Normal file
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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<string, string>
|
||||
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('固定资产客户端拒绝误用其它项目代码', () => {
|
||||
|
||||
49
backend/src/services/dingtalkBrokerLoginService.ts
Normal file
49
backend/src/services/dingtalkBrokerLoginService.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import prisma from '../utils/prisma'
|
||||
|
||||
type UserLookupClient = Pick<typeof prisma, 'user'>
|
||||
|
||||
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 }
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<string, any>) => {
|
||||
|
||||
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<NotificationCenterLoginTicket> => {
|
||||
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: {
|
||||
|
||||
2
backend/src/types/express-session.d.ts
vendored
2
backend/src/types/express-session.d.ts
vendored
@ -8,6 +8,8 @@ declare module 'express-session' {
|
||||
store?: Store
|
||||
cookie?: {
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
sameSite?: boolean | 'lax' | 'strict' | 'none'
|
||||
maxAge?: number
|
||||
}
|
||||
}
|
||||
|
||||
54
backend/src/utils/__tests__/corsPolicy.test.ts
Normal file
54
backend/src/utils/__tests__/corsPolicy.test.ts
Normal file
@ -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)
|
||||
})
|
||||
49
backend/src/utils/corsPolicy.ts
Normal file
49
backend/src/utils/corsPolicy.ts
Normal file
@ -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
|
||||
}
|
||||
@ -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()}`
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<ApiResponse<DingTalkPublicConfig>>('/dingtalk/public-config')
|
||||
export const getDingTalkPublicConfig = async (redirectPath: string) => {
|
||||
const response = await api.get<ApiResponse<DingTalkPublicConfig>>('/dingtalk/public-config', {
|
||||
params: { redirectPath }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@ -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('固定资产钉钉应用尚未配置完整')
|
||||
|
||||
@ -27,6 +27,13 @@
|
||||
<p class="page-meta__label">失败</p>
|
||||
<p class="page-meta__value text-status-error">{{ status?.failed || 0 }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">钉钉免登</p>
|
||||
<p class="page-meta__value text-base" :class="status?.login_configured ? 'text-status-success' : 'text-status-warning'">
|
||||
{{ status?.login_configured ? '可用' : '关闭' }}
|
||||
</p>
|
||||
<p class="page-meta__subvalue">{{ getLoginText() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user