fix(env): fix env

This commit is contained in:
zhangheng
2026-07-14 15:21:28 +08:00
parent d0234effd6
commit c6ab525140
8 changed files with 269 additions and 109 deletions

View File

@@ -1,8 +1,17 @@
# Mock SSO 服务地址(服务端用于 code 换取用户信息
SSO_URL="http://localhost:5501"
# sso-portal 登录页地址(客户端跳转用,即 OAuth2 授权端点入口
NEXT_PUBLIC_SSO_URL="https://sso.softtest.cowarobot.com"
# 本应用地址(作为 SSO 回调 app_url
# 本应用地址(作为 SSO 回调 client_redirect_uri
NEXT_PUBLIC_APP_URL="http://localhost:4000"
# 注册在 SSO 的应用 IDapp_id
SSO_APP_ID="app_demo_001"
# OAuth2 client_id同时作为 URL app_id 传给 sso-portal非敏感可下发前端
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"

View File

@@ -1,8 +1,17 @@
# Mock SSO 服务地址(集群内 svc 名,按实际部署填写
SSO_URL="http://sso-mock-svc:5501"
# sso-portal 登录页地址(按实际部署填写,客户端跳转用
NEXT_PUBLIC_SSO_URL="http://sso-portal-svc:5501"
# 本应用地址(作为 SSO 回调 app_url,按实际部署填写)
# 本应用地址(作为 SSO 回调 client_redirect_uri,按实际部署填写)
NEXT_PUBLIC_APP_URL=""
# 注册在 SSO 的应用 IDapp_id
SSO_APP_ID="app_demo_001"
# OAuth2 client_id同时作为 URL app_id 传给 sso-portal
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"

View File

@@ -1,8 +1,17 @@
# Mock SSO 服务地址(服务端用于 code 换取用户信息
SSO_URL="http://localhost:5501"
# sso-portal 登录页地址(客户端跳转用,即 OAuth2 授权端点入口
NEXT_PUBLIC_SSO_URL="http://localhost:5501"
# 本应用地址(作为 SSO 回调 app_url
# 本应用地址(作为 SSO 回调 client_redirect_uri
NEXT_PUBLIC_APP_URL="http://localhost:4000"
# 注册在 SSO 的应用 IDapp_id
SSO_APP_ID="app_demo_001"
# OAuth2 client_id同时作为 URL app_id 传给 sso-portal非敏感可下发前端
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"

View File

@@ -1,6 +1,6 @@
# External AppSSO 接入示例)
基于 Next.js 的示例应用,演示如何接入 Mock SSO:跳转到 sso-mock 登录 → 在 `/callback` 收到一次性 `sso_code` → 用 code 换取用户信息并写入 Cookie 建立会话。
基于 Next.js 的示例应用,演示如何接入真实 SSOOAuth2 授权码流程):跳转到 sso-portal 登录 → 在 `/callback` 收到一次性 `sso_code`服务端用 code 向后端换 `access_token` 并写入 Cookie 建立会话。
## 快速开始
@@ -9,7 +9,9 @@ pnpm install
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_APP_URL` | 客户端 | 本应用地址,作为 SSO 回调 `app_url` 传给 sso-mock | `http://localhost:4000` |
| `SSO_APP_ID` | 服务端 | 注册在 SSO 的应用 ID`app_id` | `app_demo_001` |
| `NEXT_PUBLIC_SSO_URL` | 客户端 | sso-portal 登录页地址,跳转 `/login` | dev: `http://localhost:5501`<br>prod: `http://sso-portal-svc:5501` |
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 SSO 回调 `client_redirect_uri` 传给 sso-portal | `http://localhost:4000` |
| `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 接入流程
1. 应用跳转到 sso-mock`{SSO_URL}/login?app_id={SSO_APP_ID}&app_url={NEXT_PUBLIC_APP_URL}/callback`
2. 用户在 sso-mock 登录(账号 / 手机 / 飞书
3. sso-mock 回跳:`{app_url}?sso_code=xxxxx`
4. 本应用 `/callback` 页把 `sso_code` POST 给 `/api/callback` → 服务端调 sso-mock `/api/auth/verify` `{ user, token }` → 写入 Cookie
1. 应用跳转到 sso-portal`{NEXT_PUBLIC_SSO_URL}/login?app_id={NEXT_PUBLIC_SSO_APP_ID}&app_url={NEXT_PUBLIC_APP_URL}/callback`
2. 用户在 sso-portal 登录(账号 / 手机)
3. sso-portal 回跳:`{app_url}?sso_code=xxxxx`
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 |

View File

@@ -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 的登录 URLOAuth2app_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>

View File

@@ -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 仅在服务端 envSSO_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_infobasis 习惯用 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 },
);
}
}

View File

@@ -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', {

View File

@@ -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} />
}