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

@@ -5,10 +5,32 @@ LOG_DIR=./log
LOG_FILE_NAME=iam.log
DATABASE_URL=postgres://iam_service_user:iam_service_password@localhost:5432/iam_service_db
REDIS_URL=redis://localhost:6379/0
# JWT_SECRET服务端私密随机串不是 RS256 私钥)
# 作用:作为 refresh token 指纹HMACpepper用于 refresh_tokens.token_fingerprint 计算与校验。
# 要求:生产环境必须使用高强度随机值,定期轮换会导致现有 refresh token 全部失效(通常是可接受的)。
# openssl rand -base64 64
JWT_SECRET=please_replace_with_a_secure_random_string
# AUTH_CODE_JWT_SECRET授权码codeJWT 的对称签名密钥HS256
# 作用iam-service 用它签发/验签 5 分钟一次性 code/auth/login-code 与 /auth/code2token
# 要求:生产环境必须使用高强度随机值并妥善保管。
AUTH_CODE_JWT_SECRET=please_replace_with_a_secure_random_string
# CLIENT_SECRET_PREV_TTL_DAYSclientSecret 轮换后的旧密钥宽限期(天)。
# 作用:允许业务方平滑切换新密钥,宽限期内新旧 clientSecret 都可用于 /auth/code2token。
CLIENT_SECRET_PREV_TTL_DAYS=7
# JWT_KEY_IDRS256 key idkid会出现在 JWT header 与 JWKS 中,用于多 Key 管理与轮换。
JWT_KEY_ID=default
# JWT_PRIVATE_KEY_PEM / JWT_PUBLIC_KEY_PEMRS256 私钥/公钥PEM 文本)。
# 作用:签发 access token私钥与提供 JWKS公钥业务服务通常使用 JWKS 验签。
JWT_PRIVATE_KEY_PEM=
JWT_PUBLIC_KEY_PEM=
# JWT_JWKS_EXTRA_KEYS_JSON可选额外 JWKS keysJSON 数组),用于灰度轮换/多公钥共存。
JWT_JWKS_EXTRA_KEYS_JSON=
PORT=3000

124
Cargo.lock generated
View File

