feat(callback): add callback
This commit is contained in:
22
.env.example
22
.env.example
@@ -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 指纹(HMAC)pepper,用于 refresh_tokens.token_fingerprint 计算与校验。
|
||||
# 要求:生产环境必须使用高强度随机值,定期轮换会导致现有 refresh token 全部失效(通常是可接受的)。
|
||||
# openssl rand -base64 64
|
||||
JWT_SECRET=please_replace_with_a_secure_random_string
|
||||
|
||||
# AUTH_CODE_JWT_SECRET:授权码(code)JWT 的对称签名密钥(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_DAYS:clientSecret 轮换后的旧密钥宽限期(天)。
|
||||
# 作用:允许业务方平滑切换新密钥,宽限期内新旧 clientSecret 都可用于 /auth/code2token。
|
||||
CLIENT_SECRET_PREV_TTL_DAYS=7
|
||||
|
||||
# JWT_KEY_ID:RS256 key id(kid),会出现在 JWT header 与 JWKS 中,用于多 Key 管理与轮换。
|
||||
JWT_KEY_ID=default
|
||||
|
||||
# JWT_PRIVATE_KEY_PEM / JWT_PUBLIC_KEY_PEM:RS256 私钥/公钥(PEM 文本)。
|
||||
# 作用:签发 access token(私钥)与提供 JWKS(公钥);业务服务通常使用 JWKS 验签。
|
||||
JWT_PRIVATE_KEY_PEM=
|
||||
JWT_PUBLIC_KEY_PEM=
|
||||
|
||||
# JWT_JWKS_EXTRA_KEYS_JSON:可选,额外 JWKS keys(JSON 数组),用于灰度轮换/多公钥共存。
|
||||
JWT_JWKS_EXTRA_KEYS_JSON=
|
||||
PORT=3000
|
||||
|
||||
|
||||
124
Cargo.lock
generated
124
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
211
LOCAL_E2E_RUNBOOK.md
Normal 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-service:3000
|
||||
- cms-service:3100
|
||||
|
||||
## 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 会先本地验签 token(auth-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 不一致会返回 403(tenant:mismatch)
|
||||
|
||||
### 6.3 CMS 返回 403 PermissionDenied
|
||||
|
||||
- 表示 IAM 裁权拒绝(例如用户没有 `cms:*` 权限)
|
||||
- 你可以在 IAM 中调用 `GET /me/permissions` 查看当前用户权限
|
||||
|
||||
@@ -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 token(refresh token 一次性轮换)
|
||||
- `POST /auth/code2token`:授权码换取 token(SSO)
|
||||
|
||||
### 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
162
docs/SSO_INTEGRATION.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 业务服务接入指引(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}/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"] }'
|
||||
```
|
||||
12
docs/TEMP.md
12
docs/TEMP.md
@@ -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项的管理的最佳方式是什么?
|
||||
```
|
||||
28
scripts/db/migrations/0007_oauth_clients.sql
Normal file
28
scripts/db/migrations/0007_oauth_clients.sql
Normal 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;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oauth_clients
|
||||
ADD COLUMN IF NOT EXISTS redirect_uris JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMIT;
|
||||
|
||||
9
scripts/db/rollback/0007.down.sql
Normal file
9
scripts/db/rollback/0007.down.sql
Normal 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;
|
||||
|
||||
7
scripts/db/rollback/0008.down.sql
Normal file
7
scripts/db/rollback/0008.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oauth_clients
|
||||
DROP COLUMN IF EXISTS redirect_uris;
|
||||
|
||||
COMMIT;
|
||||
|
||||
15
scripts/db/verify/0007_oauth_clients.sql
Normal file
15
scripts/db/verify/0007_oauth_clients.sql
Normal 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 $$;
|
||||
|
||||
8
scripts/db/verify/0008_oauth_client_redirect_uris.sql
Normal file
8
scripts/db/verify/0008_oauth_client_redirect_uris.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
|
||||
SELECT redirect_uris
|
||||
FROM oauth_clients
|
||||
LIMIT 1;
|
||||
|
||||
ROLLBACK;
|
||||
|
||||
@@ -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()
|
||||
|
||||
26
src/docs.rs
26
src/docs.rs
@@ -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 = "角色:创建/列表(需权限)"),
|
||||
|
||||
@@ -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
185
src/handlers/client.rs
Normal 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))
|
||||
}
|
||||
@@ -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
219
src/handlers/sso.rs
Normal 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(一次性 code,5 分钟有效,单次使用)。
|
||||
#[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,
|
||||
}))
|
||||
}
|
||||
82
src/main.rs
82
src/main.rs
@@ -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. 启动服务器
|
||||
|
||||
133
src/models.rs
133
src/models.rs
@@ -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
8
src/redis.rs
Normal 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
|
||||
}
|
||||
|
||||
@@ -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 指纹(HMAC)pepper,因此保留 `_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
365
src/services/client.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user