166 lines
6.1 KiB
Markdown
166 lines
6.1 KiB
Markdown
# 业务服务接入指引(SSO 授权码模式)
|
||
|
||
本指引描述业务服务(如 CMS/TMS)如何接入统一登录页(iam-front)与 IAM(iam-service),实现单点登录(Authorization Code → Token)。
|
||
|
||
## 1. 关键约定
|
||
|
||
- 登录页:`{IAM_FRONT_BASE_URL}/login?clientId={clientId}&tenantId={tenantId}&callback={encodeURIComponent(redirectUri)}`
|
||
- 授权码(code):JWT(HS256),有效期 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"] }'
|
||
```
|