fix(env): fix env
This commit is contained in:
@@ -1,8 +1,17 @@
|
|||||||
# Mock SSO 服务地址(服务端用于 code 换取用户信息)
|
# sso-portal 登录页地址(客户端跳转用,即 OAuth2 授权端点入口)
|
||||||
SSO_URL="http://localhost:5501"
|
NEXT_PUBLIC_SSO_URL="https://sso.softtest.cowarobot.com"
|
||||||
|
|
||||||
# 本应用地址(作为 SSO 回调 app_url)
|
# 本应用地址(作为 SSO 回调 client_redirect_uri)
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:4000"
|
NEXT_PUBLIC_APP_URL="http://localhost:4000"
|
||||||
|
|
||||||
# 注册在 SSO 的应用 ID(app_id)
|
# OAuth2 client_id(同时作为 URL app_id 传给 sso-portal;非敏感,可下发前端)
|
||||||
SSO_APP_ID="app_demo_001"
|
NEXT_PUBLIC_SSO_APP_ID="6wds5qua3b76hu748zrjmall"
|
||||||
|
|
||||||
|
# OAuth2 client_secret(仅服务端 /api/callback 调 access_token 用,绝不下发前端)
|
||||||
|
SSO_CLIENT_SECRET="oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F"
|
||||||
|
|
||||||
|
# 真实后端 SSO 宿主(服务端调 /api/v1/basis/sso/access_token)
|
||||||
|
SSO_BACKEND_API="https://internal.softtest.cowarobot.com"
|
||||||
|
|
||||||
|
# 租户标识
|
||||||
|
SSO_TENANT="cowarobot"
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
# Mock SSO 服务地址(集群内 svc 名,按实际部署填写)
|
# sso-portal 登录页地址(按实际部署填写,客户端跳转用)
|
||||||
SSO_URL="http://sso-mock-svc:5501"
|
NEXT_PUBLIC_SSO_URL="http://sso-portal-svc:5501"
|
||||||
|
|
||||||
# 本应用地址(作为 SSO 回调 app_url,按实际部署填写)
|
# 本应用地址(作为 SSO 回调 client_redirect_uri,按实际部署填写)
|
||||||
NEXT_PUBLIC_APP_URL=""
|
NEXT_PUBLIC_APP_URL=""
|
||||||
|
|
||||||
# 注册在 SSO 的应用 ID(app_id)
|
# OAuth2 client_id(同时作为 URL app_id 传给 sso-portal)
|
||||||
SSO_APP_ID="app_demo_001"
|
NEXT_PUBLIC_SSO_APP_ID="6wds5qua3b76hu748zrjmall"
|
||||||
|
|
||||||
|
# OAuth2 client_secret(⚠️ 含敏感信息,生产建议通过部署平台密钥注入,不入库)
|
||||||
|
SSO_CLIENT_SECRET="oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F"
|
||||||
|
|
||||||
|
# 真实后端 SSO 宿主(集群内 svc 名,按实际部署填写)
|
||||||
|
SSO_BACKEND_API="http://basis-app-svc:6610"
|
||||||
|
|
||||||
|
# 租户标识
|
||||||
|
SSO_TENANT="cowarobot"
|
||||||
|
|||||||
19
.env.staging
19
.env.staging
@@ -1,8 +1,17 @@
|
|||||||
# Mock SSO 服务地址(服务端用于 code 换取用户信息)
|
# sso-portal 登录页地址(客户端跳转用,即 OAuth2 授权端点入口)
|
||||||
SSO_URL="http://localhost:5501"
|
NEXT_PUBLIC_SSO_URL="http://localhost:5501"
|
||||||
|
|
||||||
# 本应用地址(作为 SSO 回调 app_url)
|
# 本应用地址(作为 SSO 回调 client_redirect_uri)
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:4000"
|
NEXT_PUBLIC_APP_URL="http://localhost:4000"
|
||||||
|
|
||||||
# 注册在 SSO 的应用 ID(app_id)
|
# OAuth2 client_id(同时作为 URL app_id 传给 sso-portal;非敏感,可下发前端)
|
||||||
SSO_APP_ID="app_demo_001"
|
NEXT_PUBLIC_SSO_APP_ID="6wds5qua3b76hu748zrjmall"
|
||||||
|
|
||||||
|
# OAuth2 client_secret(仅服务端 /api/callback 调 access_token 用,绝不下发前端)
|
||||||
|
SSO_CLIENT_SECRET="oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F"
|
||||||
|
|
||||||
|
# 真实后端 SSO 宿主(服务端调 /api/v1/basis/sso/access_token)
|
||||||
|
SSO_BACKEND_API="http://172.16.115.31:6610"
|
||||||
|
|
||||||
|
# 租户标识
|
||||||
|
SSO_TENANT="cowarobot"
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -1,6 +1,6 @@
|
|||||||
# External App(SSO 接入示例)
|
# External App(SSO 接入示例)
|
||||||
|
|
||||||
基于 Next.js 的示例应用,演示如何接入 Mock SSO:跳转到 sso-mock 登录 → 在 `/callback` 收到一次性 `sso_code` → 用 code 换取用户信息并写入 Cookie 建立会话。
|
基于 Next.js 的示例应用,演示如何接入真实 SSO(OAuth2 授权码流程):跳转到 sso-portal 登录 → 在 `/callback` 收到一次性 `sso_code` → 服务端用 code 向后端换 `access_token` 并写入 Cookie 建立会话。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ pnpm install
|
|||||||
pnpm dev # http://localhost:4000
|
pnpm dev # http://localhost:4000
|
||||||
```
|
```
|
||||||
|
|
||||||
> 需同时启动 [`sso-mock`](../sso-mock)(默认 `http://localhost:5501`)。
|
> 需同时启动 [`sso-portal`](../sso-portal)(默认 `http://localhost:5501`)。
|
||||||
|
>
|
||||||
|
> 📘 **接入文档已移至 sso-portal 仓库**:[`sso-portal/INTEGRATION_GUIDE.md`](../sso-portal/INTEGRATION_GUIDE.md) —— 通用接入指南(前置准备 / OAuth2 流程 / 环境变量 / 安全 / FAQ / 后端接口速查)。本 README 仅为 external-app 项目自身的快速上手。
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
@@ -18,22 +20,25 @@ pnpm dev # http://localhost:4000
|
|||||||
|
|
||||||
| 变量 | 端 | 说明 | 示例 |
|
| 变量 | 端 | 说明 | 示例 |
|
||||||
|------|----|------|------|
|
|------|----|------|------|
|
||||||
| `SSO_URL` | 服务端 | Mock SSO 服务地址,`/api/callback` 用它调 `/api/auth/verify` 换用户信息 | dev: `http://localhost:5501`<br>prod: `http://sso-mock-svc:5501` |
|
| `NEXT_PUBLIC_SSO_URL` | 客户端 | sso-portal 登录页地址,跳转 `/login` 用 | dev: `http://localhost:5501`<br>prod: `http://sso-portal-svc:5501` |
|
||||||
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 SSO 回调 `app_url` 传给 sso-mock | `http://localhost:4000` |
|
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 SSO 回调 `client_redirect_uri` 传给 sso-portal | `http://localhost:4000` |
|
||||||
| `SSO_APP_ID` | 服务端 | 注册在 SSO 的应用 ID(`app_id`) | `app_demo_001` |
|
| `NEXT_PUBLIC_SSO_APP_ID` | 两端 | OAuth2 `client_id`,同时作为 URL `app_id` 传给 sso-portal | `6wds5qua3b76hu748zrjmall` |
|
||||||
|
| `SSO_CLIENT_SECRET` | 服务端 | OAuth2 `client_secret`,`/api/callback` 调 `access_token` 用,**绝不下发前端** | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` |
|
||||||
|
| `SSO_BACKEND_API` | 服务端 | 真实后端 SSO 宿主,`/api/callback` 调 `/api/v1/basis/sso/access_token` 用 | dev: `http://172.16.115.31:6610`<br>prod: `http://basis-app-svc:6610` |
|
||||||
|
| `SSO_TENANT` | 服务端 | 租户标识(SSO 接口的 tenant,与 sso-portal 的 `SSO_TENANT` 一致) | `cowarobot` |
|
||||||
|
|
||||||
> 无密钥,可安全入库。`.env.production` 中的 `SSO_URL` / `NEXT_PUBLIC_APP_URL` 为占位值,请按实际部署填写。
|
> ⚠️ `SSO_CLIENT_SECRET` 含敏感信息,生产建议通过部署平台密钥注入,不入库;本地私有覆盖写入 `.env*.local`(已被 `.gitignore` 忽略)。`.env.production` 中的 `NEXT_PUBLIC_SSO_URL` / `NEXT_PUBLIC_APP_URL` 为占位值,请按实际部署填写。
|
||||||
|
|
||||||
## SSO 接入流程
|
## SSO 接入流程
|
||||||
|
|
||||||
1. 应用跳转到 sso-mock:`{SSO_URL}/login?app_id={SSO_APP_ID}&app_url={NEXT_PUBLIC_APP_URL}/callback`
|
1. 应用跳转到 sso-portal:`{NEXT_PUBLIC_SSO_URL}/login?app_id={NEXT_PUBLIC_SSO_APP_ID}&app_url={NEXT_PUBLIC_APP_URL}/callback`
|
||||||
2. 用户在 sso-mock 登录(账号 / 手机 / 飞书)
|
2. 用户在 sso-portal 登录(账号 / 手机)
|
||||||
3. sso-mock 回跳:`{app_url}?sso_code=xxxxx`
|
3. sso-portal 回跳:`{app_url}?sso_code=xxxxx`
|
||||||
4. 本应用 `/callback` 页把 `sso_code` POST 给 `/api/callback` → 服务端调 sso-mock `/api/auth/verify` 换取 `{ user, token }` → 写入 Cookie
|
4. 本应用 `/callback` 页把 `sso_code` POST 给 `/api/callback` → 服务端用 `client_secret` 调后端 `/api/v1/basis/sso/access_token` 换 `access_token` → 写入 httpOnly Cookie
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
| 接口 | 方法 | 说明 |
|
| 接口 | 方法 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/api/callback` | POST | 用 `sso_code` 换用户信息并写 Cookie |
|
| `/api/callback` | POST | 用 `sso_code` 向后端换 `access_token` 并写 Cookie |
|
||||||
| `/api/logout` | POST | 清除会话 Cookie |
|
| `/api/logout` | POST | 清除会话 Cookie |
|
||||||
|
|||||||
@@ -31,17 +31,41 @@ interface UserInfo {
|
|||||||
roles: string[]
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
// 构建跳转 sso-portal 的登录 URL(OAuth2:app_id 即 client_id)
|
||||||
// 未登录 -> 跳转 SSO
|
const SSO_APP_ID = process.env.NEXT_PUBLIC_SSO_APP_ID || ''
|
||||||
useEffect(() => {
|
const SSO_URL = (process.env.NEXT_PUBLIC_SSO_URL || 'http://localhost:5501').replace(/\/+$/, '')
|
||||||
if (!user) {
|
const APP_URL = (process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:4000').replace(/\/+$/, '')
|
||||||
const appId = 'app_demo_001'
|
const SSO_STATE_STORAGE_KEY = 'sso_login_state'
|
||||||
const callbackUrl = encodeURIComponent('http://localhost:4000/callback')
|
|
||||||
window.location.href = `http://localhost:5501/login?app_id=${appId}&app_url=${callbackUrl}`
|
|
||||||
}
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Stack align="center" justify="center" h="100vh">
|
<Stack align="center" justify="center" h="100vh">
|
||||||
<Text size="lg" c="dimmed">正在跳转到 SSO 登录页面...</Text>
|
<Text size="lg" c="dimmed">正在跳转到 SSO 登录页面...</Text>
|
||||||
@@ -51,10 +75,8 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await fetch('/api/logout', { method: 'POST' })
|
await fetch('/api/logout', { method: 'POST' })
|
||||||
// 跳到 SSO 登录页(带上 app_id 让 SSO 知道是哪个应用)
|
// 跳到 SSO 登录页;每次登录都生成新的 state 防止登录 CSRF。
|
||||||
const appId = 'app_demo_001'
|
window.location.href = buildSsoLoginUrl()
|
||||||
const callbackUrl = encodeURIComponent('http://localhost:4000/callback')
|
|
||||||
window.location.href = `http://localhost:5501/login?app_id=${appId}&app_url=${callbackUrl}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,13 +92,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Group gap="sm" style={{ cursor: 'pointer' }}>
|
<Group gap="sm" style={{ cursor: 'pointer' }}>
|
||||||
<Avatar color="brand" radius="xl" size="sm">
|
<Avatar color="brand" radius="xl" size="sm">
|
||||||
{user.name?.[0] || 'U'}
|
{user?.name?.[0] || 'U'}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="sm" fw={500}>{user.name}</Text>
|
<Text size="sm" fw={500}>{user?.name || '已登录用户'}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<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.Divider />
|
||||||
<Menu.Item leftSection={<IconLogout size={14} />} color="red" onClick={handleLogout}>
|
<Menu.Item leftSection={<IconLogout size={14} />} color="red" onClick={handleLogout}>
|
||||||
退出登录
|
退出登录
|
||||||
@@ -89,7 +113,9 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
<Stack maw={800} mx="auto" pt="xl">
|
<Stack maw={800} mx="auto" pt="xl">
|
||||||
<Text size="xl" fw={700}>登录成功!</Text>
|
<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">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} mt="md">
|
||||||
<Paper withBorder p="lg" radius="md">
|
<Paper withBorder p="lg" radius="md">
|
||||||
@@ -97,6 +123,7 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
<IconUser size={20} color="#0080FF" />
|
<IconUser size={20} color="#0080FF" />
|
||||||
<Text fw={600}>用户信息</Text>
|
<Text fw={600}>用户信息</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{user ? (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group>
|
<Group>
|
||||||
<Text size="sm" c="dimmed" w={80}>ID</Text>
|
<Text size="sm" c="dimmed" w={80}>ID</Text>
|
||||||
@@ -115,6 +142,11 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
<Text size="sm">{user.phone}</Text>
|
<Text size="sm">{user.phone}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
后端 access_token 接口未返回用户详情,暂无用户信息。
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper withBorder p="lg" radius="md">
|
<Paper withBorder p="lg" radius="md">
|
||||||
@@ -122,11 +154,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
<IconShield size={20} color="#20C997" />
|
<IconShield size={20} color="#20C997" />
|
||||||
<Text fw={600}>角色权限</Text>
|
<Text fw={600}>角色权限</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{user?.roles?.length ? (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{user.roles?.map((role: string) => (
|
{user.roles.map((role: string) => (
|
||||||
<Badge key={role} variant="light">{role}</Badge>
|
<Badge key={role} variant="light">{role}</Badge>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">暂无角色信息</Text>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
@@ -142,16 +178,16 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
|
|||||||
<strong>1.</strong> 您打开此应用(<Code>http://localhost:4000</Code>)
|
<strong>1.</strong> 您打开此应用(<Code>http://localhost:4000</Code>)
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">
|
<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>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<strong>3.</strong> 在 SSO 页面完成登录,SSO 返回 <Code>sso_code</Code>
|
<strong>3.</strong> 在 SSO 页面完成登录,SSO 返回 <Code>sso_code</Code>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">
|
<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>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<strong>5.</strong> 用户信息存入 Cookie,显示此页面
|
<strong>5.</strong> access_token 存入 httpOnly Cookie,即算登录成功;用户详情需后端 userinfo 接口提供
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,65 +1,138 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSO 回调接口
|
* 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) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { code } = await req.json()
|
const { code } = await req.json();
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return NextResponse.json({ error: '缺少 code 参数' }, { status: 400 })
|
return NextResponse.json({ error: "缺少 code 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用 Mock SSO 的验证接口
|
const clientId = process.env.NEXT_PUBLIC_SSO_APP_ID;
|
||||||
const ssoUrl = process.env.SSO_URL || 'http://localhost:5501'
|
const clientSecret = process.env.SSO_CLIENT_SECRET;
|
||||||
const verifyRes = await fetch(`${ssoUrl}/api/auth/verify`, {
|
const tenant = process.env.SSO_TENANT || "cowarobot";
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ code }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!verifyRes.ok) {
|
if (!clientId || !clientSecret) {
|
||||||
const errData = await verifyRes.json().catch(() => ({}))
|
console.error(
|
||||||
|
"[external-app] 缺少 NEXT_PUBLIC_SSO_APP_ID 或 SSO_CLIENT_SECRET",
|
||||||
|
);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: errData.error || 'SSO 验证失败' },
|
{ error: "服务端 SSO 配置缺失" },
|
||||||
{ status: 401 }
|
{ status: 500 },
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ssoData = await verifyRes.json()
|
// 用授权 code 换 access_token(标准 OAuth2)
|
||||||
const { token, refresh_token, user, permissions } = ssoData
|
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 data = await tokenRes.json().catch(() => ({}));
|
||||||
const response = NextResponse.json({ success: true })
|
console.log(
|
||||||
|
"[external-app] access_token 响应 status:",
|
||||||
|
tokenRes.status,
|
||||||
|
"| body:",
|
||||||
|
JSON.stringify(data).slice(0, 500),
|
||||||
|
);
|
||||||
|
|
||||||
response.cookies.set('user', JSON.stringify(user), {
|
if (!tokenRes.ok) {
|
||||||
httpOnly: false, // 客户端需要读取用于展示
|
const message = data?.message || data?.msg || "换 token 失败";
|
||||||
path: '/',
|
console.error(
|
||||||
maxAge: 86400,
|
"[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,
|
httpOnly: true,
|
||||||
path: '/',
|
path: "/",
|
||||||
maxAge: 86400,
|
maxAge: 86400,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (refresh_token) {
|
if (refreshToken) {
|
||||||
response.cookies.set('refresh_token', refresh_token, {
|
response.cookies.set("refresh_token", refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: '/',
|
path: "/",
|
||||||
maxAge: 86400 * 7,
|
maxAge: 86400 * 7,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
if (user) {
|
||||||
|
response.cookies.set("user", JSON.stringify(user), {
|
||||||
|
httpOnly: false, // 客户端需要读取用于展示
|
||||||
|
path: "/",
|
||||||
|
maxAge: 86400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.error("[external-app] callback error:", err?.message);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: err?.message || '回调处理失败' },
|
{ error: err?.message || "回调处理失败" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { Stack, Text, Loader } from '@mantine/core'
|
import { Stack, Text, Loader } from '@mantine/core'
|
||||||
|
|
||||||
|
const SSO_STATE_STORAGE_KEY = 'sso_login_state'
|
||||||
|
|
||||||
function CallbackContent() {
|
function CallbackContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const callbackStarted = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (callbackStarted.current) return
|
||||||
|
|
||||||
const code = searchParams.get('sso_code')
|
const code = searchParams.get('sso_code')
|
||||||
|
const state = searchParams.get('state')
|
||||||
|
const expectedState = window.sessionStorage.getItem(SSO_STATE_STORAGE_KEY)
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setError('缺少 sso_code 参数')
|
setError('缺少 sso_code 参数')
|
||||||
return
|
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 换取用户信息
|
// 用 code 调用本应用的 /api/callback 换取用户信息
|
||||||
fetch('/api/callback', {
|
fetch('/api/callback', {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import DashboardPage from './DashboardPage'
|
|||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
// 登录态以 token 为准:后端 access_token 接口只返回 token(不含用户信息),
|
||||||
|
// 拿到 access_token 即算登录成功;user 信息可选(后端暂未提供 userinfo)。
|
||||||
|
const token = cookieStore.get('token')?.value
|
||||||
const userCookie = cookieStore.get('user')?.value
|
const userCookie = cookieStore.get('user')?.value
|
||||||
|
|
||||||
let user = null
|
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