fix: 钉钉登录后安全返回内网
This commit is contained in:
parent
94fb4e1898
commit
3f56e0280f
@ -4,6 +4,76 @@ import {
|
|||||||
DINGTALK_DIRECT_LOGIN_SOURCE,
|
DINGTALK_DIRECT_LOGIN_SOURCE,
|
||||||
parseDingtalkOAuthState,
|
parseDingtalkOAuthState,
|
||||||
} from '@/lib/dingtalk-oauth'
|
} from '@/lib/dingtalk-oauth'
|
||||||
|
import { getDingtalkLoginHandoff } from '@/lib/dingtalk-login-handoff'
|
||||||
|
import { createDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
|
||||||
|
|
||||||
|
function getSetCookies(source: Response): string[] {
|
||||||
|
const getSetCookie = (source.headers as any).getSetCookie
|
||||||
|
const cookies = typeof getSetCookie === 'function'
|
||||||
|
? getSetCookie.call(source.headers)
|
||||||
|
: [source.headers.get('set-cookie')].filter(Boolean)
|
||||||
|
|
||||||
|
return cookies.filter((cookie: unknown): cookie is string => typeof cookie === 'string' && cookie.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSetCookies(target: NextResponse, cookies: string[]) {
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
target.headers.append('set-cookie', cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeHandoffNonce(value: unknown) {
|
||||||
|
return /^[A-Za-z0-9_-]{16,128}$/.test(String(value || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachInternalSessionHandoff(source: NextResponse, state: unknown) {
|
||||||
|
const data = await source.clone().json().catch(() => null)
|
||||||
|
if (!source.ok || !data?.success || data?.needCompleteProfile || !data?.user?.id) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handoffId, nonce } = parseDingtalkOAuthState(state)
|
||||||
|
if (!handoffId || !isSafeHandoffNonce(nonce)) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handoff = await getDingtalkLoginHandoff(handoffId)
|
||||||
|
if (!handoff) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await createDingtalkSessionTicket({
|
||||||
|
userId: String(data.user.id),
|
||||||
|
redirectPath: handoff.redirectPath,
|
||||||
|
targetOrigin: handoff.returnOrigin,
|
||||||
|
handoffId: handoff.id,
|
||||||
|
handoffBindingHash: handoff.bindingHash,
|
||||||
|
nonce,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
sessionHandoff: {
|
||||||
|
ticket,
|
||||||
|
targetOrigin: handoff.returnOrigin,
|
||||||
|
nonce,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: source.status,
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
appendSetCookies(response, getSetCookies(source))
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
// 公网登录已经成功,Redis 临时不可用时保留现有公网落地,而不是中断用户登录。
|
||||||
|
console.error('[Dingtalk] 创建内网登录交接票据失败:', error)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R25修复:移除硬编码的钉钉密钥,强制使用环境变量
|
* R25修复:移除硬编码的钉钉密钥,强制使用环境变量
|
||||||
@ -25,9 +95,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { source } = parseDingtalkOAuthState(state)
|
const { source } = parseDingtalkOAuthState(state)
|
||||||
return loginDingtalkUserByOAuthCode(request, String(code), {
|
const response = await loginDingtalkUserByOAuthCode(request, String(code), {
|
||||||
appSource: source === DINGTALK_DIRECT_LOGIN_SOURCE ? 'workNotice' : 'login',
|
appSource: source === DINGTALK_DIRECT_LOGIN_SOURCE ? 'workNotice' : 'login',
|
||||||
})
|
})
|
||||||
|
return attachInternalSessionHandoff(response, state)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('钉钉登录失败:', error)
|
console.error('钉钉登录失败:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||||||
import { consumeDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
|
import { consumeDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
|
||||||
|
import {
|
||||||
|
deleteDingtalkLoginHandoff,
|
||||||
|
DINGTALK_LOGIN_HANDOFF_COOKIE,
|
||||||
|
} from '@/lib/dingtalk-login-handoff'
|
||||||
|
import { getTrustedDingtalkReturnOrigin } from '@/lib/dingtalk-return-origin'
|
||||||
|
import { setDingtalkAuthenticatedSessionCookies } from '@/lib/dingtalk-login'
|
||||||
|
import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@ -66,9 +74,9 @@ function stringifyForInlineScript(value: string) {
|
|||||||
return JSON.stringify(value).replace(/</g, '\\u003c')
|
return JSON.stringify(value).replace(/</g, '\\u003c')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPublicUrl(path: string, request?: NextRequest) {
|
function buildRedirectUrl(path: string, request?: NextRequest, targetOrigin?: string | null) {
|
||||||
try {
|
try {
|
||||||
return new URL(path, getPublicAppOrigin()).toString()
|
return new URL(path, targetOrigin || getPublicAppOrigin()).toString()
|
||||||
} catch {
|
} catch {
|
||||||
return new URL(path, request?.url || 'https://sc.jiansun.vip').toString()
|
return new URL(path, request?.url || 'https://sc.jiansun.vip').toString()
|
||||||
}
|
}
|
||||||
@ -128,6 +136,7 @@ function createErrorPage(message: string, status = 410) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -137,12 +146,14 @@ function createSessionRedirectResponse({
|
|||||||
request,
|
request,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
cookies,
|
cookies,
|
||||||
|
targetOrigin,
|
||||||
}: {
|
}: {
|
||||||
request: NextRequest
|
request: NextRequest
|
||||||
redirectPath: string
|
redirectPath: string
|
||||||
cookies: string[]
|
cookies: string[]
|
||||||
|
targetOrigin?: string | null
|
||||||
}) {
|
}) {
|
||||||
const redirectUrl = buildPublicUrl(redirectPath, request)
|
const redirectUrl = buildRedirectUrl(redirectPath, request, targetOrigin)
|
||||||
const response = new NextResponse(
|
const response = new NextResponse(
|
||||||
`<!doctype html>
|
`<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@ -211,6 +222,7 @@ ${SURVEY_OPENING_PAGE_MARKUP}
|
|||||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
Pragma: 'no-cache',
|
Pragma: 'no-cache',
|
||||||
Expires: '0',
|
Expires: '0',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -225,15 +237,88 @@ ${SURVEY_OPENING_PAGE_MARKUP}
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
async function resolveActiveTicketUser(userId: string) {
|
||||||
const ticket = consumeDingtalkSessionTicket(request.nextUrl.searchParams.get('ticket'))
|
const user = await prisma.user.findUnique({
|
||||||
if (!ticket) {
|
where: { id: userId },
|
||||||
return createErrorPage('暂时无法打开问卷,请从最新通知重新进入。')
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
realName: true,
|
||||||
|
role: true,
|
||||||
|
department: true,
|
||||||
|
managedDepartment: true,
|
||||||
|
avatar: true,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
status: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const status = user.status || (user.isActive ? 'active' : 'pending')
|
||||||
|
if (status !== 'active') {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return createSessionRedirectResponse({
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const requestOrigin = request.nextUrl.origin
|
||||||
|
const handoffBinding = request.cookies.get(DINGTALK_LOGIN_HANDOFF_COOKIE)?.value || null
|
||||||
|
let ticket
|
||||||
|
|
||||||
|
try {
|
||||||
|
ticket = await consumeDingtalkSessionTicket(request.nextUrl.searchParams.get('ticket'), {
|
||||||
|
expectedTargetOrigin: requestOrigin,
|
||||||
|
expectedHandoffBinding: handoffBinding,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Dingtalk] 消费登录会话交接票据失败:', error)
|
||||||
|
return createErrorPage('暂时无法完成登录交接,请重新登录。', 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return createErrorPage('登录交接已失效或不是当前浏览器发起,请重新登录。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.targetOrigin && getTrustedDingtalkReturnOrigin(ticket.targetOrigin) !== requestOrigin) {
|
||||||
|
return createErrorPage('登录交接来源校验失败,请重新登录。')
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticketUser = null
|
||||||
|
if (ticket.userId) {
|
||||||
|
ticketUser = await resolveActiveTicketUser(ticket.userId)
|
||||||
|
if (!ticketUser) {
|
||||||
|
return createErrorPage('账号状态已变化,请重新登录。', 403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = createSessionRedirectResponse({
|
||||||
request,
|
request,
|
||||||
redirectPath: ticket.redirectPath,
|
redirectPath: ticket.redirectPath,
|
||||||
cookies: ticket.cookies,
|
cookies: ticket.cookies,
|
||||||
|
targetOrigin: ticket.targetOrigin,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (ticketUser) {
|
||||||
|
await setDingtalkAuthenticatedSessionCookies(response, request, ticketUser)
|
||||||
|
response.cookies.set(DINGTALK_LOGIN_HANDOFF_COOKIE, '', {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: resolveSecureAuthCookieFromRequest(request),
|
||||||
|
path: '/api/dingtalk/session-ticket',
|
||||||
|
maxAge: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ticket.handoffId) {
|
||||||
|
await deleteDingtalkLoginHandoff(ticket.handoffId).catch((error) => {
|
||||||
|
console.warn('[Dingtalk] 清理已完成的登录交接请求失败:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
61
app/api/dingtalk/session-transfer/start/route.test.ts
Normal file
61
app/api/dingtalk/session-transfer/start/route.test.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/** @jest-environment node */
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { POST } from './route'
|
||||||
|
import {
|
||||||
|
deleteDingtalkLoginHandoff,
|
||||||
|
getDingtalkLoginHandoff,
|
||||||
|
} from '@/lib/dingtalk-login-handoff'
|
||||||
|
|
||||||
|
describe('POST /api/dingtalk/session-transfer/start', () => {
|
||||||
|
const originalUseRedis = process.env.USE_REDIS
|
||||||
|
const originalRedisUrl = process.env.REDIS_URL
|
||||||
|
const originalOrigins = process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.USE_REDIS = 'false'
|
||||||
|
delete process.env.REDIS_URL
|
||||||
|
process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = 'http://192.168.58.20:4320'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalUseRedis === undefined) delete process.env.USE_REDIS
|
||||||
|
else process.env.USE_REDIS = originalUseRedis
|
||||||
|
|
||||||
|
if (originalRedisUrl === undefined) delete process.env.REDIS_URL
|
||||||
|
else process.env.REDIS_URL = originalRedisUrl
|
||||||
|
|
||||||
|
if (originalOrigins === undefined) delete process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
|
||||||
|
else process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = originalOrigins
|
||||||
|
})
|
||||||
|
|
||||||
|
test('仅在受信内网入口创建绑定 Cookie 与交接 ID', async () => {
|
||||||
|
const response = await POST(new NextRequest('http://192.168.58.20:4320/api/dingtalk/session-transfer/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ redirectPath: '/admin?tab=users' }),
|
||||||
|
}))
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(data).toMatchObject({ success: true, enabled: true })
|
||||||
|
expect(response.headers.get('set-cookie')).toContain('dingtalk-login-handoff=')
|
||||||
|
|
||||||
|
const stored = await getDingtalkLoginHandoff(data.handoffId)
|
||||||
|
expect(stored).toMatchObject({
|
||||||
|
returnOrigin: 'http://192.168.58.20:4320',
|
||||||
|
redirectPath: '/admin?tab=users',
|
||||||
|
})
|
||||||
|
await deleteDingtalkLoginHandoff(data.handoffId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('公网入口不创建内网交接记录', async () => {
|
||||||
|
const response = await POST(new NextRequest('https://sc.jiansun.vip/api/dingtalk/session-transfer/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ redirectPath: '/admin' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(response.json()).resolves.toEqual({ success: true, enabled: false })
|
||||||
|
expect(response.headers.get('set-cookie')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
49
app/api/dingtalk/session-transfer/start/route.ts
Normal file
49
app/api/dingtalk/session-transfer/start/route.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import {
|
||||||
|
createDingtalkLoginHandoff,
|
||||||
|
DINGTALK_LOGIN_HANDOFF_COOKIE,
|
||||||
|
} from '@/lib/dingtalk-login-handoff'
|
||||||
|
import { getTrustedDingtalkReturnOrigin } from '@/lib/dingtalk-return-origin'
|
||||||
|
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
|
||||||
|
import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestOrigin = request.nextUrl.origin
|
||||||
|
const trustedOrigin = getTrustedDingtalkReturnOrigin(requestOrigin)
|
||||||
|
|
||||||
|
// 公网登录不需要、也不能创建内网回跳票据;保持既有的公网登录流程。
|
||||||
|
if (!trustedOrigin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, enabled: false },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const handoff = await createDingtalkLoginHandoff({
|
||||||
|
returnOrigin: trustedOrigin,
|
||||||
|
redirectPath: normalizeInternalRedirectPath(body?.redirectPath),
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
enabled: true,
|
||||||
|
handoffId: handoff.id,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
response.cookies.set(DINGTALK_LOGIN_HANDOFF_COOKIE, handoff.binding, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: resolveSecureAuthCookieFromRequest(request),
|
||||||
|
path: '/api/dingtalk/session-ticket',
|
||||||
|
maxAge: 5 * 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
@ -24,6 +24,40 @@ function parseDingtalkStateSource(value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDingtalkLoginState(value: string) {
|
||||||
|
let raw = value
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(raw)
|
||||||
|
} catch {
|
||||||
|
// URLSearchParams 通常已解码;兼容手工拼接的 state。
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
redirectPath: normalizeInternalRedirectPath(data?.redirect),
|
||||||
|
nonce: typeof data?.nonce === 'string' ? data.nonce : '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { redirectPath: '/', nonce: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionTicketUrl(handoff: any) {
|
||||||
|
if (!handoff || typeof handoff.ticket !== 'string' || typeof handoff.targetOrigin !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = new URL('/api/dingtalk/session-ticket', handoff.targetOrigin)
|
||||||
|
if (target.origin !== handoff.targetOrigin) return null
|
||||||
|
target.searchParams.set('ticket', handoff.ticket)
|
||||||
|
return target.toString()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function DingtalkCallbackContent() {
|
function DingtalkCallbackContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@ -46,16 +80,7 @@ function DingtalkCallbackContent() {
|
|||||||
throw new Error('授权失败:缺少授权码')
|
throw new Error('授权失败:缺少授权码')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析state参数,获取原始访问地址
|
const { redirectPath, nonce } = parseDingtalkLoginState(state)
|
||||||
let originUrl = ''
|
|
||||||
let redirectPath = '/'
|
|
||||||
try {
|
|
||||||
const stateData = JSON.parse(decodeURIComponent(state))
|
|
||||||
originUrl = stateData.origin || ''
|
|
||||||
redirectPath = normalizeInternalRedirectPath(stateData.redirect)
|
|
||||||
} catch (e) {
|
|
||||||
// state解析失败,忽略
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage('正在验证授权信息...')
|
setMessage('正在验证授权信息...')
|
||||||
|
|
||||||
@ -86,8 +111,9 @@ function DingtalkCallbackContent() {
|
|||||||
{
|
{
|
||||||
type: 'dingtalk_need_complete_profile',
|
type: 'dingtalk_need_complete_profile',
|
||||||
userInfo: data.user,
|
userInfo: data.user,
|
||||||
|
nonce,
|
||||||
},
|
},
|
||||||
'*'
|
window.location.origin,
|
||||||
)
|
)
|
||||||
window.close()
|
window.close()
|
||||||
} else if (window.parent !== window) {
|
} else if (window.parent !== window) {
|
||||||
@ -96,8 +122,9 @@ function DingtalkCallbackContent() {
|
|||||||
{
|
{
|
||||||
type: 'dingtalk_need_complete_profile',
|
type: 'dingtalk_need_complete_profile',
|
||||||
userInfo: data.user,
|
userInfo: data.user,
|
||||||
|
nonce,
|
||||||
},
|
},
|
||||||
'*'
|
window.location.origin,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
router.push(`/complete-profile?userInfo=${userInfoParam}`)
|
router.push(`/complete-profile?userInfo=${userInfoParam}`)
|
||||||
@ -108,6 +135,8 @@ function DingtalkCallbackContent() {
|
|||||||
|
|
||||||
setMessage('登录成功,正在跳转...')
|
setMessage('登录成功,正在跳转...')
|
||||||
setStatus('success')
|
setStatus('success')
|
||||||
|
const sessionHandoff = data.sessionHandoff
|
||||||
|
const sessionTicketUrl = buildSessionTicketUrl(sessionHandoff)
|
||||||
|
|
||||||
// 通知父窗口登录成功(如果在 iframe 或新窗口中)
|
// 通知父窗口登录成功(如果在 iframe 或新窗口中)
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
@ -116,10 +145,11 @@ function DingtalkCallbackContent() {
|
|||||||
{
|
{
|
||||||
type: 'dingtalk_login_success',
|
type: 'dingtalk_login_success',
|
||||||
userInfo: data.user,
|
userInfo: data.user,
|
||||||
originUrl: originUrl, // 携带原始地址
|
|
||||||
redirectUrl: redirectPath,
|
redirectUrl: redirectPath,
|
||||||
|
nonce,
|
||||||
|
sessionHandoff,
|
||||||
},
|
},
|
||||||
'*'
|
sessionHandoff?.targetOrigin || window.location.origin,
|
||||||
)
|
)
|
||||||
} else if (window.parent !== window) {
|
} else if (window.parent !== window) {
|
||||||
// iframe模式:通知父窗口(兼容旧代码)
|
// iframe模式:通知父窗口(兼容旧代码)
|
||||||
@ -127,10 +157,11 @@ function DingtalkCallbackContent() {
|
|||||||
{
|
{
|
||||||
type: 'dingtalk_login_success',
|
type: 'dingtalk_login_success',
|
||||||
userInfo: data.user,
|
userInfo: data.user,
|
||||||
originUrl: originUrl, // 携带原始地址
|
|
||||||
redirectUrl: redirectPath,
|
redirectUrl: redirectPath,
|
||||||
|
nonce,
|
||||||
|
sessionHandoff,
|
||||||
},
|
},
|
||||||
'*'
|
sessionHandoff?.targetOrigin || window.location.origin,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
@ -144,7 +175,7 @@ function DingtalkCallbackContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = redirectPath
|
window.location.href = sessionTicketUrl || redirectPath
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -164,11 +195,17 @@ function DingtalkCallbackContent() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
// 新窗口模式:通知打开窗口的父页面
|
// 新窗口模式:通知打开窗口的父页面
|
||||||
window.opener.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
|
window.opener.postMessage(
|
||||||
|
{ type: 'dingtalk_login_error', message: error.message, nonce: parseDingtalkLoginState(state).nonce },
|
||||||
|
window.location.origin,
|
||||||
|
)
|
||||||
window.close()
|
window.close()
|
||||||
} else if (window.parent !== window) {
|
} else if (window.parent !== window) {
|
||||||
// iframe模式:通知父窗口(兼容旧代码)
|
// iframe模式:通知父窗口(兼容旧代码)
|
||||||
window.parent.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
|
window.parent.postMessage(
|
||||||
|
{ type: 'dingtalk_login_error', message: error.message, nonce: parseDingtalkLoginState(state).nonce },
|
||||||
|
window.location.origin,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import Image from 'next/image'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ensureHttpsUrl } from '@/lib/url-rewrite'
|
import { ensureHttpsUrl } from '@/lib/url-rewrite'
|
||||||
import { parseApiError } from '@/lib/error-handler'
|
import { parseApiError } from '@/lib/error-handler'
|
||||||
import { getAllowedPublicAppOrigins, getPublicAppOrigin } from '@/lib/public-app-url'
|
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||||||
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
|
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
|
||||||
|
|
||||||
const DEFAULT_LOGIN_LOGO_URL = '/api/favicon'
|
const DEFAULT_LOGIN_LOGO_URL = '/api/favicon'
|
||||||
@ -25,7 +25,6 @@ export default function LoginPage() {
|
|||||||
// 钉钉配置(从环境变量或配置文件获取)
|
// 钉钉配置(从环境变量或配置文件获取)
|
||||||
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
|
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
|
||||||
const publicAppOrigin = getPublicAppOrigin()
|
const publicAppOrigin = getPublicAppOrigin()
|
||||||
const allowedMessageOrigins = getAllowedPublicAppOrigins()
|
|
||||||
const dingtalkCallbackOrigin =
|
const dingtalkCallbackOrigin =
|
||||||
process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin
|
process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin
|
||||||
|
|
||||||
@ -154,13 +153,35 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDingtalkLogin = (options?: { redirectPath?: string; useCurrentWindow?: boolean }) => {
|
const handleDingtalkLogin = async (options?: { redirectPath?: string; useCurrentWindow?: boolean }) => {
|
||||||
const redirectUrl = `${dingtalkCallbackOrigin}/dingtalk-callback`
|
const redirectUrl = `${dingtalkCallbackOrigin}/dingtalk-callback`
|
||||||
const redirectPath = normalizeInternalRedirectPath(options?.redirectPath ?? getLoginRedirectPath())
|
const redirectPath = normalizeInternalRedirectPath(options?.redirectPath ?? getLoginRedirectPath())
|
||||||
|
const nonce = typeof window.crypto?.randomUUID === 'function'
|
||||||
// 在state中携带原始访问地址信息(用于登录后跳转)
|
? window.crypto.randomUUID()
|
||||||
const currentOrigin = window.location.origin
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
|
||||||
const stateData = JSON.stringify({ origin: currentOrigin, redirect: redirectPath })
|
let handoffId = ''
|
||||||
|
|
||||||
|
// 内网入口先在本机写入短时 HttpOnly 绑定 Cookie。OAuth 回到公网后只能携带无语义 ID,
|
||||||
|
// 最终仍必须由同一浏览器带着该 Cookie 消费一次性票据,避免登录注入和开放重定向。
|
||||||
|
try {
|
||||||
|
const handoffResponse = await fetch('/api/dingtalk/session-transfer/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ redirectPath }),
|
||||||
|
})
|
||||||
|
const handoffData = handoffResponse.ok
|
||||||
|
? await handoffResponse.json().catch(() => null)
|
||||||
|
: null
|
||||||
|
if (handoffData?.enabled && typeof handoffData.handoffId === 'string') {
|
||||||
|
handoffId = handoffData.handoffId
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 交接不可用时仍允许原有公网钉钉登录,不能把用户卡在登录页。
|
||||||
|
console.warn('创建钉钉内网登录交接请求失败,将使用公网登录:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateData = JSON.stringify({ redirect: redirectPath, nonce, handoffId })
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
redirect_uri: redirectUrl,
|
redirect_uri: redirectUrl,
|
||||||
@ -200,17 +221,35 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 监听新窗口的消息(从回调页面发送)
|
// 监听新窗口的消息(从回调页面发送)
|
||||||
|
const callbackMessageOrigin = new URL(dingtalkCallbackOrigin).origin
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
if (!allowedMessageOrigins.includes(event.origin) && !event.origin.includes('192.168.')) {
|
if (event.origin !== callbackMessageOrigin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.data || event.data.nonce !== nonce) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data && event.data.type === 'dingtalk_login_success') {
|
if (event.data.type === 'dingtalk_login_success') {
|
||||||
popup?.close()
|
popup?.close()
|
||||||
window.removeEventListener('message', handleMessage)
|
window.removeEventListener('message', handleMessage)
|
||||||
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
|
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
|
||||||
|
const handoff = event.data.sessionHandoff
|
||||||
|
const canReturnToCurrentOrigin =
|
||||||
|
typeof handoff?.ticket === 'string' &&
|
||||||
|
handoff.nonce === nonce &&
|
||||||
|
handoff.targetOrigin === window.location.origin
|
||||||
|
|
||||||
|
if (canReturnToCurrentOrigin) {
|
||||||
|
const ticketUrl = new URL('/api/dingtalk/session-ticket', window.location.origin)
|
||||||
|
ticketUrl.searchParams.set('ticket', handoff.ticket)
|
||||||
|
window.location.href = ticketUrl.toString()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
window.location.href = new URL(targetPath, dingtalkCallbackOrigin).toString()
|
window.location.href = new URL(targetPath, dingtalkCallbackOrigin).toString()
|
||||||
} else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {
|
} else if (event.data.type === 'dingtalk_need_complete_profile') {
|
||||||
popup?.close()
|
popup?.close()
|
||||||
window.removeEventListener('message', handleMessage)
|
window.removeEventListener('message', handleMessage)
|
||||||
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
|
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
|
||||||
|
|||||||
@ -49,6 +49,8 @@ services:
|
|||||||
- APP_PUBLIC_URL=https://sc.jiansun.vip
|
- APP_PUBLIC_URL=https://sc.jiansun.vip
|
||||||
- NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=https://sc.jiansun.vip
|
- NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=https://sc.jiansun.vip
|
||||||
- NEXTAUTH_URL=https://sc.jiansun.vip
|
- NEXTAUTH_URL=https://sc.jiansun.vip
|
||||||
|
# 钉钉 OAuth 必须回公网;认证完成后仅允许把会话交接回这一条受信内网入口。
|
||||||
|
- DINGTALK_INTERNAL_RETURN_ORIGINS=http://192.168.58.20:4320
|
||||||
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)
|
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)
|
||||||
- SERVER_IP=192.168.58.20
|
- SERVER_IP=192.168.58.20
|
||||||
- MINIO_API_PORT=9000
|
- MINIO_API_PORT=9000
|
||||||
|
|||||||
44
lib/__tests__/dingtalk-login-handoff.test.ts
Normal file
44
lib/__tests__/dingtalk-login-handoff.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/** @jest-environment node */
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDingtalkLoginHandoff,
|
||||||
|
deleteDingtalkLoginHandoff,
|
||||||
|
getDingtalkLoginHandoff,
|
||||||
|
} from '@/lib/dingtalk-login-handoff'
|
||||||
|
|
||||||
|
describe('DingTalk login handoff start record', () => {
|
||||||
|
const originalUseRedis = process.env.USE_REDIS
|
||||||
|
const originalRedisUrl = process.env.REDIS_URL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.USE_REDIS = 'false'
|
||||||
|
delete process.env.REDIS_URL
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalUseRedis === undefined) delete process.env.USE_REDIS
|
||||||
|
else process.env.USE_REDIS = originalUseRedis
|
||||||
|
|
||||||
|
if (originalRedisUrl === undefined) delete process.env.REDIS_URL
|
||||||
|
else process.env.REDIS_URL = originalRedisUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
test('只存储绑定摘要,不暴露浏览器绑定值', async () => {
|
||||||
|
const handoff = await createDingtalkLoginHandoff({
|
||||||
|
returnOrigin: 'http://192.168.58.20:4320',
|
||||||
|
redirectPath: '/admin?tab=users',
|
||||||
|
})
|
||||||
|
|
||||||
|
const stored = await getDingtalkLoginHandoff(handoff.id)
|
||||||
|
expect(stored).toMatchObject({
|
||||||
|
id: handoff.id,
|
||||||
|
returnOrigin: 'http://192.168.58.20:4320',
|
||||||
|
redirectPath: '/admin?tab=users',
|
||||||
|
})
|
||||||
|
expect(stored?.bindingHash).toHaveLength(64)
|
||||||
|
expect(stored).not.toHaveProperty('binding')
|
||||||
|
|
||||||
|
await deleteDingtalkLoginHandoff(handoff.id)
|
||||||
|
await expect(getDingtalkLoginHandoff(handoff.id)).resolves.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
35
lib/__tests__/dingtalk-return-origin.test.ts
Normal file
35
lib/__tests__/dingtalk-return-origin.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/** @jest-environment node */
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDingtalkReturnOrigins,
|
||||||
|
getTrustedDingtalkReturnOrigin,
|
||||||
|
} from '@/lib/dingtalk-return-origin'
|
||||||
|
|
||||||
|
describe('DingTalk internal return origin allowlist', () => {
|
||||||
|
const originalValue = process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalValue === undefined) {
|
||||||
|
delete process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
|
||||||
|
} else {
|
||||||
|
process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = originalValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('未配置时仅接受当前稳定内网入口', () => {
|
||||||
|
delete process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
|
||||||
|
|
||||||
|
expect(getDingtalkReturnOrigins()).toEqual(['http://192.168.58.20:4320'])
|
||||||
|
expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20:4320')).toBe('http://192.168.58.20:4320')
|
||||||
|
expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20:4321')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('严格拒绝相似地址、路径和外部地址', () => {
|
||||||
|
process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = 'http://192.168.58.20:4320,https://lan.example.test'
|
||||||
|
|
||||||
|
expect(getTrustedDingtalkReturnOrigin('https://lan.example.test')).toBe('https://lan.example.test')
|
||||||
|
expect(getTrustedDingtalkReturnOrigin('https://lan.example.test/redirect')).toBeNull()
|
||||||
|
expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20.evil.test:4320')).toBeNull()
|
||||||
|
expect(getTrustedDingtalkReturnOrigin('https://sc.jiansun.vip')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
80
lib/__tests__/dingtalk-session-ticket.test.ts
Normal file
80
lib/__tests__/dingtalk-session-ticket.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/** @jest-environment node */
|
||||||
|
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import {
|
||||||
|
consumeDingtalkSessionTicket,
|
||||||
|
createDingtalkSessionTicket,
|
||||||
|
} from '@/lib/dingtalk-session-ticket'
|
||||||
|
|
||||||
|
describe('DingTalk one-time session ticket', () => {
|
||||||
|
const originalUseRedis = process.env.USE_REDIS
|
||||||
|
const originalRedisUrl = process.env.REDIS_URL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.USE_REDIS = 'false'
|
||||||
|
delete process.env.REDIS_URL
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalUseRedis === undefined) delete process.env.USE_REDIS
|
||||||
|
else process.env.USE_REDIS = originalUseRedis
|
||||||
|
|
||||||
|
if (originalRedisUrl === undefined) delete process.env.REDIS_URL
|
||||||
|
else process.env.REDIS_URL = originalRedisUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
test('必须匹配原内网地址和浏览器绑定值,且只能消费一次', async () => {
|
||||||
|
const binding = 'binding-for-original-browser'
|
||||||
|
const ticketId = await createDingtalkSessionTicket({
|
||||||
|
userId: 'user-1',
|
||||||
|
redirectPath: '/admin?tab=users',
|
||||||
|
targetOrigin: 'http://192.168.58.20:4320',
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
handoffBindingHash: createHash('sha256').update(binding).digest('hex'),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
consumeDingtalkSessionTicket(ticketId, {
|
||||||
|
expectedTargetOrigin: 'http://192.168.58.20:4320',
|
||||||
|
expectedHandoffBinding: 'other-browser-binding',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
consumeDingtalkSessionTicket(ticketId, {
|
||||||
|
expectedTargetOrigin: 'http://192.168.58.20:4321',
|
||||||
|
expectedHandoffBinding: binding,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
consumeDingtalkSessionTicket(ticketId, {
|
||||||
|
expectedTargetOrigin: 'http://192.168.58.20:4320',
|
||||||
|
expectedHandoffBinding: binding,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
userId: 'user-1',
|
||||||
|
redirectPath: '/admin?tab=users',
|
||||||
|
targetOrigin: 'http://192.168.58.20:4320',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
consumeDingtalkSessionTicket(ticketId, {
|
||||||
|
expectedTargetOrigin: 'http://192.168.58.20:4320',
|
||||||
|
expectedHandoffBinding: binding,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('现有仅写 Cookie 的会话票据仍能正常消费', async () => {
|
||||||
|
const ticketId = await createDingtalkSessionTicket({
|
||||||
|
redirectPath: '/performance-survey',
|
||||||
|
cookies: ['next-auth.session-token=test-token; Path=/; HttpOnly'],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(consumeDingtalkSessionTicket(ticketId)).resolves.toMatchObject({
|
||||||
|
redirectPath: '/performance-survey',
|
||||||
|
cookies: ['next-auth.session-token=test-token; Path=/; HttpOnly'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
113
lib/dingtalk-login-handoff.ts
Normal file
113
lib/dingtalk-login-handoff.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from 'crypto'
|
||||||
|
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
|
||||||
|
|
||||||
|
type DingtalkLoginHandoff = {
|
||||||
|
id: string
|
||||||
|
returnOrigin: string
|
||||||
|
redirectPath: string
|
||||||
|
bindingHash: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDOFF_TTL_SECONDS = 5 * 60
|
||||||
|
const HANDOFF_TTL_MS = HANDOFF_TTL_SECONDS * 1_000
|
||||||
|
const HANDOFF_KEY_PREFIX = 'dingtalk:login-handoff:'
|
||||||
|
export const DINGTALK_LOGIN_HANDOFF_COOKIE = 'dingtalk-login-handoff'
|
||||||
|
|
||||||
|
const memoryHandoffs = new Map<string, DingtalkLoginHandoff>()
|
||||||
|
|
||||||
|
function shouldUseRedisHandoffStore() {
|
||||||
|
return process.env.USE_REDIS === 'true' && Boolean(process.env.REDIS_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashBinding(value: string) {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredMemoryHandoffs() {
|
||||||
|
const now = Date.now()
|
||||||
|
memoryHandoffs.forEach((handoff, id) => {
|
||||||
|
if (handoff.expiresAt <= now) {
|
||||||
|
memoryHandoffs.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHandoff(id: string, raw: string): DingtalkLoginHandoff | null {
|
||||||
|
try {
|
||||||
|
const handoff = JSON.parse(raw) as DingtalkLoginHandoff
|
||||||
|
if (
|
||||||
|
handoff.id !== id ||
|
||||||
|
typeof handoff.returnOrigin !== 'string' ||
|
||||||
|
typeof handoff.redirectPath !== 'string' ||
|
||||||
|
typeof handoff.bindingHash !== 'string' ||
|
||||||
|
!Number.isFinite(handoff.expiresAt) ||
|
||||||
|
handoff.expiresAt <= Date.now()
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return handoff
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKey(id: string) {
|
||||||
|
return `${HANDOFF_KEY_PREFIX}${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDingtalkLoginHandoff(input: {
|
||||||
|
returnOrigin: string
|
||||||
|
redirectPath: unknown
|
||||||
|
}) {
|
||||||
|
const id = randomUUID()
|
||||||
|
const binding = randomBytes(32).toString('base64url')
|
||||||
|
const handoff: DingtalkLoginHandoff = {
|
||||||
|
id,
|
||||||
|
returnOrigin: input.returnOrigin,
|
||||||
|
redirectPath: normalizeInternalRedirectPath(input.redirectPath),
|
||||||
|
bindingHash: hashBinding(binding),
|
||||||
|
expiresAt: Date.now() + HANDOFF_TTL_MS,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUseRedisHandoffStore()) {
|
||||||
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
||||||
|
const saved = await redis.set(getKey(id), JSON.stringify(handoff), 'EX', HANDOFF_TTL_SECONDS, 'NX')
|
||||||
|
if (saved !== 'OK') {
|
||||||
|
throw new Error('创建钉钉登录内网交接请求失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cleanupExpiredMemoryHandoffs()
|
||||||
|
memoryHandoffs.set(id, handoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, binding, expiresAt: handoff.expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDingtalkLoginHandoff(id: string | null | undefined) {
|
||||||
|
const normalizedId = String(id || '').trim()
|
||||||
|
if (!normalizedId) return null
|
||||||
|
|
||||||
|
if (shouldUseRedisHandoffStore()) {
|
||||||
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
||||||
|
const raw = await redis.get(getKey(normalizedId))
|
||||||
|
return raw ? parseHandoff(normalizedId, raw) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupExpiredMemoryHandoffs()
|
||||||
|
return memoryHandoffs.get(normalizedId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDingtalkLoginHandoff(id: string | null | undefined) {
|
||||||
|
const normalizedId = String(id || '').trim()
|
||||||
|
if (!normalizedId) return
|
||||||
|
|
||||||
|
if (shouldUseRedisHandoffStore()) {
|
||||||
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
||||||
|
await redis.del(getKey(normalizedId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryHandoffs.delete(normalizedId)
|
||||||
|
}
|
||||||
@ -30,7 +30,7 @@ type DingtalkUserInfo = {
|
|||||||
|
|
||||||
type DingtalkLoginAppSource = 'login' | 'workNotice'
|
type DingtalkLoginAppSource = 'login' | 'workNotice'
|
||||||
|
|
||||||
type DingtalkNextAuthUser = {
|
export type DingtalkNextAuthUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
username: string
|
username: string
|
||||||
@ -252,19 +252,10 @@ function warmPerformanceSurveyProfile(user: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAuthenticatedResponse(
|
export async function setDingtalkAuthenticatedSessionCookies(
|
||||||
|
response: NextResponse,
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
user: {
|
user: DingtalkNextAuthUser,
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
username: string
|
|
||||||
realName?: string | null
|
|
||||||
role: Role
|
|
||||||
department: string
|
|
||||||
managedDepartment?: string | null
|
|
||||||
avatar?: string | null
|
|
||||||
isSuperAdmin?: boolean | null
|
|
||||||
},
|
|
||||||
options?: {
|
options?: {
|
||||||
cookieSameSite?: 'lax' | 'none'
|
cookieSameSite?: 'lax' | 'none'
|
||||||
},
|
},
|
||||||
@ -297,22 +288,6 @@ async function createAuthenticatedResponse(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
needCompleteProfile: false,
|
|
||||||
message: '登录成功',
|
|
||||||
sessionCreated: true,
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
realName: user.realName,
|
|
||||||
avatar: user.avatar,
|
|
||||||
role: user.role,
|
|
||||||
department: user.department,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
response.cookies.set(cookieName, sessionToken, {
|
response.cookies.set(cookieName, sessionToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite,
|
sameSite,
|
||||||
@ -335,6 +310,32 @@ async function createAuthenticatedResponse(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAuthenticatedResponse(
|
||||||
|
request: NextRequest,
|
||||||
|
user: DingtalkNextAuthUser,
|
||||||
|
options?: {
|
||||||
|
cookieSameSite?: 'lax' | 'none'
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
needCompleteProfile: false,
|
||||||
|
message: '登录成功',
|
||||||
|
sessionCreated: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
realName: user.realName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
role: user.role,
|
||||||
|
department: user.department,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await setDingtalkAuthenticatedSessionCookies(response, request, user, options)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,8 @@ export function parseDingtalkOAuthState(value: unknown) {
|
|||||||
origin: '',
|
origin: '',
|
||||||
redirectPath: '/',
|
redirectPath: '/',
|
||||||
source: '',
|
source: '',
|
||||||
|
nonce: '',
|
||||||
|
handoffId: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,12 +54,16 @@ export function parseDingtalkOAuthState(value: unknown) {
|
|||||||
origin: typeof data?.origin === 'string' ? data.origin : '',
|
origin: typeof data?.origin === 'string' ? data.origin : '',
|
||||||
redirectPath: normalizeInternalRedirectPath(data?.redirect),
|
redirectPath: normalizeInternalRedirectPath(data?.redirect),
|
||||||
source: typeof data?.source === 'string' ? data.source : '',
|
source: typeof data?.source === 'string' ? data.source : '',
|
||||||
|
nonce: typeof data?.nonce === 'string' ? data.nonce : '',
|
||||||
|
handoffId: typeof data?.handoffId === 'string' ? data.handoffId : '',
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
origin: '',
|
origin: '',
|
||||||
redirectPath: '/',
|
redirectPath: '/',
|
||||||
source: '',
|
source: '',
|
||||||
|
nonce: '',
|
||||||
|
handoffId: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
lib/dingtalk-return-origin.ts
Normal file
47
lib/dingtalk-return-origin.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
const DEFAULT_DINGTALK_RETURN_ORIGINS = ['http://192.168.58.20:4320']
|
||||||
|
|
||||||
|
function normalizeHttpOrigin(value: unknown): string | null {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw)
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.origin
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfiguredOrigins(value: string | undefined): string[] {
|
||||||
|
return String(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => normalizeHttpOrigin(item))
|
||||||
|
.filter((item): item is string => Boolean(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉必须回到公网完成 OAuth;这里仅定义 OAuth 完成后允许交接会话的内网入口。
|
||||||
|
*
|
||||||
|
* 生产环境通过 DINGTALK_INTERNAL_RETURN_ORIGINS 显式配置。未配置时仅保留当前稳定内网入口,
|
||||||
|
* 防止把 state 中任意地址当成跳转目标。
|
||||||
|
*/
|
||||||
|
export function getDingtalkReturnOrigins(): string[] {
|
||||||
|
const configured = parseConfiguredOrigins(process.env.DINGTALK_INTERNAL_RETURN_ORIGINS)
|
||||||
|
const candidates = configured.length > 0 ? configured : DEFAULT_DINGTALK_RETURN_ORIGINS
|
||||||
|
return Array.from(new Set(candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrustedDingtalkReturnOrigin(value: unknown): string | null {
|
||||||
|
const origin = normalizeHttpOrigin(value)
|
||||||
|
if (!origin) return null
|
||||||
|
|
||||||
|
return getDingtalkReturnOrigins().includes(origin) ? origin : null
|
||||||
|
}
|
||||||
@ -1,13 +1,20 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { createHash, randomUUID, timingSafeEqual } from 'crypto'
|
||||||
|
|
||||||
type DingtalkSessionTicket = {
|
type DingtalkSessionTicket = {
|
||||||
id: string
|
id: string
|
||||||
redirectPath: string
|
redirectPath: string
|
||||||
cookies: string[]
|
cookies?: string[]
|
||||||
|
userId?: string | null
|
||||||
|
targetOrigin?: string | null
|
||||||
|
handoffId?: string | null
|
||||||
|
handoffBindingHash?: string | null
|
||||||
|
nonce?: string | null
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICKET_TTL_MS = 60_000
|
const TICKET_TTL_SECONDS = 60
|
||||||
|
const TICKET_TTL_MS = TICKET_TTL_SECONDS * 1_000
|
||||||
|
const REDIS_KEY_PREFIX = 'dingtalk:session-ticket:'
|
||||||
const tickets = new Map<string, DingtalkSessionTicket>()
|
const tickets = new Map<string, DingtalkSessionTicket>()
|
||||||
|
|
||||||
function cleanupExpiredTickets() {
|
function cleanupExpiredTickets() {
|
||||||
@ -19,40 +26,169 @@ function cleanupExpiredTickets() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDingtalkSessionTicket(input: {
|
function shouldUseRedisTicketStore() {
|
||||||
|
return process.env.USE_REDIS === 'true' && Boolean(process.env.REDIS_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedisKey(id: string) {
|
||||||
|
return `${REDIS_KEY_PREFIX}${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTicket(id: string, raw: string): DingtalkSessionTicket | null {
|
||||||
|
try {
|
||||||
|
const ticket = JSON.parse(raw) as DingtalkSessionTicket
|
||||||
|
if (
|
||||||
|
ticket.id !== id ||
|
||||||
|
(!Array.isArray(ticket.cookies) && !ticket.userId) ||
|
||||||
|
(Array.isArray(ticket.cookies) && !ticket.cookies.every((cookie) => typeof cookie === 'string' && cookie.length > 0)) ||
|
||||||
|
typeof ticket.redirectPath !== 'string' ||
|
||||||
|
!Number.isFinite(ticket.expiresAt) ||
|
||||||
|
ticket.expiresAt <= Date.now()
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ticket
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesExpectedTargetOrigin(ticket: DingtalkSessionTicket, expectedTargetOrigin?: string | null) {
|
||||||
|
if (!ticket.targetOrigin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket.targetOrigin === String(expectedTargetOrigin || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBindingHash(value: string) {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesExpectedHandoffBinding(ticket: DingtalkSessionTicket, expectedHandoffBinding?: string | null) {
|
||||||
|
if (!ticket.handoffBindingHash) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = String(expectedHandoffBinding || '')
|
||||||
|
if (!expected) return false
|
||||||
|
|
||||||
|
const expectedHash = createBindingHash(expected)
|
||||||
|
const left = Buffer.from(ticket.handoffBindingHash, 'hex')
|
||||||
|
const right = Buffer.from(expectedHash, 'hex')
|
||||||
|
return left.length === right.length && timingSafeEqual(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDingtalkSessionTicket(input: {
|
||||||
redirectPath: string
|
redirectPath: string
|
||||||
cookies: string[]
|
cookies?: string[]
|
||||||
|
userId?: string | null
|
||||||
|
targetOrigin?: string | null
|
||||||
|
handoffId?: string | null
|
||||||
|
handoffBindingHash?: string | null
|
||||||
|
nonce?: string | null
|
||||||
}) {
|
}) {
|
||||||
cleanupExpiredTickets()
|
if (!input.userId && (!Array.isArray(input.cookies) || input.cookies.length === 0)) {
|
||||||
|
throw new Error('创建钉钉登录会话交接票据时缺少会话内容')
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
tickets.set(id, {
|
const ticket: DingtalkSessionTicket = {
|
||||||
id,
|
id,
|
||||||
redirectPath: input.redirectPath,
|
redirectPath: input.redirectPath,
|
||||||
cookies: input.cookies,
|
cookies: input.cookies || [],
|
||||||
|
userId: input.userId || null,
|
||||||
|
targetOrigin: input.targetOrigin || null,
|
||||||
|
handoffId: input.handoffId || null,
|
||||||
|
handoffBindingHash: input.handoffBindingHash || null,
|
||||||
|
nonce: input.nonce || null,
|
||||||
expiresAt: Date.now() + TICKET_TTL_MS,
|
expiresAt: Date.now() + TICKET_TTL_MS,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (shouldUseRedisTicketStore()) {
|
||||||
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
||||||
|
const saved = await redis.set(
|
||||||
|
getRedisKey(id),
|
||||||
|
JSON.stringify(ticket),
|
||||||
|
'EX',
|
||||||
|
TICKET_TTL_SECONDS,
|
||||||
|
'NX',
|
||||||
|
)
|
||||||
|
if (saved !== 'OK') {
|
||||||
|
throw new Error('创建钉钉登录会话交接票据失败')
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupExpiredTickets()
|
||||||
|
tickets.set(id, ticket)
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function consumeDingtalkSessionTicket(id: string | null | undefined) {
|
export async function consumeDingtalkSessionTicket(
|
||||||
cleanupExpiredTickets()
|
id: string | null | undefined,
|
||||||
|
options?: {
|
||||||
|
expectedTargetOrigin?: string | null
|
||||||
|
expectedHandoffBinding?: string | null
|
||||||
|
},
|
||||||
|
) {
|
||||||
const normalizedId = String(id || '').trim()
|
const normalizedId = String(id || '').trim()
|
||||||
if (!normalizedId) {
|
if (!normalizedId) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticket = tickets.get(normalizedId)
|
if (shouldUseRedisTicketStore()) {
|
||||||
tickets.delete(normalizedId)
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
||||||
|
const key = getRedisKey(normalizedId)
|
||||||
|
const raw = await redis.get(key)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
if (!ticket || ticket.expiresAt <= Date.now()) {
|
const ticket = normalizeTicket(normalizedId, raw)
|
||||||
|
if (
|
||||||
|
!ticket ||
|
||||||
|
!matchesExpectedTargetOrigin(ticket, options?.expectedTargetOrigin) ||
|
||||||
|
!matchesExpectedHandoffBinding(ticket, options?.expectedHandoffBinding)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await redis.eval(
|
||||||
|
'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0',
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
if (Number(consumed) !== 1) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirectPath: ticket.redirectPath,
|
||||||
|
cookies: ticket.cookies || [],
|
||||||
|
userId: ticket.userId || null,
|
||||||
|
targetOrigin: ticket.targetOrigin || null,
|
||||||
|
handoffId: ticket.handoffId || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupExpiredTickets()
|
||||||
|
const ticket = tickets.get(normalizedId)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ticket ||
|
||||||
|
ticket.expiresAt <= Date.now() ||
|
||||||
|
!matchesExpectedTargetOrigin(ticket, options?.expectedTargetOrigin) ||
|
||||||
|
!matchesExpectedHandoffBinding(ticket, options?.expectedHandoffBinding)
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tickets.delete(normalizedId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirectPath: ticket.redirectPath,
|
redirectPath: ticket.redirectPath,
|
||||||
cookies: ticket.cookies,
|
cookies: ticket.cookies || [],
|
||||||
|
userId: ticket.userId || null,
|
||||||
|
targetOrigin: ticket.targetOrigin || null,
|
||||||
|
handoffId: ticket.handoffId || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ $targetConfig = @{
|
|||||||
PublicAppUrl = 'https://sc.jiansun.vip'
|
PublicAppUrl = 'https://sc.jiansun.vip'
|
||||||
CorsAllowedOrigins = 'https://sc.jiansun.vip,http://192.168.58.20:4320'
|
CorsAllowedOrigins = 'https://sc.jiansun.vip,http://192.168.58.20:4320'
|
||||||
DingtalkCallbackOrigin = 'https://sc.jiansun.vip'
|
DingtalkCallbackOrigin = 'https://sc.jiansun.vip'
|
||||||
|
DingtalkInternalReturnOrigins = 'http://192.168.58.20:4320'
|
||||||
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
|
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
|
||||||
}
|
}
|
||||||
shadow = @{
|
shadow = @{
|
||||||
@ -67,6 +68,7 @@ $targetConfig = @{
|
|||||||
PublicAppUrl = 'http://192.168.58.20:5320'
|
PublicAppUrl = 'http://192.168.58.20:5320'
|
||||||
CorsAllowedOrigins = 'http://192.168.58.20:5320'
|
CorsAllowedOrigins = 'http://192.168.58.20:5320'
|
||||||
DingtalkCallbackOrigin = 'http://192.168.58.20:5320'
|
DingtalkCallbackOrigin = 'http://192.168.58.20:5320'
|
||||||
|
DingtalkInternalReturnOrigins = 'http://192.168.58.20:5320'
|
||||||
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
|
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -282,6 +284,7 @@ $tokens = @{
|
|||||||
'__PUBLIC_APP_URL__' = ConvertTo-ShellSingleQuoted $cfg.PublicAppUrl
|
'__PUBLIC_APP_URL__' = ConvertTo-ShellSingleQuoted $cfg.PublicAppUrl
|
||||||
'__CORS_ALLOWED_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.CorsAllowedOrigins
|
'__CORS_ALLOWED_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.CorsAllowedOrigins
|
||||||
'__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin
|
'__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin
|
||||||
|
'__DINGTALK_INTERNAL_RETURN_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkInternalReturnOrigins
|
||||||
'__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain
|
'__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain
|
||||||
'__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret
|
'__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret
|
||||||
'__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' }))
|
'__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' }))
|
||||||
@ -314,6 +317,7 @@ ENV_FILE=__ENV_FILE__
|
|||||||
PUBLIC_APP_URL=__PUBLIC_APP_URL__
|
PUBLIC_APP_URL=__PUBLIC_APP_URL__
|
||||||
CORS_ALLOWED_ORIGINS=__CORS_ALLOWED_ORIGINS__
|
CORS_ALLOWED_ORIGINS=__CORS_ALLOWED_ORIGINS__
|
||||||
DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__
|
DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__
|
||||||
|
DINGTALK_INTERNAL_RETURN_ORIGINS=__DINGTALK_INTERNAL_RETURN_ORIGINS__
|
||||||
S3_PUBLIC_DOMAIN=__S3_PUBLIC_DOMAIN__
|
S3_PUBLIC_DOMAIN=__S3_PUBLIC_DOMAIN__
|
||||||
DOWNLOAD_TOKEN_SECRET=__DOWNLOAD_TOKEN_SECRET__
|
DOWNLOAD_TOKEN_SECRET=__DOWNLOAD_TOKEN_SECRET__
|
||||||
DRY_RUN=__DRY_RUN__
|
DRY_RUN=__DRY_RUN__
|
||||||
@ -516,6 +520,7 @@ start_container() {
|
|||||||
-e "APP_PUBLIC_URL=$PUBLIC_APP_URL"
|
-e "APP_PUBLIC_URL=$PUBLIC_APP_URL"
|
||||||
-e "CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS"
|
-e "CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS"
|
||||||
-e "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN"
|
-e "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN"
|
||||||
|
-e "DINGTALK_INTERNAL_RETURN_ORIGINS=$DINGTALK_INTERNAL_RETURN_ORIGINS"
|
||||||
-e "S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN"
|
-e "S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN"
|
||||||
-e "DISABLE_CACHE=true"
|
-e "DISABLE_CACHE=true"
|
||||||
-e "DEPLOYMENT_VERSION=$deploy_version"
|
-e "DEPLOYMENT_VERSION=$deploy_version"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user