diff --git a/.env.development b/.env.development index 7f661e7..e413d1c 100644 --- a/.env.development +++ b/.env.development @@ -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 的应用 ID(app_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" diff --git a/.env.production b/.env.production index 960f826..429a68f 100644 --- a/.env.production +++ b/.env.production @@ -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 的应用 ID(app_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" diff --git a/.env.staging b/.env.staging index 7f661e7..7997432 100644 --- a/.env.staging +++ b/.env.staging @@ -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 的应用 ID(app_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" diff --git a/README.md b/README.md index d0cda92..7143652 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 ``` -> 需同时启动 [`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`
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`
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`
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 | diff --git a/app/DashboardPage.tsx b/app/DashboardPage.tsx index eb03177..911b16b 100644 --- a/app/DashboardPage.tsx +++ b/app/DashboardPage.tsx @@ -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 ( 正在跳转到 SSO 登录页面... @@ -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 }) { - {user.name?.[0] || 'U'} + {user?.name?.[0] || 'U'} - {user.name} + {user?.name || '已登录用户'} - }>{user.account} + }> + {user?.account || '(未获取到账号)'} + } color="red" onClick={handleLogout}> 退出登录 @@ -89,7 +113,9 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) { 登录成功! - 您已通过 Mock SSO 认证登录到此应用。 + + 您已通过 SSO 认证登录到此应用(OAuth2 授权码流程,已换取 access_token)。 + @@ -97,24 +123,30 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) { 用户信息 - - - ID - {user.id} - - - 账号 - {user.account} - - - 姓名 - {user.name} - - - 手机 - {user.phone} - - + {user ? ( + + + ID + {user.id} + + + 账号 + {user.account} + + + 姓名 + {user.name} + + + 手机 + {user.phone} + + + ) : ( + + 后端 access_token 接口未返回用户详情,暂无用户信息。 + + )} @@ -122,11 +154,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) { 角色权限 - - {user.roles?.map((role: string) => ( - {role} - ))} - + {user?.roles?.length ? ( + + {user.roles.map((role: string) => ( + {role} + ))} + + ) : ( + 暂无角色信息 + )} @@ -142,16 +178,16 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) { 1. 您打开此应用(http://localhost:4000 - 2. 检测到未登录,自动跳转到 Mock SSO(http://localhost:5501/login) + 2. 检测到未登录,自动跳转到 SSO(http://localhost:5501/login 3. 在 SSO 页面完成登录,SSO 返回 sso_code - 4. 此应用用 sso_code 调用 POST /api/auth/verify 换取用户信息 + 4. 此应用用 sso_code 调用后端 /api/v1/basis/sso/access_token 换取 access_token - 5. 用户信息存入 Cookie,显示此页面 + 5. access_token 存入 httpOnly Cookie,即算登录成功;用户详情需后端 userinfo 接口提供 diff --git a/app/api/callback/route.ts b/app/api/callback/route.ts index 8aa8a4e..ab9aa86 100644 --- a/app/api/callback/route.ts +++ b/app/api/callback/route.ts @@ -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 }, + ); } } diff --git a/app/callback/page.tsx b/app/callback/page.tsx index 068ff19..7865b9b 100644 --- a/app/callback/page.tsx +++ b/app/callback/page.tsx @@ -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(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', { diff --git a/app/page.tsx b/app/page.tsx index 54ca5aa..98be090 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 + return }