Files
iam-service/docs/SSO_INTEGRATION.md
2026-02-03 17:31:08 +08:00

166 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 业务服务接入指引SSO 授权码模式)
本指引描述业务服务(如 CMS/TMS如何接入统一登录页iam-front与 IAMiam-service实现单点登录Authorization Code → Token
## 1. 关键约定
- 登录页:`{IAM_FRONT_BASE_URL}/login?clientId={clientId}&tenantId={tenantId}&callback={encodeURIComponent(redirectUri)}`
- 授权码codeJWTHS256有效期 5 分钟Redis 单次使用
- 换取 token业务服务端携带 `clientSecret` 调用
- `POST {IAM_SERVICE_BASE_URL}/api/v1/auth/code2token`(外部第三方:必须携带 tenant_id 并校验 clientId/clientSecret
- `POST {IAM_SERVICE_BASE_URL}/api/v1/internal/auth/code2token`(内部服务:需 `X-Internal-Token` 预共享密钥,不强制 tenant_id
- 签发授权码:由 IAM 服务端完成(校验 redirectUri 是否在该 clientId 的 allowlist 中)
- `POST {IAM_SERVICE_BASE_URL}/api/v1/auth/login-code`
- token 刷新:`POST {IAM_SERVICE_BASE_URL}/api/v1/auth/refresh`
- 退出:`POST {IAM_SERVICE_BASE_URL}/api/v1/auth/logout`
## 2. 业务 Next.js前端接入中间件跳转登录
### 2.1 最小示例:缺少 token 则 302 去登录
在业务项目根目录创建 `/src/middleware.ts`
```ts
import { NextRequest, NextResponse } from "next/server";
function isExpired(jwt: string): boolean {
const parts = jwt.split(".");
if (parts.length < 2) return true;
const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (normalized.length % 4)) % 4;
const json = atob(normalized + "=".repeat(padLen));
const payload = JSON.parse(json) as { exp?: number };
if (!payload.exp) return true;
return Math.floor(Date.now() / 1000) >= payload.exp;
}
export function middleware(req: NextRequest) {
const tenantId = req.headers.get("x-tenant-id") ?? "";
const accessToken = req.cookies.get("accessToken")?.value ?? "";
if (!accessToken || isExpired(accessToken)) {
const currentUrl = req.nextUrl.clone();
const callback = `${process.env.CMS_SERVICE_BASE_URL}/auth/callback?next=${encodeURIComponent(
currentUrl.toString(),
)}`;
const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent(
process.env.CMS_CLIENT_ID ?? "",
)}&tenantId=${encodeURIComponent(
tenantId,
)}&callback=${encodeURIComponent(callback)}`;
return NextResponse.redirect(loginUrl, 302);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/public).*)"],
};
```
说明:
- 示例使用 cookie `accessToken`;你也可以改为本地存储或自定义 header。
- 校验是否过期仅做 payload 解析(不验签);服务端真正鉴权仍应使用 `auth-kit` + RS256 验签。
## 3. 业务 Axum后端接入code → token服务端通道
### 3.1 业务后端回调接口(接收 code
用户从 iam-front 回跳到 `callbackUrl?code=...` 后,建议业务服务由后端处理 code 换 token并以 HttpOnly Cookie 存储 refresh token若换取失败可重定向到业务前端的错误页`/auth-error`)给出提示。
redirectUri allowlist 建议配置为“固定回调地址scheme+host+port+path不要把 `next=...` 这类动态参数写进 allowlist例如
-`http://localhost:5031/auth/callback`
-`http://localhost:5031/auth/callback?next=http%3A%2F%2Flocalhost%3A6031%2F`
```rust
use axum::{extract::Query, response::Redirect, routing::get, Router};
use serde::Deserialize;
#[derive(Deserialize)]
struct CallbackQuery {
code: String,
}
async fn sso_callback(Query(q): Query<CallbackQuery>) -> Redirect {
let iam_base = std::env::var("IAM_SERVICE_BASE_URL").unwrap();
let client_id = std::env::var("IAM_CLIENT_ID").unwrap();
let client_secret = std::env::var("IAM_CLIENT_SECRET").unwrap();
let http = reqwest::Client::new();
let resp = http
.post(format!("{}/api/v1/auth/code2token", iam_base.trim_end_matches('/')))
.json(&serde_json::json!({
"code": q.code,
"clientId": client_id,
"clientSecret": client_secret
}))
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap();
let data = &resp["data"];
let access_token = data["accessToken"].as_str().unwrap_or_default();
let refresh_token = data["refreshToken"].as_str().unwrap_or_default();
let target = format!("/?loggedIn=1");
let mut r = Redirect::temporary(&target).into_response();
r.headers_mut().append(
axum::http::header::SET_COOKIE,
format!("accessToken={}; Path=/; Secure; SameSite=Strict", access_token)
.parse()
.unwrap(),
);
r.headers_mut().append(
axum::http::header::SET_COOKIE,
format!("refreshToken={}; HttpOnly; Path=/; Secure; SameSite=Strict", refresh_token)
.parse()
.unwrap(),
);
r
}
pub fn router() -> Router {
Router::new().route("/auth/callback", get(sso_callback))
}
```
### 3.2 后端鉴权与自动刷新
推荐:
- 业务后端对受保护接口强制 `Authorization: Bearer <access_token>`(来自 cookie 或前端 header
- 过期时由业务后端调用 `POST /api/v1/auth/refresh`,轮换 refresh token并回写 cookie。
## 4. clientId / clientSecret 管理
由平台管理员通过 IAM 平台接口创建与轮换(仅示例,实际需要具备平台权限码 `iam:client:*`
```bash
curl -X POST "$IAM_SERVICE_BASE_URL/api/v1/platform/clients" \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "clientId": "cms", "name": "CMS", "redirectUris": ["https://cms-api.example.com/auth/callback"] }'
```
轮换:
```bash
curl -X POST "$IAM_SERVICE_BASE_URL/api/v1/platform/clients/cms/rotate-secret" \
-H "Authorization: Bearer $PLATFORM_TOKEN"
```
更新允许回调地址redirectUris
```bash
curl -X PUT "$IAM_SERVICE_BASE_URL/api/v1/platform/clients/cms/redirect-uris" \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "redirectUris": ["https://cms-api.example.com/auth/callback"] }'
```