@@ -32,6 +32,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arc-swap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e"
dependencies = [
"rustversion",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -174,6 +183,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "backon"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
dependencies = [
"fastrand",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -277,6 +295,20 @@ dependencies = [
"cc",
]
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"futures-core",
"memchr",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
name = "common-telemetry"
version = "0.1.5"
@@ -664,6 +696,21 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -708,6 +755,17 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -732,8 +790,10 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -1013,7 +1073,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.2",
"tokio",
"tower-service",
"tracing",
@@ -1039,7 +1099,9 @@ dependencies = [
"http",
"ipnet",
"jsonwebtoken",
"percent-encoding",
"rand 0.9.2",
"redis",
"rsa",
"serde",
"serde_json",
@@ -1051,6 +1113,7 @@ dependencies = [
"tower_governor",
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"utoipa-scalar",
"uuid",
@@ -1210,6 +1273,15 @@ dependencies = [
"serde",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -1787,7 +1859,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -1824,7 +1896,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -1912,6 +1984,32 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redis"
version = "0.27.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc"
dependencies = [
"arc-swap",
"async-trait",
"backon",
"bytes",
"combine",
"futures",
"futures-util",
"itertools",
"itoa",
"num-bigint",
"percent-encoding",
"pin-project-lite",
"ryu",
"sha1_smol",
"socket2 0.5.10",
"tokio",
"tokio-util",
"url",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2257,6 +2355,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2330,6 +2434,16 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.2"
@@ -2758,7 +2872,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -2858,7 +2972,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"socket2",
"socket2 0.6.2",
"sync_wrapper",
"tokio",
"tokio-stream",

View File

@@ -56,6 +56,7 @@ rsa = "0.9.10"
uuid = { version = "1", features = ["v4", "serde"] }
hmac = "0.12.1"
sha2 = "0.10.9"
redis = { version = "0.27.6", features = ["tokio-comp", "connection-manager"] }
# API 文档 (OpenAPI/Scalar)
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
@@ -68,6 +69,8 @@ tower_governor = { version = "0.8.0", features = ["axum"] }
# 时间处理
chrono = "0.4.43"
url = "2.5.4"
percent-encoding = "2.3.2"
[dev-dependencies]
# 测试工具

211
LOCAL_E2E_RUNBOOK.md Normal file
View File

@@ -0,0 +1,211 @@
# 本地端到端联调iam-service + cms-service
目标:
- 生成临时 RSA 公私钥供 iam-service 使用RS256
- 启动 iam-service签发 token、发布 JWKS、提供权限裁决 `/authorize/check`
- 启动 cms-service本地验签 + 调用 IAM 做权限裁决)
- 在两个服务的 Scalar 页面完成:创建租户 → 注册用户 → 登录 → 调用 CMS 资源接口,验证“认证/权限由 IAM 控制”
> 本文不要求修改任何业务代码;只需要本地导出环境变量或创建 `.env` 文件。
## 0. 前置条件
- Rust支持 edition 2024与 Cargo
- PostgreSQL本机或 Docker并可使用 `psql`
- `openssl`
端口默认:
- iam-service3000
- cms-service3100
## 1. 准备数据库推荐Docker 一键起 PostgreSQL
启动 PostgreSQL
```bash
docker run --rm -d --name local-pg \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:16
```
创建 iam/cms 两个数据库与用户(示例账号,可按需调整):
```bash
docker exec -i local-pg psql -U postgres <<'SQL'
CREATE USER iam_service_user WITH PASSWORD 'iam_service_password';
CREATE DATABASE iam_service_db OWNER iam_service_user;
GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user;
CREATE USER cms_service_user WITH PASSWORD 'cms_service_password';
CREATE DATABASE cms_service_db OWNER cms_service_user;
GRANT ALL PRIVILEGES ON DATABASE cms_service_db TO cms_service_user;
SQL
```
准备连接串(后续会分别用于两服务):
- IAM`postgres://iam_service_user:iam_service_password@127.0.0.1:5432/iam_service_db`
- CMS`postgres://cms_service_user:cms_service_password@127.0.0.1:5432/cms_service_db`
## 2. 生成临时 RSA 公私钥RS256
```bash
KEY_DIR="$(mktemp -d)"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$KEY_DIR/jwt_private_key.pem"
openssl pkey -in "$KEY_DIR/jwt_private_key.pem" -pubout -out "$KEY_DIR/jwt_public_key.pem"
chmod 600 "$KEY_DIR/jwt_private_key.pem"
chmod 644 "$KEY_DIR/jwt_public_key.pem"
echo "KEY_DIR=$KEY_DIR"
```
说明:
- 私钥PKCS#8 PEM`BEGIN PRIVATE KEY`
- 公钥PKCS#8 公钥 PEM`BEGIN PUBLIC KEY`
- 该目录是临时目录,重启/清理后会消失;适合本地联调
## 3. 启动 iam-service签发 token + JWKS + 权限裁决)
在一个终端中执行(建议在 `iam-service` 目录下):
```bash
cd /home/shay/project/backend/iam-service
export PORT=3000
export DATABASE_URL='postgres://iam_service_user:iam_service_password@127.0.0.1:5432/iam_service_db'
# 用于 refresh token 指纹(不是 JWT 签名密钥),本地随便填一个即可
export JWT_SECRET='local-dev-refresh-token-pepper'
export JWT_KEY_ID='local-dev'
export JWT_PRIVATE_KEY_PEM="$(cat "$KEY_DIR/jwt_private_key.pem")"
export JWT_PUBLIC_KEY_PEM="$(cat "$KEY_DIR/jwt_public_key.pem")"
# 初始化/重建 DB会清库重建适合开发环境
BACKUP=0 ./scripts/db/rebuild_iam_db.sh
cargo run
```
启动后:
- IAM Scalar`http://127.0.0.1:3000/scalar`
- JWKS`http://127.0.0.1:3000/.well-known/jwks.json`
## 4. 启动 cms-service本地验签 + 调 IAM 裁权)
在另一个终端中执行(建议在 `cms-service` 目录下):
```bash
cd /home/shay/project/backend/cms-service
export PORT=3100
export DATABASE_URL='postgres://cms_service_user:cms_service_password@127.0.0.1:5432/cms_service_db'
export IAM_BASE_URL='http://127.0.0.1:3000'
# CMS 默认会走 IAM_BASE_URL + /.well-known/jwks.json 拉取公钥
# 如需显式指定:
# export IAM_JWKS_URL='http://127.0.0.1:3000/.well-known/jwks.json'
# 初始化/重建 DB会回滚并重建 cms schema适合开发环境
./scripts/db/rebuild_cms_db.sh
cargo run
```
启动后:
- CMS Scalar`http://127.0.0.1:3100/scalar`
## 5. 在 Scalar 中跑通“租户 → 注册 → 登录 → 调用 CMS 接口”
### 5.1 在 IAM Scalar 创建租户
打开 `http://127.0.0.1:3000/scalar`
1) 调用 `POST /tenants/register`
示例 body
```json
{ "name": "Tenant A" }
```
从响应中拿到 `tenant_id`UUID
### 5.2 在 IAM Scalar 注册首个用户(自动 Admin + CMS 权限)
调用 `POST /auth/register`
- Header`X-Tenant-ID: <tenant_id>`
- Body
```json
{ "email": "admin@example.com", "password": "Passw0rd!" }
```
重要说明:
- 该租户的**首个用户**会自动触发 bootstrap创建/获取 `Admin` 角色,并授予该租户下的全量非 `iam:*` 权限(包含 `cms:*` 权限),再绑定给该用户。
### 5.3 在 IAM Scalar 登录拿 token
调用 `POST /auth/login`
- Header`X-Tenant-ID: <tenant_id>`
- Body
```json
{ "email": "admin@example.com", "password": "Passw0rd!" }
```
保存响应中的:
- `access_token`
- `refresh_token`(可选,用于测试刷新)
### 5.4 在 CMS Scalar 调用资源接口(验证由 IAM 裁权)
打开 `http://127.0.0.1:3100/scalar`,选择任意需要权限的接口,例如:
- `POST /v1/columns`(需要 `cms:column:write`
在请求中设置:
- `Authorization: Bearer <access_token>`
- `X-Tenant-ID: <tenant_id>`
若一切正常:
- CMS 会先本地验签 tokenauth-kit
- 然后调用 IAM 的 `POST /authorize/check` 判断权限
- IAM 返回 allowed 后CMS 才会执行业务写入
验证建议:
- 观察 iam-service 控制台日志:应能看到 `/authorize/check` 的请求
- 如将 `access_token` 改成无效字符串CMS 会直接返回 401本地验签失败不会去请求 IAM
## 6. 常见问题(本地联调)
### 6.1 CMS 返回 401 MissingAuthHeader
- 检查是否在 CMS 请求里设置了 `Authorization: Bearer <access_token>`
- 检查 token 是否复制完整(不要带引号/空格)
### 6.2 CMS 返回 400 Missing X-Tenant-ID header
- CMS 需要 `X-Tenant-ID`
- 若同时提供 token + header但 tenant_id 不一致会返回 403tenant:mismatch
### 6.3 CMS 返回 403 PermissionDenied
- 表示 IAM 裁权拒绝(例如用户没有 `cms:*` 权限)
- 你可以在 IAM 中调用 `GET /me/permissions` 查看当前用户权限

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项的管理的最佳方式是什么
```

View File

@@ -0,0 +1,28 @@
BEGIN;
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255),
secret_hash VARCHAR(255) NOT NULL,
prev_secret_hash VARCHAR(255),
prev_expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_updated_at ON oauth_clients(updated_at);
INSERT INTO permissions (code, description, resource, action) VALUES
('iam:client:read', 'List OAuth clients', 'client', 'read'),
('iam:client:write', 'Create/Rotate OAuth clients', 'client', 'write')
ON CONFLICT (code) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.code IN ('iam:client:read', 'iam:client:write')
WHERE r.is_system = TRUE
ON CONFLICT DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS redirect_uris JSONB NOT NULL DEFAULT '[]'::jsonb;
COMMIT;

View File

@@ -0,0 +1,9 @@
BEGIN;
DELETE FROM permissions
WHERE code IN ('iam:client:read', 'iam:client:write');
DROP TABLE IF EXISTS oauth_clients;
COMMIT;

View File

@@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE oauth_clients
DROP COLUMN IF EXISTS redirect_uris;
COMMIT;

View File

@@ -0,0 +1,15 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'oauth_clients'
) THEN
RAISE EXCEPTION 'missing oauth_clients table';
END IF;
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'iam:client:write') THEN
RAISE EXCEPTION 'missing iam client permissions seed';
END IF;
END $$;

View File

@@ -0,0 +1,8 @@
BEGIN;
SELECT redirect_uris
FROM oauth_clients
LIMIT 1;
ROLLBACK;

View File

@@ -8,9 +8,12 @@ pub struct AppConfig {
pub log_dir: String,
pub log_file_name: String,
pub database_url: String,
pub redis_url: String,
pub db_max_connections: u32,
pub db_min_connections: u32,
pub jwt_secret: String,
pub auth_code_jwt_secret: String,
pub client_secret_prev_ttl_days: u32,
pub port: u16,
}
@@ -25,9 +28,16 @@ impl AppConfig {
log_dir: env::var("LOG_DIR").unwrap_or_else(|_| "./log".into()),
log_file_name: env::var("LOG_FILE_NAME").unwrap_or_else(|_| "iam.log".into()),
database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL environment variable is required")?,
redis_url: env::var("REDIS_URL").map_err(|_| "REDIS_URL environment variable is required")?,
db_max_connections: env::var("DB_MAX_CONNECTIONS").unwrap_or("20".into()).parse().map_err(|_| "DB_MAX_CONNECTIONS must be a number")?,
db_min_connections: env::var("DB_MIN_CONNECTIONS").unwrap_or("5".into()).parse().map_err(|_| "DB_MIN_CONNECTIONS must be a number")?,
jwt_secret: env::var("JWT_SECRET").map_err(|_| "JWT_SECRET environment variable is required")?,
auth_code_jwt_secret: env::var("AUTH_CODE_JWT_SECRET")
.map_err(|_| "AUTH_CODE_JWT_SECRET environment variable is required")?,
client_secret_prev_ttl_days: env::var("CLIENT_SECRET_PREV_TTL_DAYS")
.unwrap_or_else(|_| "7".to_string())
.parse()
.map_err(|_| "CLIENT_SECRET_PREV_TTL_DAYS must be a number")?,
port: env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()

View File

@@ -2,12 +2,15 @@ use crate::handlers;
use crate::models::{
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest,
ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest,
CreateUserRequest, ListAppsQuery, LoginRequest, LoginResponse,
RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery,
ClientSummary, Code2TokenRequest, Code2TokenResponse, CreateClientRequest, CreateClientResponse,
CreateUserRequest, ListAppsQuery, ListPermissionsQuery, LoginRequest, LoginResponse, Permission,
RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse,
RotateClientSecretResponse, Tenant, TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest,
UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest,
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, AuthorizationCheckRequest, AuthorizationCheckResponse,
UpdateClientRedirectUrisRequest,
LoginCodeRequest, LoginCodeResponse,
};
use serde_json::Value;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
@@ -138,7 +141,14 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
paths(
handlers::auth::register_handler,
handlers::auth::login_handler,
handlers::sso::login_code_handler,
handlers::auth::refresh_handler,
handlers::auth::logout_handler,
handlers::sso::code2token_handler,
handlers::client::create_client_handler,
handlers::client::rotate_client_secret_handler,
handlers::client::list_clients_handler,
handlers::client::update_client_redirect_uris_handler,
handlers::authorization::my_permissions_handler,
handlers::authorization::authorization_check_handler,
handlers::permission::list_permissions_handler,
@@ -185,7 +195,16 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
UpdateUserRequest,
LoginRequest,
LoginResponse,
LoginCodeRequest,
LoginCodeResponse,
RefreshTokenRequest,
Code2TokenRequest,
Code2TokenResponse,
CreateClientRequest,
CreateClientResponse,
RotateClientSecretResponse,
ClientSummary,
UpdateClientRedirectUrisRequest,
Permission,
ListPermissionsQuery,
Role,
@@ -218,6 +237,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
),
tags(
(name = "Auth", description = "认证:注册/登录/令牌"),
(name = "Client", description = "客户端clientId/clientSecret 管理(平台级)"),
(name = "Tenant", description = "租户:创建/查询/更新/状态/删除"),
(name = "User", description = "用户:查询/列表/更新/删除(需权限)"),
(name = "Role", description = "角色:创建/列表(需权限)"),

View File

@@ -1,5 +1,6 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{
CreateUserRequest, LoginRequest, LoginResponse, RefreshTokenRequest, UserResponse,
};
@@ -93,3 +94,29 @@ pub async fn refresh_handler(
.await?;
Ok(AppResponse::ok(response))
}
/// Logout (revoke all refresh tokens for current user).
/// 退出登录(吊销当前用户所有 refresh token
#[utoipa::path(
post,
path = "/auth/logout",
tag = "Auth",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Logged out"),
(status = 401, description = "Unauthorized")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state))]
pub async fn logout_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
) -> Result<AppResponse<serde_json::Value>, AppError> {
state.auth_service.logout(user_id).await?;
Ok(AppResponse::ok(serde_json::json!({})))
}

185
src/handlers/client.rs Normal file
View File

@@ -0,0 +1,185 @@
use crate::handlers::AppState;
use crate::middleware::auth::AuthContext;
use crate::models::{
ClientSummary, CreateClientRequest, CreateClientResponse, RotateClientSecretResponse,
UpdateClientRedirectUrisRequest,
};
use axum::{
Json,
extract::{Path, State},
};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
/// Create a new client and return its secret (shown once).
/// 创建 client 并返回 clientSecret仅展示一次
#[utoipa::path(
post,
path = "/platform/clients",
tag = "Client",
security(
("bearer_auth" = [])
),
request_body = CreateClientRequest,
responses(
(status = 201, description = "Created", body = CreateClientResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state, payload))]
pub async fn create_client_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Json(payload): Json<CreateClientRequest>,
) -> Result<AppResponse<CreateClientResponse>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:write")
.await?;
let secret = state
.client_service
.create_client(
payload.client_id.clone(),
payload.name.clone(),
payload.redirect_uris.clone(),
)
.await?;
Ok(AppResponse::created(CreateClientResponse {
client_id: payload.client_id,
client_secret: secret,
}))
}
/// Update allowed redirect URIs for a client.
/// 更新 client 的允许回调地址redirectUris
#[utoipa::path(
put,
path = "/platform/clients/{client_id}/redirect-uris",
tag = "Client",
security(
("bearer_auth" = [])
),
request_body = UpdateClientRedirectUrisRequest,
responses(
(status = 200, description = "Updated"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("client_id" = String, Path, description = "clientId")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_client_redirect_uris_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(client_id): Path<String>,
Json(payload): Json<UpdateClientRedirectUrisRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:write")
.await?;
state
.client_service
.set_redirect_uris(client_id, payload.redirect_uris)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
/// Rotate client secret (previous secret stays valid for grace period).
/// 轮换 clientSecret旧密钥在宽限期内仍可用
#[utoipa::path(
post,
path = "/platform/clients/{client_id}/rotate-secret",
tag = "Client",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Rotated", body = RotateClientSecretResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("client_id" = String, Path, description = "clientId")
)
)]
#[instrument(skip(state))]
pub async fn rotate_client_secret_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(client_id): Path<String>,
) -> Result<AppResponse<RotateClientSecretResponse>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:write")
.await?;
let secret = state
.client_service
.rotate_secret(client_id.clone())
.await?;
Ok(AppResponse::ok(RotateClientSecretResponse {
client_id,
client_secret: secret,
}))
}
/// List clients (secrets are never returned).
/// 查询 client 列表(不返回 secret
#[utoipa::path(
get,
path = "/platform/clients",
tag = "Client",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "OK", body = [ClientSummary]),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state))]
pub async fn list_clients_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
) -> Result<AppResponse<Vec<ClientSummary>>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:read")
.await?;
let rows = state.client_service.list_clients().await?;
let clients = rows
.into_iter()
.map(
|(client_id, name, redirect_uris, created_at, updated_at)| ClientSummary {
client_id,
name,
redirect_uris,
created_at,
updated_at,
},
)
.collect();
Ok(AppResponse::ok(clients))
}

View File

@@ -1,25 +1,32 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod client;
pub mod jwks;
pub mod permission;
pub mod platform;
pub mod role;
pub mod sso;
pub mod tenant;
pub mod user;
use crate::services::{
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
UserService,
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
TenantService, UserService,
};
use redis::aio::ConnectionManager;
pub use app::{
approve_app_status_change_handler, create_app_handler, delete_app_handler, get_app_handler,
list_app_status_change_requests_handler, list_apps_handler, reject_app_status_change_handler,
request_app_status_change_handler, update_app_handler,
};
pub use auth::{login_handler, refresh_handler, register_handler};
pub use auth::{login_handler, logout_handler, refresh_handler, register_handler};
pub use authorization::{authorization_check_handler, my_permissions_handler};
pub use client::{
create_client_handler, list_clients_handler, rotate_client_secret_handler,
update_client_redirect_uris_handler,
};
pub use jwks::jwks_handler;
pub use permission::list_permissions_handler;
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
@@ -28,6 +35,7 @@ pub use role::{
grant_role_users_handler, list_roles_handler, revoke_role_permissions_handler,
revoke_role_users_handler, update_role_handler,
};
pub use sso::{code2token_handler, login_code_handler};
pub use tenant::{
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
update_tenant_status_handler,
@@ -42,10 +50,13 @@ pub use user::{
#[derive(Clone)]
pub struct AppState {
pub auth_service: AuthService,
pub client_service: ClientService,
pub user_service: UserService,
pub role_service: RoleService,
pub tenant_service: TenantService,
pub authorization_service: AuthorizationService,
pub app_service: AppService,
pub permission_service: PermissionService,
pub redis: ConnectionManager,
pub auth_code_jwt_secret: String,
}

219
src/handlers/sso.rs Normal file
View File

@@ -0,0 +1,219 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::models::{Code2TokenRequest, Code2TokenResponse, LoginCodeRequest, LoginCodeResponse};
use anyhow::anyhow;
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use redis::AsyncCommands;
use redis::Script;
use serde::Deserialize;
use tracing::instrument;
use uuid::Uuid;
#[derive(Debug, Deserialize, serde::Serialize)]
struct AuthCodeClaims {
sub: String,
tenant_id: String,
client_id: Option<String>,
redirect_uri: Option<String>,
exp: usize,
iat: usize,
iss: String,
jti: String,
}
#[derive(Debug, Deserialize)]
struct AuthCodeRedisValue {
user_id: String,
tenant_id: String,
client_id: Option<String>,
redirect_uri: Option<String>,
}
fn redis_key(jti: &str) -> String {
format!("iam:auth_code:{}", jti)
}
/// Exchange one-time authorization code to access/refresh token.
/// 授权码换取 token一次性 code5 分钟有效,单次使用)。
#[utoipa::path(
post,
path = "/auth/code2token",
tag = "Auth",
request_body = Code2TokenRequest,
responses(
(status = 200, description = "Token issued", body = Code2TokenResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized")
)
)]
#[instrument(skip(state, payload))]
pub async fn code2token_handler(
State(state): State<AppState>,
Json(payload): Json<Code2TokenRequest>,
) -> Result<AppResponse<Code2TokenResponse>, AppError> {
state
.client_service
.verify_client_secret(&payload.client_id, &payload.client_secret)
.await?;
if payload.code.trim().is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&["iam-front", "iam-service"]);
let token_data = decode::<AuthCodeClaims>(
payload.code.trim(),
&DecodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
&validation,
)
.map_err(|e| AppError::AuthError(e.to_string()))?;
let claims = token_data.claims;
if let Some(cid) = &claims.client_id {
if cid != payload.client_id.trim() {
return Err(AppError::AuthError("Invalid code".into()));
}
}
let jti = claims.jti.trim();
if jti.is_empty() {
return Err(AppError::AuthError("Invalid code".into()));
}
let script = Script::new(
r#"
local v = redis.call('GET', KEYS[1])
if v then
redis.call('DEL', KEYS[1])
end
return v
"#,
);
let key = redis_key(jti);
let mut conn = state.redis.clone();
let val: Option<String> = script
.key(key)
.invoke_async(&mut conn)
.await
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let Some(val) = val else {
return Err(AppError::AuthError("Invalid or used code".into()));
};
let stored: AuthCodeRedisValue =
serde_json::from_str(&val).map_err(|_| AppError::AuthError("Invalid code".into()))?;
if let Some(cid) = stored.client_id.as_deref() {
if cid != payload.client_id.trim() {
return Err(AppError::AuthError("Invalid code".into()));
}
}
if stored.user_id != claims.sub || stored.tenant_id != claims.tenant_id {
return Err(AppError::AuthError("Invalid code".into()));
}
let user_id =
Uuid::parse_str(&stored.user_id).map_err(|_| AppError::AuthError("Invalid code".into()))?;
let tenant_id = Uuid::parse_str(&stored.tenant_id)
.map_err(|_| AppError::AuthError("Invalid code".into()))?;
let tokens = state
.auth_service
.issue_tokens_for_user(tenant_id, user_id, 7200)
.await?;
Ok(AppResponse::ok(Code2TokenResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type,
expires_in: tokens.expires_in,
tenant_id: tenant_id.to_string(),
user_id: user_id.to_string(),
}))
}
/// Login with username/password and issue one-time authorization code.
/// 用户账户密码登录并签发一次性授权码(用于 SSO 授权码模式)。
#[utoipa::path(
post,
path = "/auth/login-code",
tag = "Auth",
request_body = LoginCodeRequest,
responses(
(status = 200, description = "Code issued", body = LoginCodeResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 429, description = "Too many requests")
),
params(
("X-Tenant-ID" = String, Header, description = "Tenant UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn login_code_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
Json(payload): Json<LoginCodeRequest>,
) -> Result<AppResponse<LoginCodeResponse>, AppError> {
let redirect_uri = state
.client_service
.assert_redirect_uri_allowed(&payload.client_id, &payload.redirect_uri)
.await?;
let user = state
.auth_service
.verify_user_credentials(tenant_id, payload.email, payload.password)
.await?;
let now = chrono::Utc::now().timestamp() as usize;
let exp = now + 5 * 60;
let jti = Uuid::new_v4().to_string();
let claims = AuthCodeClaims {
sub: user.id.to_string(),
tenant_id: user.tenant_id.to_string(),
client_id: Some(payload.client_id),
redirect_uri: Some(redirect_uri.clone()),
exp,
iat: now,
iss: "iam-service".to_string(),
jti: jti.clone(),
};
let header = Header::new(Algorithm::HS256);
let code = encode(
&header,
&claims,
&EncodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
)
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let value = serde_json::json!({
"user_id": user.id.to_string(),
"tenant_id": user.tenant_id.to_string(),
"client_id": claims.client_id,
"redirect_uri": claims.redirect_uri
})
.to_string();
let mut conn = state.redis.clone();
let _: () = conn
.set_ex(redis_key(&jti), value, 5 * 60)
.await
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let mut u = url::Url::parse(&redirect_uri)
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?;
u.query_pairs_mut().append_pair("code", &code);
Ok(AppResponse::ok(LoginCodeResponse {
redirect_to: u.to_string(),
expires_at: exp,
}))
}

View File

@@ -4,6 +4,7 @@ mod docs;
mod handlers;
mod middleware;
mod models;
mod redis;
mod services;
mod utils;
@@ -12,26 +13,27 @@ use axum::{
http::StatusCode,
middleware::from_fn,
middleware::from_fn_with_state,
routing::{get, post},
routing::{get, post, put},
};
use config::AppConfig;
use handlers::{
AppState, approve_app_status_change_handler, authorization_check_handler, create_app_handler,
create_role_handler, create_tenant_handler, delete_app_handler, delete_role_handler,
delete_tenant_handler, delete_user_handler, get_app_handler, get_role_handler,
get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
grant_role_permissions_handler, grant_role_users_handler, jwks_handler,
list_app_status_change_requests_handler, list_apps_handler, list_permissions_handler,
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
my_permissions_handler, refresh_handler, register_handler, reject_app_status_change_handler,
request_app_status_change_handler, reset_my_password_handler, reset_user_password_handler,
revoke_role_permissions_handler, revoke_role_users_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_role_handler, update_tenant_handler,
update_tenant_status_handler, update_user_handler,
AppState, approve_app_status_change_handler, authorization_check_handler, code2token_handler,
create_app_handler, create_client_handler, create_role_handler, create_tenant_handler,
delete_app_handler, delete_role_handler, delete_tenant_handler, delete_user_handler,
get_app_handler, get_role_handler, get_tenant_enabled_apps_handler, get_tenant_handler,
get_user_handler, grant_role_permissions_handler, grant_role_users_handler, jwks_handler,
list_app_status_change_requests_handler, list_apps_handler, list_clients_handler,
list_permissions_handler, list_roles_handler, list_user_roles_handler, list_users_handler,
login_code_handler, login_handler, logout_handler, my_permissions_handler, refresh_handler,
register_handler, reject_app_status_change_handler, request_app_status_change_handler,
reset_my_password_handler, reset_user_password_handler, revoke_role_permissions_handler,
revoke_role_users_handler, rotate_client_secret_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_client_redirect_uris_handler,
update_role_handler, update_tenant_handler, update_tenant_status_handler, update_user_handler,
};
use services::{
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
UserService,
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
TenantService, UserService,
};
use std::net::SocketAddr;
use utoipa::OpenApi;
@@ -80,21 +82,32 @@ async fn main() {
// 4. 初始化 Service 和 AppState
let auth_service = AuthService::new(pool.clone(), config.jwt_secret.clone());
let client_service = ClientService::new(pool.clone(), config.client_secret_prev_ttl_days);
let user_service = UserService::new(pool.clone());
let role_service = RoleService::new(pool.clone());
let tenant_service = TenantService::new(pool.clone());
let authorization_service = AuthorizationService::new(pool.clone());
let app_service = AppService::new(pool.clone());
let permission_service = PermissionService::new(pool.clone());
let redis = match redis::init_manager(&config).await {
Ok(m) => m,
Err(e) => {
tracing::error!(%e, "Fatal error: Failed to connect to redis!");
std::process::exit(1);
}
};
let state = AppState {
auth_service,
client_service,
user_service,
role_service,
tenant_service,
authorization_service,
app_service,
permission_service,
redis,
auth_code_jwt_secret: config.auth_code_jwt_secret.clone(),
};
let auth_cfg = middleware::auth::AuthMiddlewareConfig {
@@ -103,7 +116,16 @@ async fn main() {
"/tenants/register".to_string(),
"/auth/register".to_string(),
"/auth/login".to_string(),
"/auth/login-code".to_string(),
"/auth/refresh".to_string(),
"/auth/code2token".to_string(),
"/iam/api/v1/.well-known/jwks.json".to_string(),
"/iam/api/v1/tenants/register".to_string(),
"/iam/api/v1/auth/register".to_string(),
"/iam/api/v1/auth/login".to_string(),
"/iam/api/v1/auth/login-code".to_string(),
"/iam/api/v1/auth/refresh".to_string(),
"/iam/api/v1/auth/code2token".to_string(),
],
skip_path_prefixes: vec!["/scalar".to_string()],
jwt: JwtVerifyConfig::rs256_from_pem(
@@ -117,6 +139,11 @@ async fn main() {
"/.well-known/jwks.json".to_string(),
"/tenants/register".to_string(),
"/auth/refresh".to_string(),
"/auth/code2token".to_string(),
"/iam/api/v1/.well-known/jwks.json".to_string(),
"/iam/api/v1/tenants/register".to_string(),
"/iam/api/v1/auth/refresh".to_string(),
"/iam/api/v1/auth/code2token".to_string(),
],
skip_path_prefixes: vec!["/scalar".to_string()],
};
@@ -144,12 +171,20 @@ async fn main() {
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route(
"/auth/login-code",
post(login_code_handler)
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route(
"/auth/refresh",
post(refresh_handler)
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route("/auth/logout", post(logout_handler))
.route("/auth/code2token", post(code2token_handler))
.route("/me/permissions", get(my_permissions_handler))
.route("/authorize/check", post(authorization_check_handler))
.route("/users", get(list_users_handler))
@@ -203,6 +238,18 @@ async fn main() {
"/platform/tenants/{tenant_id}/enabled-apps",
get(get_tenant_enabled_apps_handler).put(set_tenant_enabled_apps_handler),
)
.route(
"/platform/clients",
get(list_clients_handler).post(create_client_handler),
)
.route(
"/platform/clients/{client_id}/rotate-secret",
post(rotate_client_secret_handler),
)
.route(
"/platform/clients/{client_id}/redirect-uris",
put(update_client_redirect_uris_handler),
)
.route(
"/platform/apps",
get(list_apps_handler).post(create_app_handler),
@@ -237,11 +284,12 @@ async fn main() {
common_telemetry::axum_middleware::trace_http_request,
));
let v1 = Router::new().merge(platform_api).merge(api);
let app = Router::new()
.route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.merge(platform_api)
.merge(api)
.merge(v1.clone())
.nest("/iam/api/v1", v1)
.with_state(state);
// 6. 启动服务器

View File

@@ -360,6 +360,139 @@ pub struct RefreshTokenRequest {
pub refresh_token: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct LoginCodeRequest {
#[schema(default = "", example = "user@example.com")]
#[serde(default)]
pub email: String,
#[schema(default = "", example = "password")]
#[serde(default)]
pub password: String,
#[schema(default = "", example = "cms")]
#[serde(default)]
pub client_id: String,
#[schema(
default = "",
example = "https://cms-api.example.com/auth/callback?next=https%3A%2F%2Fcms.example.com%2F"
)]
#[serde(default)]
pub redirect_uri: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct LoginCodeResponse {
#[schema(
default = "",
example = "https://cms-api.example.com/auth/callback?next=...&code=..."
)]
#[serde(default)]
pub redirect_to: String,
#[schema(default = 1700000000, example = 1700000000)]
#[serde(default)]
pub expires_at: usize,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct Code2TokenRequest {
#[schema(default = "", example = "one_time_code_jwt")]
#[serde(default)]
pub code: String,
#[schema(default = "", example = "cms")]
#[serde(default)]
pub client_id: String,
#[schema(default = "", example = "client_secret")]
#[serde(default)]
pub client_secret: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct Code2TokenResponse {
#[schema(default = "", example = "access_token")]
#[serde(default)]
pub access_token: String,
#[schema(default = "", example = "refresh_token")]
#[serde(default)]
pub refresh_token: String,
#[schema(default = "Bearer", example = "Bearer")]
#[serde(default = "default_token_type")]
pub token_type: String,
#[schema(default = 7200, example = 7200)]
#[serde(default)]
pub expires_in: usize,
#[schema(default = "00000000-0000-0000-0000-000000000000")]
#[serde(default)]
pub tenant_id: String,
#[schema(default = "00000000-0000-0000-0000-000000000000")]
#[serde(default)]
pub user_id: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct CreateClientRequest {
#[schema(default = "", example = "cms")]
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub name: Option<String>,
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
#[serde(default)]
pub redirect_uris: Option<Vec<String>>,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct CreateClientResponse {
#[schema(default = "", example = "cms")]
#[serde(default)]
pub client_id: String,
#[schema(default = "", example = "client_secret")]
#[serde(default)]
pub client_secret: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct RotateClientSecretResponse {
#[schema(default = "", example = "cms")]
#[serde(default)]
pub client_id: String,
#[schema(default = "", example = "new_client_secret")]
#[serde(default)]
pub client_secret: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ClientSummary {
#[schema(default = "", example = "cms")]
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub name: Option<String>,
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
#[serde(default)]
pub redirect_uris: Vec<String>,
#[schema(default = "", example = "2026-02-02T12:00:00Z")]
#[serde(default)]
pub created_at: String,
#[schema(default = "", example = "2026-02-02T12:00:00Z")]
#[serde(default)]
pub updated_at: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientRedirectUrisRequest {
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
#[serde(default)]
pub redirect_uris: Vec<String>,
}
#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)]
pub struct Permission {
#[serde(default = "default_uuid")]

8
src/redis.rs Normal file
View File

@@ -0,0 +1,8 @@
use crate::config::AppConfig;
use redis::aio::ConnectionManager;
pub async fn init_manager(config: &AppConfig) -> Result<ConnectionManager, redis::RedisError> {
let client = redis::Client::open(config.redis_url.clone())?;
ConnectionManager::new(client).await
}

View File

@@ -1,6 +1,6 @@
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
use crate::utils::authz::filter_permissions_by_enabled_apps;
use crate::utils::{hash_password, sign, verify_password};
use crate::utils::{hash_password, sign, sign_with_ttl, verify_password};
use common_telemetry::AppError;
use hmac::{Hmac, Mac};
use rand::RngCore;
@@ -12,7 +12,6 @@ use uuid::Uuid;
#[derive(Clone)]
pub struct AuthService {
pool: PgPool,
// jwt_secret removed, using RS256 keys
refresh_token_pepper: String,
}
@@ -20,7 +19,8 @@ impl AuthService {
/// 创建认证服务实例。
///
/// 说明:
/// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留
/// - Access Token 使用 RS256 密钥对进行签发与校验,不使用对称密钥HS256
/// - 但仍需要一个服务端 Secret 作为 Refresh Token 指纹HMACpepper因此保留 `_jwt_secret` 入参(对齐环境变量名 `JWT_SECRET`)。
pub fn new(pool: PgPool, _jwt_secret: String) -> Self {
Self {
pool,
@@ -35,6 +35,92 @@ impl AuthService {
Ok(hex::encode(mac.finalize().into_bytes()))
}
#[instrument(skip(self))]
pub async fn issue_tokens_for_user(
&self,
tenant_id: Uuid,
user_id: Uuid,
access_ttl_secs: usize,
) -> Result<LoginResponse, AppError> {
let roles = sqlx::query_scalar::<_, String>(
r#"
SELECT r.name
FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(tenant_id)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
let permissions = sqlx::query_scalar::<_, String>(
r#"
SELECT DISTINCT p.code
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
JOIN roles r ON r.id = ur.role_id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(tenant_id)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
r#"
SELECT enabled_apps, version
FROM tenant_entitlements
WHERE tenant_id = $1
"#,
)
.bind(tenant_id)
.fetch_optional(&self.pool)
.await?
.unwrap_or_else(|| (vec![], 0));
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
let access_token = sign_with_ttl(
user_id,
tenant_id,
roles,
permissions,
enabled_apps,
apps_version,
access_ttl_secs,
)?;
let mut refresh_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut refresh_bytes);
let refresh_token = hex::encode(refresh_bytes);
let refresh_token_hash =
hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?;
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
sqlx::query(
"INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)",
)
.bind(user_id)
.bind(refresh_token_hash)
.bind(refresh_token_fingerprint)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(LoginResponse {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: access_ttl_secs,
})
}
// 注册业务
#[instrument(skip(self, req))]
/// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。
@@ -115,101 +201,47 @@ impl AuthService {
tenant_id: Uuid,
req: LoginRequest,
) -> Result<LoginResponse, AppError> {
// 1. 查找用户 (带 tenant_id 防止跨租户登录)
let user = self
.verify_user_credentials(tenant_id, req.email, req.password)
.await?;
self.issue_tokens_for_user(user.tenant_id, user.id, 15 * 60).await
}
#[instrument(skip(self, password))]
pub async fn verify_user_credentials(
&self,
tenant_id: Uuid,
email: String,
password: String,
) -> Result<User, AppError> {
let email = email.trim().to_string();
if email.is_empty() || password.is_empty() {
return Err(AppError::BadRequest("email and password are required".into()));
}
let query = "SELECT * FROM users WHERE tenant_id = $1 AND email = $2";
let user = sqlx::query_as::<_, User>(query)
.bind(tenant_id)
.bind(&req.email)
.bind(&email)
.fetch_optional(&self.pool)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
// 2. 验证密码
if !verify_password(&req.password, &user.password_hash) {
if !verify_password(&password, &user.password_hash) {
return Err(AppError::InvalidCredentials);
}
let roles = sqlx::query_scalar::<_, String>(
r#"
SELECT r.name
FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(user.tenant_id)
.bind(user.id)
.fetch_all(&self.pool)
.await?;
Ok(user)
}
let permissions = sqlx::query_scalar::<_, String>(
r#"
SELECT DISTINCT p.code
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
JOIN roles r ON r.id = ur.role_id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(user.tenant_id)
.bind(user.id)
.fetch_all(&self.pool)
.await?;
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
r#"
SELECT enabled_apps, version
FROM tenant_entitlements
WHERE tenant_id = $1
"#,
)
.bind(user.tenant_id)
.fetch_optional(&self.pool)
.await?
.unwrap_or_else(|| (vec![], 0));
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
// 3. 签发 Access Token
let access_token = sign(
user.id,
user.tenant_id,
roles,
permissions,
enabled_apps,
apps_version,
)?;
// 4. 生成 Refresh Token
let mut refresh_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut refresh_bytes);
let refresh_token = hex::encode(refresh_bytes);
// Hash refresh token for storage
let refresh_token_hash =
hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?;
// 5. 存储 Refresh Token (30天过期)
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
sqlx::query(
"INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)",
)
.bind(user.id)
.bind(refresh_token_hash)
.bind(refresh_token_fingerprint)
.bind(expires_at)
#[instrument(skip(self))]
pub async fn logout(&self, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(LoginResponse {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: 15 * 60, // 15 mins
})
Ok(())
}
async fn bootstrap_tenant_admin(

365
src/services/client.rs Normal file
View File

@@ -0,0 +1,365 @@
use crate::utils::{hash_password, verify_password};
use anyhow::anyhow;
use common_telemetry::AppError;
use percent_encoding::percent_decode_str;
use rand::RngCore;
use serde_json::Value;
use sqlx::PgPool;
use tracing::instrument;
#[derive(Clone)]
pub struct ClientService {
pool: PgPool,
prev_ttl_days: u32,
}
impl ClientService {
pub fn new(pool: PgPool, prev_ttl_days: u32) -> Self {
Self {
pool,
prev_ttl_days,
}
}
fn generate_secret(&self) -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn normalize_redirect_uri(&self, raw: &str) -> Result<String, AppError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(AppError::BadRequest("redirectUri is required".into()));
}
if raw.contains('\r') || raw.contains('\n') {
return Err(AppError::BadRequest("redirectUri is invalid".into()));
}
let mut url = match url::Url::parse(raw) {
Ok(u) => u,
Err(_) => {
if raw.contains('%') {
let decoded = percent_decode_str(raw).decode_utf8_lossy().to_string();
if decoded.contains('\r') || decoded.contains('\n') {
return Err(AppError::BadRequest("redirectUri is invalid".into()));
}
url::Url::parse(&decoded)
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?
} else {
return Err(AppError::BadRequest("redirectUri is invalid".into()));
}
}
};
url.set_fragment(None);
let host = url.host_str().unwrap_or_default();
let is_localhost = host == "localhost" || host == "127.0.0.1";
let is_allowed_scheme = url.scheme() == "https" || (is_localhost && url.scheme() == "http");
if !is_allowed_scheme {
return Err(AppError::BadRequest(
"redirectUri must be https (or http://localhost in dev)".into(),
));
}
Ok(url.to_string())
}
fn normalize_redirect_uris(&self, raw: Vec<String>) -> Result<Vec<String>, AppError> {
let mut out = Vec::new();
for u in raw {
out.push(self.normalize_redirect_uri(&u)?);
}
out.sort();
out.dedup();
Ok(out)
}
#[instrument(skip(self))]
pub async fn create_client(
&self,
client_id: String,
name: Option<String>,
redirect_uris: Option<Vec<String>>,
) -> Result<String, AppError> {
let client_id = client_id.trim().to_string();
if client_id.is_empty() {
return Err(AppError::BadRequest("clientId is required".into()));
}
let secret = self.generate_secret();
let secret_hash = hash_password(&secret).map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let redirect_uris = redirect_uris
.map(|v| self.normalize_redirect_uris(v))
.transpose()?
.unwrap_or_default();
let redirect_uris_json =
Value::Array(redirect_uris.into_iter().map(Value::String).collect());
let inserted = sqlx::query(
r#"
INSERT INTO oauth_clients (client_id, name, secret_hash, redirect_uris)
VALUES ($1, $2, $3, $4)
ON CONFLICT (client_id) DO NOTHING
"#,
)
.bind(&client_id)
.bind(name)
.bind(secret_hash)
.bind(redirect_uris_json)
.execute(&self.pool)
.await?
.rows_affected();
if inserted == 0 {
return Err(AppError::BadRequest("clientId already exists".into()));
}
Ok(secret)
}
#[instrument(skip(self))]
pub async fn rotate_secret(&self, client_id: String) -> Result<String, AppError> {
let client_id = client_id.trim().to_string();
if client_id.is_empty() {
return Err(AppError::BadRequest("clientId is required".into()));
}
let row = sqlx::query_as::<_, (String,)>(
"SELECT secret_hash FROM oauth_clients WHERE client_id = $1",
)
.bind(&client_id)
.fetch_optional(&self.pool)
.await?;
let Some((current_hash,)) = row else {
return Err(AppError::NotFound("client not found".into()));
};
let new_secret = self.generate_secret();
let new_secret_hash =
hash_password(&new_secret).map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let prev_expires_at =
chrono::Utc::now() + chrono::Duration::days(self.prev_ttl_days as i64);
sqlx::query(
r#"
UPDATE oauth_clients
SET prev_secret_hash = $1,
prev_expires_at = $2,
secret_hash = $3,
updated_at = NOW()
WHERE client_id = $4
"#,
)
.bind(current_hash)
.bind(prev_expires_at)
.bind(new_secret_hash)
.bind(&client_id)
.execute(&self.pool)
.await?;
Ok(new_secret)
}
#[instrument(skip(self))]
pub async fn set_redirect_uris(
&self,
client_id: String,
redirect_uris: Vec<String>,
) -> Result<(), AppError> {
let client_id = client_id.trim().to_string();
if client_id.is_empty() {
return Err(AppError::BadRequest("clientId is required".into()));
}
let redirect_uris = self.normalize_redirect_uris(redirect_uris)?;
let redirect_uris_json =
Value::Array(redirect_uris.into_iter().map(Value::String).collect());
let rows = sqlx::query(
r#"
UPDATE oauth_clients
SET redirect_uris = $1,
updated_at = NOW()
WHERE client_id = $2
"#,
)
.bind(redirect_uris_json)
.bind(&client_id)
.execute(&self.pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound("client not found".into()));
}
Ok(())
}
#[instrument(skip(self))]
pub async fn assert_redirect_uri_allowed(
&self,
client_id: &str,
redirect_uri: &str,
) -> Result<String, AppError> {
let client_id = client_id.trim();
if client_id.is_empty() {
return Err(AppError::BadRequest("clientId is required".into()));
}
let normalized = self.normalize_redirect_uri(redirect_uri)?;
let row = sqlx::query_as::<_, (Value,)>(
"SELECT redirect_uris FROM oauth_clients WHERE client_id = $1",
)
.bind(client_id)
.fetch_optional(&self.pool)
.await?;
let Some((v,)) = row else {
return Err(AppError::AuthError("Invalid client credentials".into()));
};
let Some(arr) = v.as_array() else {
return Err(AppError::ConfigError(
"Invalid oauth_clients.redirect_uris".into(),
));
};
let requested = url::Url::parse(&normalized)
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?;
let requested_host = requested.host_str().unwrap_or_default().to_string();
let requested_port = requested.port_or_known_default().unwrap_or(0);
let requested_path = requested.path().to_string();
let requested_scheme = requested.scheme().to_string();
let allowed = arr.iter().filter_map(|x| x.as_str()).any(|raw_allowed| {
let allowed_norm = match self.normalize_redirect_uri(raw_allowed) {
Ok(v) => v,
Err(_) => return false,
};
let allowed_url = match url::Url::parse(&allowed_norm) {
Ok(v) => v,
Err(_) => return false,
};
let host = allowed_url.host_str().unwrap_or_default();
let port = allowed_url.port_or_known_default().unwrap_or(0);
if allowed_url.scheme() != requested_scheme
|| host != requested_host
|| port != requested_port
|| allowed_url.path() != requested_path
{
return false;
}
let q = allowed_url.query().unwrap_or_default();
if q.is_empty() {
return true;
}
allowed_norm == normalized
});
if !allowed {
return Err(AppError::AuthError("redirectUri is not allowed".into()));
}
Ok(normalized)
}
#[instrument(skip(self, client_secret))]
pub async fn verify_client_secret(
&self,
client_id: &str,
client_secret: &str,
) -> Result<(), AppError> {
if client_id.trim().is_empty() || client_secret.trim().is_empty() {
return Err(AppError::AuthError("Invalid client credentials".into()));
}
let row = sqlx::query_as::<
_,
(
String,
Option<String>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT secret_hash, prev_secret_hash, prev_expires_at
FROM oauth_clients
WHERE client_id = $1
"#,
)
.bind(client_id.trim())
.fetch_optional(&self.pool)
.await?;
let Some((secret_hash, prev_hash, prev_expires_at)) = row else {
return Err(AppError::AuthError("Invalid client credentials".into()));
};
if verify_password(client_secret.trim(), &secret_hash) {
return Ok(());
}
if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at) {
if chrono::Utc::now() < prev_expires_at
&& verify_password(client_secret.trim(), &prev_hash)
{
return Ok(());
}
}
Err(AppError::AuthError("Invalid client credentials".into()))
}
#[instrument(skip(self))]
pub async fn list_clients(
&self,
) -> Result<Vec<(String, Option<String>, Vec<String>, String, String)>, AppError> {
let rows = sqlx::query_as::<
_,
(
String,
Option<String>,
Value,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT client_id, name, redirect_uris, created_at, updated_at
FROM oauth_clients
ORDER BY client_id ASC
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, redirect_uris, created_at, updated_at)| {
let uris = redirect_uris
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
(
id,
name,
uris,
created_at.to_rfc3339(),
updated_at.to_rfc3339(),
)
})
.collect())
}
}

View File

@@ -1,6 +1,7 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod client;
pub mod permission;
pub mod role;
pub mod tenant;
@@ -9,6 +10,7 @@ pub mod user;
pub use app::AppService;
pub use auth::AuthService;
pub use authorization::AuthorizationService;
pub use client::ClientService;
pub use permission::PermissionService;
pub use role::RoleService;
pub use tenant::TenantService;

View File

@@ -29,13 +29,33 @@ pub fn sign(
permissions: Vec<String>,
apps: Vec<String>,
apps_version: i32,
) -> Result<String, AppError> {
sign_with_ttl(
user_id,
tenant_id,
roles,
permissions,
apps,
apps_version,
15 * 60,
)
}
pub fn sign_with_ttl(
user_id: Uuid,
tenant_id: Uuid,
roles: Vec<String>,
permissions: Vec<String>,
apps: Vec<String>,
apps_version: i32,
ttl_secs: usize,
) -> Result<String, AppError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize;
let expiration = now + 15 * 60; // 15 minutes access token
let expiration = now + ttl_secs;
let claims = Claims {
sub: user_id.to_string(),

View File

@@ -1,7 +1,7 @@
pub mod keys;
pub mod jwt;
pub mod password;
pub mod authz;
pub mod jwt;
pub mod keys;
pub mod password;
pub use jwt::{sign, sign_with_ttl, verify};
pub use password::{hash_password, verify_password};
pub use jwt::{sign, verify};