feat(callback): add callback

This commit is contained in:
2026-02-03 10:17:11 +08:00
parent 27a6791591
commit 202b5eaad5
27 changed files with 1806 additions and 124 deletions

View File

@@ -19,13 +19,20 @@
### 文档
- `GET /scalar`Scalar UI
- SSO 授权码接入:`docs/SSO_INTEGRATION.md`
### Auth公开
- `POST /tenants/register`:创建租户(初始租户管理员账号由后续 `/auth/register` + 首用户 bootstrap 完成)
- `POST /auth/register`:用户注册(需要 `X-Tenant-ID`
- `POST /auth/login`:用户登录(需要 `X-Tenant-ID`
- `POST /auth/login-code`用户名密码签发一次性授权码SSO需要 `X-Tenant-ID`,并校验 redirectUri allowlist
- `POST /auth/refresh`:刷新 access tokenrefresh token 一次性轮换)
- `POST /auth/code2token`:授权码换取 tokenSSO
### Auth需认证
- `POST /auth/logout`:退出登录(吊销 refresh token
### Tenant需认证 + 权限)
@@ -73,6 +80,10 @@
- `GET /platform/tenants/{tenant_id}/enabled-apps`
- `PUT /platform/tenants/{tenant_id}/enabled-apps`
- `GET /platform/clients`
- `POST /platform/clients`
- `PUT /platform/clients/{client_id}/redirect-uris`
- `POST /platform/clients/{client_id}/rotate-secret`
- `GET /platform/apps`
- `POST /platform/apps`
- `GET /platform/apps/{app_id}`
@@ -112,4 +123,3 @@ flowchart TD
- `authenticate`(解析 token 并注入 user/tenant 字段到 span
- `resolve_tenant`(统一 TenantId 注入,并校验 X-Tenant-ID 与 token tenant 一致性)
- 权限校验禁止在业务侧实现一套 RBAC 聚合逻辑;应通过 `POST /authorize/check` 由 IAM 统一裁决。

162
docs/SSO_INTEGRATION.md Normal file
View File

@@ -0,0 +1,162 @@
# 业务服务接入指引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}/iam/api/v1/auth/code2token`
- 签发授权码:由 IAM 服务端完成(校验 redirectUri 是否在该 clientId 的 allowlist 中)
- `POST {IAM_SERVICE_BASE_URL}/iam/api/v1/auth/login-code`
- token 刷新:`POST {IAM_SERVICE_BASE_URL}/iam/api/v1/auth/refresh`
- 退出:`POST {IAM_SERVICE_BASE_URL}/iam/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!("{}/iam/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 /iam/api/v1/auth/refresh`,轮换 refresh token并回写 cookie。
## 4. clientId / clientSecret 管理
由平台管理员通过 IAM 平台接口创建与轮换(仅示例,实际需要具备平台权限码 `iam:client:*`
```bash
curl -X POST "$IAM_SERVICE_BASE_URL/iam/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/iam/api/v1/platform/clients/cms/rotate-secret" \
-H "Authorization: Bearer $PLATFORM_TOKEN"
```
更新允许回调地址redirectUris
```bash
curl -X PUT "$IAM_SERVICE_BASE_URL/iam/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"] }'
```

View File

@@ -20,14 +20,21 @@
`shay7sev@gmail.com`
`tenantdev1`
```json
{
"clientId": "cms",
"clientSecret": "2adbc0d720b687a6d05df32942c2919b0adcbd579c23ecd9cbb27f7a7a7e3326"
}
```
```json
{
"code": 0,
"message": "Success",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwMTQxNTIsImlhdCI6MTc3MDAxMzI1MiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnBhc3N3b3JkOnJlc2V0OmFueSIsInVzZXI6cmVhZCIsInVzZXI6d3JpdGUiXSwiYXBwcyI6WyJjbXMiXSwiYXBwc192ZXJzaW9uIjoxfQ.Myh55kW5xknQNvECzz4U3Ojq-T-gmulaJJAkpa92gF66CDbN9lCLlK0hZOAbzpsABSOBtH0VKFCZJ0rfX1PamWxyp0nl3SBHiQMR5owy0dMqJsA8UDvaibL_-MxXRQgIL3Bpm3nd1l6GtrnnRXL4qBy5UleD_d89RqUPhF0FV34T-RwSHYSPs_0h33DNI4gD564Jjkn6-t6Z4CpR34OnqeMRuR6OugZzyoa1w0D2xzC6qohfwyIQMffP99OejvJUPis36vcvxOY3SILXwdooFc_CLT0glx2IRoeerJJpoQF40Dz3lWAhBI5-4CfORztwPfxuC441uzA3PaR2DqI-kw",
"refresh_token": "d19095aca07c32fb8e660b93a2cc5c0660e78ae490dac3a56b93f755761b5e50",
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwMjU5OTQsImlhdCI6MTc3MDAyNTA5NCwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.NNfdO14PRxkLa5Kkiz5SZ0tDnbrXvVTgOsU65Xg8jSbowrRsbdO3N_fBpEaSxJ3n2DhtD0uYZyRABuCBVWCncxk0RDUWXhoHVXucEFA1Br6I4niZTfIbnv-L1M-Q1fNvPGZE2DQ8Os9K5b2F91kpcwkaR-vQgE9oyFeq1xhQ-MR7YeQLXgLk9UQpWyD2Yj3VIWyFYiG94JX9eI6iJsOJZayqSXaeid50c5R4Z9lq9SQ07ZmFTqZFitCrPrQRY_wh6OeeQrHF33HMKC3yQ1jq4XyiNlDIzLIzDerUpK5UtLdz9Cntt31yg-2tsj2nSMUZLssllMZZaPjFUTMFeu0egQ",
"refresh_token": "bd60d869926bac781dd04ad4b340f79624c4da35373c85865bd4627093714e2e",
"token_type": "Bearer",
"expires_in": 900
}
@@ -51,5 +58,4 @@
```
```text
permission项你是推荐数据库迁移的方式新增吗permission项的管理的最佳方式是什么
```