293 lines
9.2 KiB
TypeScript
293 lines
9.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
|
import { consumeDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
|
|
import {
|
|
deleteDingtalkLoginHandoff,
|
|
DINGTALK_LOGIN_HANDOFF_COOKIE,
|
|
} from '@/lib/dingtalk-login-handoff'
|
|
import { getTrustedDingtalkReturnOriginFromRequest } from '@/lib/dingtalk-return-origin'
|
|
import { setDingtalkAuthenticatedSessionCookies } from '@/lib/dingtalk-login'
|
|
import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const LOGIN_TRANSITION_PAGE_STYLE = `
|
|
html,body{margin:0;min-height:100%;background:#f6f8fb;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
body{min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}
|
|
main{box-sizing:border-box;width:min(400px,100%);padding:28px 24px;text-align:center;border:1px solid #e3eaf3;border-radius:24px;background:#fff;box-shadow:0 18px 48px rgba(15,23,42,.10)}
|
|
.brand{font-size:14px;font-weight:650;color:#1677ff}
|
|
h1{margin:12px 0 0;font-size:24px;line-height:1.25;font-weight:700;letter-spacing:-.01em;color:#101828}
|
|
#message{margin:10px 0 0;color:#667085;font-size:14px;font-weight:500;line-height:1.7}
|
|
.progress{width:72px;height:4px;margin:22px auto 0;overflow:hidden;border-radius:999px;background:#e8f1ff}
|
|
.progress span{display:block;width:65%;height:100%;border-radius:inherit;background:#1677ff;animation:pulse 1.1s ease-in-out infinite}
|
|
@keyframes pulse{0%,100%{opacity:.55;transform:translateX(-20%)}50%{opacity:1;transform:translateX(55%)}}
|
|
`
|
|
|
|
const LOGIN_TRANSITION_PAGE_MARKUP = `
|
|
<main>
|
|
<div class="brand">由壹点「素材库」</div>
|
|
<h1>登录成功</h1>
|
|
<p id="message">正在进入素材库...</p>
|
|
<div class="progress" aria-hidden="true"><span></span></div>
|
|
</main>`
|
|
|
|
function stringifyForInlineScript(value: string) {
|
|
return JSON.stringify(value).replace(/</g, '\\u003c')
|
|
}
|
|
|
|
function buildRedirectUrl(path: string, request?: NextRequest, targetOrigin?: string | null) {
|
|
try {
|
|
return new URL(path, targetOrigin || getPublicAppOrigin()).toString()
|
|
} catch {
|
|
return new URL(path, request?.url || 'https://sc.jiansun.vip').toString()
|
|
}
|
|
}
|
|
|
|
function appendSetCookies(target: NextResponse, cookies: string[]) {
|
|
const cookieNames: string[] = []
|
|
|
|
for (const cookie of cookies) {
|
|
target.headers.append('set-cookie', cookie)
|
|
const cookieName = String(cookie).split('=', 1)[0]?.trim()
|
|
if (cookieName) {
|
|
cookieNames.push(cookieName)
|
|
}
|
|
}
|
|
|
|
return cookieNames
|
|
}
|
|
|
|
function getRequestHeaderSnapshot(request: NextRequest) {
|
|
return {
|
|
host: request.headers.get('host') || '',
|
|
forwardedHost: request.headers.get('x-forwarded-host') || '',
|
|
forwardedProto: request.headers.get('x-forwarded-proto') || '',
|
|
}
|
|
}
|
|
|
|
function createErrorPage(message: string, status = 410) {
|
|
const safeMessage = stringifyForInlineScript(message)
|
|
return new NextResponse(
|
|
`<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>登录失败</title>
|
|
<meta http-equiv="Cache-Control" content="no-store" />
|
|
<style>
|
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
main{width:min(420px,calc(100vw - 48px));padding:32px 24px;text-align:center}
|
|
h1{margin:0 0 12px;font-size:22px}
|
|
p{margin:0;color:#cbd5e1;line-height:1.7}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>登录失败</h1>
|
|
<p id="message"></p>
|
|
</main>
|
|
<script>
|
|
document.getElementById('message').textContent = ${safeMessage};
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
{
|
|
status,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
'Referrer-Policy': 'no-referrer',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
function createSessionRedirectResponse({
|
|
request,
|
|
redirectPath,
|
|
cookies,
|
|
targetOrigin,
|
|
}: {
|
|
request: NextRequest
|
|
redirectPath: string
|
|
cookies: string[]
|
|
targetOrigin?: string | null
|
|
}) {
|
|
const redirectUrl = buildRedirectUrl(redirectPath, request, targetOrigin)
|
|
const response = new NextResponse(
|
|
`<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>正在进入素材库</title>
|
|
<meta http-equiv="Cache-Control" content="no-store" />
|
|
<style>${LOGIN_TRANSITION_PAGE_STYLE}</style>
|
|
</head>
|
|
<body>
|
|
${LOGIN_TRANSITION_PAGE_MARKUP}
|
|
<script>
|
|
(function () {
|
|
var targetUrl = ${stringifyForInlineScript(redirectUrl)};
|
|
var message = document.getElementById('message');
|
|
var attempts = 0;
|
|
var maxAttempts = 12;
|
|
|
|
function setMessage(text) {
|
|
if (message) message.textContent = text;
|
|
}
|
|
|
|
function wait(ms) {
|
|
return new Promise(function (resolve) {
|
|
window.setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
async function confirmSessionThenEnter() {
|
|
while (attempts < maxAttempts) {
|
|
attempts += 1;
|
|
setMessage('正在进入素材库...');
|
|
|
|
try {
|
|
var response = await fetch('/api/auth/session?source=dingtalk_session_ticket&attempt=' + attempts + '&_=' + Date.now(), {
|
|
cache: 'no-store',
|
|
credentials: 'include'
|
|
});
|
|
var data = response.ok ? await response.json().catch(function () { return null; }) : null;
|
|
|
|
if (data && data.user) {
|
|
setMessage('正在进入素材库...');
|
|
window.location.replace(targetUrl);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
// 下一轮继续确认,避免钉钉内置容器偶发网络抖动导致误判。
|
|
}
|
|
|
|
await wait(450);
|
|
}
|
|
|
|
setMessage('暂时无法完成登录,请关闭当前页面后重新登录。');
|
|
}
|
|
|
|
window.setTimeout(confirmSessionThenEnter, 250);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
Pragma: 'no-cache',
|
|
Expires: '0',
|
|
'Referrer-Policy': 'no-referrer',
|
|
},
|
|
},
|
|
)
|
|
|
|
const cookieNames = appendSetCookies(response, cookies)
|
|
console.log('[Dingtalk H5] 通过一次性票据写入会话Cookie并返回落地页:', {
|
|
redirectPath,
|
|
redirectUrl,
|
|
cookieNames,
|
|
requestHeaders: getRequestHeaderSnapshot(request),
|
|
})
|
|
return response
|
|
}
|
|
|
|
async function resolveActiveTicketUser(userId: string) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
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 user
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const requestOrigin = getTrustedDingtalkReturnOriginFromRequest(request)
|
|
const handoffBinding = request.cookies.get(DINGTALK_LOGIN_HANDOFF_COOKIE)?.value || null
|
|
|
|
if (!requestOrigin) {
|
|
return createErrorPage('登录交接来源校验失败,请重新登录。')
|
|
}
|
|
|
|
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 && 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,
|
|
redirectPath: ticket.redirectPath,
|
|
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
|
|
}
|