fix(env): fix env
This commit is contained in:
@@ -31,17 +31,41 @@ interface UserInfo {
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
// 未登录 -> 跳转 SSO
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
const appId = 'app_demo_001'
|
||||
const callbackUrl = encodeURIComponent('http://localhost:4000/callback')
|
||||
window.location.href = `http://localhost:5501/login?app_id=${appId}&app_url=${callbackUrl}`
|
||||
}
|
||||
}, [user])
|
||||
// 构建跳转 sso-portal 的登录 URL(OAuth2:app_id 即 client_id)
|
||||
const SSO_APP_ID = process.env.NEXT_PUBLIC_SSO_APP_ID || ''
|
||||
const SSO_URL = (process.env.NEXT_PUBLIC_SSO_URL || 'http://localhost:5501').replace(/\/+$/, '')
|
||||
const APP_URL = (process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:4000').replace(/\/+$/, '')
|
||||
const SSO_STATE_STORAGE_KEY = 'sso_login_state'
|
||||
|
||||
if (!user) {
|
||||
function buildSsoLoginUrl(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(bytes)
|
||||
const state = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
const loginUrl = new URL(`${SSO_URL}/login`)
|
||||
|
||||
window.sessionStorage.setItem(SSO_STATE_STORAGE_KEY, state)
|
||||
loginUrl.searchParams.set('app_id', SSO_APP_ID)
|
||||
loginUrl.searchParams.set('app_url', `${APP_URL}/callback`)
|
||||
loginUrl.searchParams.set('state', state)
|
||||
return loginUrl.toString()
|
||||
}
|
||||
|
||||
export default function DashboardPage({
|
||||
user,
|
||||
authenticated,
|
||||
}: {
|
||||
user: UserInfo | null
|
||||
// 登录态以 token 为准(拿到 access_token 即算成功);user 可能为 null
|
||||
authenticated: boolean
|
||||
}) {
|
||||
// 未登录(无 token)-> 跳转 SSO
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
window.location.href = buildSsoLoginUrl()
|
||||
}
|
||||
}, [authenticated])
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Text size="lg" c="dimmed">正在跳转到 SSO 登录页面...</Text>
|
||||
@@ -51,10 +75,8 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
// 跳到 SSO 登录页(带上 app_id 让 SSO 知道是哪个应用)
|
||||
const appId = 'app_demo_001'
|
||||
const callbackUrl = encodeURIComponent('http://localhost:4000/callback')
|
||||
window.location.href = `http://localhost:5501/login?app_id=${appId}&app_url=${callbackUrl}`
|
||||
// 跳到 SSO 登录页;每次登录都生成新的 state 防止登录 CSRF。
|
||||
window.location.href = buildSsoLoginUrl()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,13 +92,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
<Menu.Target>
|
||||
<Group gap="sm" style={{ cursor: 'pointer' }}>
|
||||
<Avatar color="brand" radius="xl" size="sm">
|
||||
{user.name?.[0] || 'U'}
|
||||
{user?.name?.[0] || 'U'}
|
||||
</Avatar>
|
||||
<Text size="sm" fw={500}>{user.name}</Text>
|
||||
<Text size="sm" fw={500}>{user?.name || '已登录用户'}</Text>
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconUser size={14} />}>{user.account}</Menu.Item>
|
||||
<Menu.Item leftSection={<IconUser size={14} />}>
|
||||
{user?.account || '(未获取到账号)'}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLogout size={14} />} color="red" onClick={handleLogout}>
|
||||
退出登录
|
||||
@@ -89,7 +113,9 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
<AppShell.Main>
|
||||
<Stack maw={800} mx="auto" pt="xl">
|
||||
<Text size="xl" fw={700}>登录成功!</Text>
|
||||
<Text c="dimmed">您已通过 Mock SSO 认证登录到此应用。</Text>
|
||||
<Text c="dimmed">
|
||||
您已通过 SSO 认证登录到此应用(OAuth2 授权码流程,已换取 access_token)。
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} mt="md">
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
@@ -97,24 +123,30 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
<IconUser size={20} color="#0080FF" />
|
||||
<Text fw={600}>用户信息</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>ID</Text>
|
||||
<Code>{user.id}</Code>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>账号</Text>
|
||||
<Text size="sm">{user.account}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>姓名</Text>
|
||||
<Text size="sm">{user.name}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>手机</Text>
|
||||
<Text size="sm">{user.phone}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
{user ? (
|
||||
<Stack gap="xs">
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>ID</Text>
|
||||
<Code>{user.id}</Code>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>账号</Text>
|
||||
<Text size="sm">{user.account}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>姓名</Text>
|
||||
<Text size="sm">{user.name}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>手机</Text>
|
||||
<Text size="sm">{user.phone}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
后端 access_token 接口未返回用户详情,暂无用户信息。
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
@@ -122,11 +154,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
<IconShield size={20} color="#20C997" />
|
||||
<Text fw={600}>角色权限</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{user.roles?.map((role: string) => (
|
||||
<Badge key={role} variant="light">{role}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{user?.roles?.length ? (
|
||||
<Group gap="xs">
|
||||
{user.roles.map((role: string) => (
|
||||
<Badge key={role} variant="light">{role}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">暂无角色信息</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -142,16 +178,16 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
||||
<strong>1.</strong> 您打开此应用(<Code>http://localhost:4000</Code>)
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>2.</strong> 检测到未登录,自动跳转到 Mock SSO(<Code>http://localhost:5501/login</Code>)
|
||||
<strong>2.</strong> 检测到未登录,自动跳转到 SSO(<Code>http://localhost:5501/login</Code>)
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>3.</strong> 在 SSO 页面完成登录,SSO 返回 <Code>sso_code</Code>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>4.</strong> 此应用用 <Code>sso_code</Code> 调用 <Code>POST /api/auth/verify</Code> 换取用户信息
|
||||
<strong>4.</strong> 此应用用 <Code>sso_code</Code> 调用后端 <Code>/api/v1/basis/sso/access_token</Code> 换取 access_token
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>5.</strong> 用户信息存入 Cookie,显示此页面
|
||||
<strong>5.</strong> access_token 存入 httpOnly Cookie,即算登录成功;用户详情需后端 userinfo 接口提供
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,65 +1,138 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* SSO 回调接口
|
||||
* 接收 sso_code,调用 Mock SSO 验证接口换取用户信息,
|
||||
* 然后将用户信息写入 Cookie。
|
||||
*
|
||||
* OAuth2 授权码流程:用一次性授权 code 向真实后端换取 access_token,
|
||||
* 然后将 token / 用户信息写入 Cookie 建立本地会话。
|
||||
*
|
||||
* ⚠️ client_secret 仅在服务端 env(SSO_CLIENT_SECRET),绝不下发浏览器。
|
||||
*/
|
||||
const BACKEND = (
|
||||
process.env.SSO_BACKEND_API || "http://172.16.115.31:6610"
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { code } = await req.json()
|
||||
const { code } = await req.json();
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: '缺少 code 参数' }, { status: 400 })
|
||||
return NextResponse.json({ error: "缺少 code 参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 调用 Mock SSO 的验证接口
|
||||
const ssoUrl = process.env.SSO_URL || 'http://localhost:5501'
|
||||
const verifyRes = await fetch(`${ssoUrl}/api/auth/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
const clientId = process.env.NEXT_PUBLIC_SSO_APP_ID;
|
||||
const clientSecret = process.env.SSO_CLIENT_SECRET;
|
||||
const tenant = process.env.SSO_TENANT || "cowarobot";
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const errData = await verifyRes.json().catch(() => ({}))
|
||||
if (!clientId || !clientSecret) {
|
||||
console.error(
|
||||
"[external-app] 缺少 NEXT_PUBLIC_SSO_APP_ID 或 SSO_CLIENT_SECRET",
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: errData.error || 'SSO 验证失败' },
|
||||
{ status: 401 }
|
||||
)
|
||||
{ error: "服务端 SSO 配置缺失" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const ssoData = await verifyRes.json()
|
||||
const { token, refresh_token, user, permissions } = ssoData
|
||||
// 用授权 code 换 access_token(标准 OAuth2)
|
||||
const tokenRes = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
tenant,
|
||||
}),
|
||||
});
|
||||
|
||||
// 将用户信息和 token 写入 Cookie
|
||||
const response = NextResponse.json({ success: true })
|
||||
const data = await tokenRes.json().catch(() => ({}));
|
||||
console.log(
|
||||
"[external-app] access_token 响应 status:",
|
||||
tokenRes.status,
|
||||
"| body:",
|
||||
JSON.stringify(data).slice(0, 500),
|
||||
);
|
||||
|
||||
response.cookies.set('user', JSON.stringify(user), {
|
||||
httpOnly: false, // 客户端需要读取用于展示
|
||||
path: '/',
|
||||
maxAge: 86400,
|
||||
})
|
||||
if (!tokenRes.ok) {
|
||||
const message = data?.message || data?.msg || "换 token 失败";
|
||||
console.error(
|
||||
"[external-app] access_token error:",
|
||||
tokenRes.status,
|
||||
message,
|
||||
);
|
||||
return NextResponse.json({ error: message }, { status: 401 });
|
||||
}
|
||||
|
||||
response.cookies.set('token', token, {
|
||||
// 兼容平铺 / 外壳两种响应;字段名兼容 user / user_info(basis 习惯用 user_info)
|
||||
const token = data?.access_token ?? data?.data?.access_token;
|
||||
const refreshToken = data?.refresh_token ?? data?.data?.refresh_token;
|
||||
|
||||
if (!token) {
|
||||
console.error(
|
||||
"[external-app] access_token 响应中无 token:",
|
||||
JSON.stringify(data).slice(0, 300),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "后端未返回 access_token" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const infoRes = await fetch(
|
||||
`${BACKEND}/api/v1/basis/sso/user_info?access_token=${token}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
const infoData = await infoRes.json().catch(() => ({}));
|
||||
|
||||
const user = infoData?.data;
|
||||
console.log(
|
||||
"[external-app] 提取 → token:",
|
||||
!!token,
|
||||
"| refresh_token:",
|
||||
!!refreshToken,
|
||||
"| user:",
|
||||
!!user,
|
||||
user
|
||||
? `(${Object.keys(user).join(",")})`
|
||||
: "(无 — 登录态无法建立,首页会跳回 sso-portal)",
|
||||
);
|
||||
|
||||
// 写入 Cookie 建立会话
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set("token", token, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
path: "/",
|
||||
maxAge: 86400,
|
||||
})
|
||||
});
|
||||
|
||||
if (refresh_token) {
|
||||
response.cookies.set('refresh_token', refresh_token, {
|
||||
if (refreshToken) {
|
||||
response.cookies.set("refresh_token", refreshToken, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
path: "/",
|
||||
maxAge: 86400 * 7,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return response
|
||||
if (user) {
|
||||
response.cookies.set("user", JSON.stringify(user), {
|
||||
httpOnly: false, // 客户端需要读取用于展示
|
||||
path: "/",
|
||||
maxAge: 86400,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
console.error("[external-app] callback error:", err?.message);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || '回调处理失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
{ error: err?.message || "回调处理失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Stack, Text, Loader } from '@mantine/core'
|
||||
|
||||
const SSO_STATE_STORAGE_KEY = 'sso_login_state'
|
||||
|
||||
function CallbackContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const callbackStarted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackStarted.current) return
|
||||
|
||||
const code = searchParams.get('sso_code')
|
||||
const state = searchParams.get('state')
|
||||
const expectedState = window.sessionStorage.getItem(SSO_STATE_STORAGE_KEY)
|
||||
|
||||
if (!code) {
|
||||
setError('缺少 sso_code 参数')
|
||||
return
|
||||
}
|
||||
if (!state || !expectedState || state !== expectedState) {
|
||||
window.sessionStorage.removeItem(SSO_STATE_STORAGE_KEY)
|
||||
setError('state 无效或已过期,请重新登录')
|
||||
return
|
||||
}
|
||||
|
||||
callbackStarted.current = true
|
||||
window.sessionStorage.removeItem(SSO_STATE_STORAGE_KEY)
|
||||
|
||||
// 用 code 调用本应用的 /api/callback 换取用户信息
|
||||
fetch('/api/callback', {
|
||||
|
||||
@@ -3,6 +3,9 @@ import DashboardPage from './DashboardPage'
|
||||
|
||||
export default async function Home() {
|
||||
const cookieStore = await cookies()
|
||||
// 登录态以 token 为准:后端 access_token 接口只返回 token(不含用户信息),
|
||||
// 拿到 access_token 即算登录成功;user 信息可选(后端暂未提供 userinfo)。
|
||||
const token = cookieStore.get('token')?.value
|
||||
const userCookie = cookieStore.get('user')?.value
|
||||
|
||||
let user = null
|
||||
@@ -14,5 +17,5 @@ export default async function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
return <DashboardPage user={user} />
|
||||
return <DashboardPage user={user} authenticated={!!token} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user