feat(lib): add auth-kit

This commit is contained in:
2026-02-02 14:26:24 +08:00
parent e49b33a464
commit 27a6791591
19 changed files with 1154 additions and 185 deletions

View File

@@ -6,6 +6,10 @@ LOG_FILE_NAME=iam.log
DATABASE_URL=postgres://iam_service_user:iam_service_password@localhost:5432/iam_service_db
JWT_SECRET=please_replace_with_a_secure_random_string
JWT_KEY_ID=default
JWT_PRIVATE_KEY_PEM=
JWT_PUBLIC_KEY_PEM=
JWT_JWKS_EXTRA_KEYS_JSON=
PORT=3000
# Optional: Scalar/OpenAPI example injection

347
Cargo.lock generated
View File

@@ -76,6 +76,23 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auth-kit"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"common-telemetry",
"dashmap",
"http",
"jsonwebtoken",
"reqwest",
"serde",
"serde_json",
"tracing",
"uuid",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -89,7 +106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86"
dependencies = [
"aws-lc-sys",
"untrusted",
"untrusted 0.7.1",
"zeroize",
]
@@ -232,6 +249,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.43"
@@ -736,8 +759,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -940,6 +965,23 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
@@ -959,6 +1001,7 @@ version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
@@ -966,7 +1009,9 @@ dependencies = [
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
@@ -981,6 +1026,7 @@ dependencies = [
"anyhow",
"argon2",
"async-trait",
"auth-kit",
"axum",
"base64",
"chrono",
@@ -1154,6 +1200,16 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -1277,6 +1333,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1712,6 +1774,61 @@ dependencies = [
"winapi",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.44"
@@ -1842,6 +1959,58 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]]
name = "ron"
version = "0.12.0"
@@ -1886,6 +2055,12 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.3"
@@ -1899,6 +2074,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2394,6 +2604,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -2561,6 +2774,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -2664,6 +2887,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -2843,6 +3084,12 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -2971,6 +3218,20 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
@@ -3023,6 +3284,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.6.1"
@@ -3123,6 +3393,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -3156,6 +3435,22 @@ dependencies = [
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
@@ -3166,7 +3461,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
@@ -3179,6 +3474,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
@@ -3191,6 +3492,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
@@ -3203,12 +3510,24 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
@@ -3221,6 +3540,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
@@ -3233,6 +3558,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
@@ -3245,6 +3576,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
@@ -3257,6 +3594,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"

View File

@@ -11,6 +11,8 @@ common-telemetry = { version = "0.1.5", registry = "kellnr", default-features =
"with-sqlx",
] } # 内部公共库(统一错误/响应/日志/Telemetry
auth-kit = { path = "../auth-kit" }
# Web 框架与 HTTP 基础
axum = "0.8.8"
http = "1.4.0"

View File

@@ -8,7 +8,7 @@
- WebAxum
- 数据库PostgreSQL + SQLx
- 密码Argon2
- TokenJWTRS256 非对称签发/验签已实现JWK Set 端点待补齐
- TokenJWTRS256 非对称签发/验签 + JWKS 端点 `/.well-known/jwks.json`
- 可观测性tracing + `common-telemetry`(私有 registrykellnr
- API 文档utoipa + Scalar`/scalar`
@@ -84,7 +84,8 @@ cp .env.example .env
按需修改 `.env`
- `DATABASE_URL`PostgreSQL 连接串
- `JWT_SECRET`保留字段(当前 RS256 实现未使用;后续将用于密钥加载/加密存储)
- `JWT_SECRET`用于 refresh token 指纹HMAC pepper不是 JWT 签名密钥
- `JWT_PRIVATE_KEY_PEM` / `JWT_PUBLIC_KEY_PEM` / `JWT_KEY_ID`RS256 签发/验签密钥(详见 [jwt-rs256-keys-and-e2e.md](file:///home/shay/project/backend/iam-service/docs/jwt-rs256-keys-and-e2e.md)
- `PORT`:监听端口
4. 启动服务

115
docs/INTEGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,115 @@
# IAM Service 集成基线(接口清单 & 鉴权流程)
本文用于后续其他服务(如 CMS集成时对齐以下能力基线
- 租户隔离Tenant Context / X-Tenant-ID / Token tenant_id 一致性校验)
- 用户注册/登录/刷新
- 基础租户管理
- 基于 JWT 的认证中间件
- RBAC 权限校验(服务端校验接口)
## 核心 Header/Token 约定
- `Authorization: Bearer <access_token>`
- `X-Tenant-ID: <tenant_uuid>`
- 当请求携带 `Authorization` 时,若同时提供 `X-Tenant-ID`,必须与 JWT claims 内的 `tenant_id` 一致,否则返回 403`tenant:mismatch`)。
## 接口清单REST
### 文档
- `GET /scalar`Scalar UI
### Auth公开
- `POST /tenants/register`:创建租户(初始租户管理员账号由后续 `/auth/register` + 首用户 bootstrap 完成)
- `POST /auth/register`:用户注册(需要 `X-Tenant-ID`
- `POST /auth/login`:用户登录(需要 `X-Tenant-ID`
- `POST /auth/refresh`:刷新 access tokenrefresh token 一次性轮换)
### Tenant需认证 + 权限)
- `GET /tenants/me``tenant:read`
- `PATCH /tenants/me``tenant:write`
- `DELETE /tenants/me``tenant:write`
- `POST /tenants/me/status``tenant:write`
### Me需认证
- `GET /me/permissions`:查询当前用户在当前租户下的权限码列表
### RBAC供其他服务复用
- `POST /authorize/check`:服务端校验“当前用户”是否具备指定权限码(用于各业务微服务鉴权复用)
### User需认证 + 权限)
- `GET /users`
- `GET /users/{id}`
- `PATCH /users/{id}`
- `DELETE /users/{id}`
- `POST /users/me/password/reset`
- `POST /users/{id}/password/reset`
- `GET /users/{id}/roles`
- `PUT /users/{id}/roles`
### Role需认证 + 权限)
- `GET /roles`
- `POST /roles`
- `GET /roles/{id}`
- `PATCH /roles/{id}`
- `DELETE /roles/{id}`
- `POST /roles/{id}/permissions/grant`
- `POST /roles/{id}/permissions/revoke`
- `POST /roles/{id}/users/grant`
- `POST /roles/{id}/users/revoke`
### Permission需认证 + 权限)
- `GET /permissions`
### Platform需认证平台权限
- `GET /platform/tenants/{tenant_id}/enabled-apps`
- `PUT /platform/tenants/{tenant_id}/enabled-apps`
- `GET /platform/apps`
- `POST /platform/apps`
- `GET /platform/apps/{app_id}`
- `PATCH /platform/apps/{app_id}`
- `DELETE /platform/apps/{app_id}`
- `POST /platform/apps/{app_id}/status-change-requests`
- `GET /platform/app-status-change-requests`
- `POST /platform/app-status-change-requests/{request_id}/approve`
- `POST /platform/app-status-change-requests/{request_id}/reject`
## 鉴权流程图(请求 → 认证 → 租户隔离 → 权限校验)
```mermaid
flowchart TD
A[HTTP 请求进入] --> B[trace_http_request: 创建 http.request span]
B --> C{是否公开路径?}
C -- 是 --> H[进入 Handler]
C -- 否 --> D[JWT 认证中间件: Authorization Bearer]
D -->|验签/解析失败| E[返回 401]
D -->|成功| F[注入 AuthContext<br/>tenant_id/user_id/roles/permissions<br/>并 record span 字段]
F --> G[租户解析中间件: resolve_tenant]
G -->|缺少 X-Tenant-ID 且 Token 无 tenant| I[返回 400]
G -->|X-Tenant-ID 与 Token tenant 不一致| J[返回 403 tenant:mismatch]
G -->|成功| K[注入 TenantId 到 extensions 并 record span tenant_id]
K --> H
H --> L{是否需要 RBAC 权限?}
L -- 否 --> M[返回业务响应]
L -- 是 --> N[调用 AuthorizationService::require_permission]
N -->|无权限| O[返回 403 PermissionDenied]
N -->|通过| M
```
## 集成建议(面向业务微服务)
- 业务服务应直接复用 iam-service 的 JWT 认证中间件与 Tenant 解析中间件,并在业务路由层按以下顺序挂载:
- `trace_http_request`(生成请求 span
- `authenticate`(解析 token 并注入 user/tenant 字段到 span
- `resolve_tenant`(统一 TenantId 注入,并校验 X-Tenant-ID 与 token tenant 一致性)
- 权限校验禁止在业务侧实现一套 RBAC 聚合逻辑;应通过 `POST /authorize/check` 由 IAM 统一裁决。

View File

@@ -26,8 +26,8 @@
"code": 0,
"message": "Success",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk4NTEyOTIsImlhdCI6MTc2OTg1MDM5MiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnBhc3N3b3JkOnJlc2V0OmFueSIsInVzZXI6cmVhZCIsInVzZXI6d3JpdGUiXSwiYXBwcyI6WyJjbXMiXSwiYXBwc192ZXJzaW9uIjoxfQ.jt1os6re3yhxBk4wfmBjy1_Qh8n5nkfwe8ptn-yi7Vws_MOepOAmdxqSY_sabOnvGZ74Rq2EFSDpOaan4HuPln35Vlt6-CPlEu5eikLu3AIBl7sZfGoOquHwnybuOwo8b5oFwgAWF0bqSmn1v--LdGvv7vX4zWiKxK6GeeCTZ8279GqO70tl4o6ug2swSMqPbspL-ZwnWrnvFRhfZkyrRmM6jn3TVUMFWX3FfTlm68lNl_UPj9OcUPvbIXFL3X-h8qk-W1Dq2hV_Z1WxjkwVV0XEa0iwz12Mb_-QFys2xLSXSxL4ubUJhV2RVQ2WmW-I0njLEJAQ5oR56nZi7XMZHA",
"refresh_token": "982236b2f680366a895768df1ffc29bf4bbc09eb82d6a88a4413a07f66b3badb",
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwMTQxNTIsImlhdCI6MTc3MDAxMzI1MiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnBhc3N3b3JkOnJlc2V0OmFueSIsInVzZXI6cmVhZCIsInVzZXI6d3JpdGUiXSwiYXBwcyI6WyJjbXMiXSwiYXBwc192ZXJzaW9uIjoxfQ.Myh55kW5xknQNvECzz4U3Ojq-T-gmulaJJAkpa92gF66CDbN9lCLlK0hZOAbzpsABSOBtH0VKFCZJ0rfX1PamWxyp0nl3SBHiQMR5owy0dMqJsA8UDvaibL_-MxXRQgIL3Bpm3nd1l6GtrnnRXL4qBy5UleD_d89RqUPhF0FV34T-RwSHYSPs_0h33DNI4gD564Jjkn6-t6Z4CpR34OnqeMRuR6OugZzyoa1w0D2xzC6qohfwyIQMffP99OejvJUPis36vcvxOY3SILXwdooFc_CLT0glx2IRoeerJJpoQF40Dz3lWAhBI5-4CfORztwPfxuC441uzA3PaR2DqI-kw",
"refresh_token": "d19095aca07c32fb8e660b93a2cc5c0660e78ae490dac3a56b93f755761b5e50",
"token_type": "Bearer",
"expires_in": 900
}

View File

@@ -0,0 +1,336 @@
# iam-serviceRS256 公私钥生成与认证链路配置(含 JWKS
本文以 **RSA + RS256** 为主线,覆盖从密钥生成、配置、发布 JWKS、公钥验签、到端到端验证与故障排查的完整链路。文档中的代码路径、配置项与测试命令均已在本仓库中实际运行验证。
## 1. 密钥对生成RSA/RS256
### 1.1 技术规范(推荐)
- 算法RSA
- JWT 签名算法RS256JWT header `alg=RS256`
- 密钥长度2048 位(推荐);可选 3072/4096性能会下降
- 私钥格式PKCS#8PEM 编码(`-----BEGIN PRIVATE KEY-----`
- 公钥格式SubjectPublicKeyInfoPKCS#8 公钥PEM 编码(`-----BEGIN PUBLIC KEY-----`
- Key ID`kid`(用于密钥轮换与多 key 共存)
当前实现兼容:
- `JWT_PUBLIC_KEY_PEM`PKCS#8 公钥 PEM 或 PKCS#1 RSA 公钥 PEM
- `JWT_PRIVATE_KEY_PEM`RSA 私钥 PEM建议 PKCS#8
### 1.2 使用 OpenSSL 生成(已验证可用)
在安全目录生成(不要提交到仓库):
```bash
mkdir -p ./keys && chmod 700 ./keys
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ./keys/jwt_private_key.pem
openssl pkey -in ./keys/jwt_private_key.pem -pubout -out ./keys/jwt_public_key.pem
chmod 600 ./keys/jwt_private_key.pem
chmod 644 ./keys/jwt_public_key.pem
```
校验密钥可解析:
```bash
openssl pkey -in ./keys/jwt_private_key.pem -text -noout >/dev/null
openssl pkey -pubin -in ./keys/jwt_public_key.pem -text -noout >/dev/null
```
### 1.3 可选:生成 PKCS#1 RSA PUBLIC KEY仅在部分生态需要
```bash
openssl rsa -in ./keys/jwt_private_key.pem -RSAPublicKey_out -out ./keys/jwt_public_key_pkcs1.pem
chmod 644 ./keys/jwt_public_key_pkcs1.pem
```
## 2. 密钥管理生命周期(生成 → 存储 → 权限 → 轮换)
### 2.1 生成与分发原则
- 私钥仅用于 iam-service 签发令牌,严禁分发给其他服务
- 公钥用于验签,可通过 JWKS 端点按需分发
- 禁止在日志/错误信息中输出 PEM 内容
### 2.2 安全存储建议
- Kubernetes用 Secret 存 PEM多行字符串挂载为文件或以 env 方式注入
- 传统部署:使用 Vault/KMS/主机密钥管理系统下发到主机,落盘权限 600
- CI/CD密钥只在部署阶段注入不进入镜像层与构建产物
### 2.3 文件权限Linux 推荐)
- 目录:`chmod 700 /path/to/keys`
- 私钥文件:`chmod 600 jwt_private_key.pem`
- 公钥文件:`chmod 644 jwt_public_key.pem`
- 运行用户:仅运行用户可读私钥
### 2.4 密钥轮换kid + JWKS 多 key 共存)
目标:启用新私钥签发,同时让旧 token 在 TTL 内仍可被验签。
1) 生成新密钥对,确定新 `kid`(如 `2026-02`
2) iam-service 切换到新 key签发使用新私钥
- `JWT_KEY_ID=2026-02`
- `JWT_PRIVATE_KEY_PEM`/`JWT_PUBLIC_KEY_PEM` 指向新密钥
3) 将旧公钥加入 JWKS 兼容列表(轮换窗口):
- `JWT_JWKS_EXTRA_KEYS_JSON` 包含旧 `kid` + 旧公钥 PEM
```json
[
{
"kid": "2026-01",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
}
]
```
4) 等待旧 token 最大有效期结束(当前 access token 默认 15 分钟)后,从 `JWT_JWKS_EXTRA_KEYS_JSON` 移除旧公钥
## 3. JWT 签名与验证方案RS256 + JWKS
### 3.1 服务端iam-service私钥签名
- RS256 签名header 自动携带 `kid`
- 代码: [jwt.rs](file:///home/shay/project/backend/iam-service/src/utils/jwt.rs)
- 私钥/公钥读取与兼容解析: [keys.rs](file:///home/shay/project/backend/iam-service/src/utils/keys.rs)
注意:
- `JWT_SECRET` 在本项目中用于 refresh token 指纹HMAC pepper不用于 JWT RS256 令牌签名
### 3.2 服务端iam-service发布 JWKS
公开端点:
- `GET /.well-known/jwks.json`
- 代码: [jwks.rs](file:///home/shay/project/backend/iam-service/src/handlers/jwks.rs)
行为:
- 返回当前活动 key 的 JWK`kty=RSA,use=sig,alg=RS256,n/e,kid`
- 可选附加历史公钥(`JWT_JWKS_EXTRA_KEYS_JSON`
### 3.3 客户端(微服务/网关):公钥验签
本仓库内 cms-service 默认使用 JWKS 验签:
- 优先使用静态 `JWT_PUBLIC_KEY_PEM`(离线、公钥固定)
- 否则使用 `IAM_JWKS_URL`(未提供则默认 `IAM_BASE_URL + /.well-known/jwks.json`
- 配置代码: [cms main.rs](file:///home/shay/project/backend/cms-service/src/main.rs#L33-L64)
## 4. 集成配置示例(服务端 & 客户端)
### 4.1 iam-service服务端签名 + 发布 JWKS
推荐以“文件 + 启动脚本注入”的方式运行(避免把多行 PEM 塞进 .env
```bash
export JWT_SECRET="change-me-refresh-token-pepper"
export JWT_KEY_ID="2026-02"
export JWT_PRIVATE_KEY_PEM="$(cat ./keys/jwt_private_key.pem)"
export JWT_PUBLIC_KEY_PEM="$(cat ./keys/jwt_public_key.pem)"
cargo run
```
### 4.2 cms-service客户端验签
方式 A推荐只配置 IAM 地址,走默认 JWKS URL
```bash
IAM_BASE_URL=http://127.0.0.1:3000
```
方式 B显式指定 JWKS URL
```bash
IAM_JWKS_URL=http://127.0.0.1:3000/.well-known/jwks.json
```
方式 C静态公钥不依赖网络
```bash
JWT_PUBLIC_KEY_PEM="$(cat ./keys/jwt_public_key.pem)"
```
### 4.3 可选:客户端离线签发(仅用于测试/联调)
正常生产链路中,私钥应只由 iam-service 持有并用于签发;客户端应只携带 token 或使用公钥验签。若需要在联调/测试环境离线生成一个 RS256 token可使用示例程序
- 示例代码: [offline_issue_jwt.rs](file:///home/shay/project/backend/iam-service/examples/offline_issue_jwt.rs)
运行(会在 stdout 输出生成的 JWT
```bash
cd /home/shay/project/backend/iam-service
export JWT_KEY_ID="2026-02"
export JWT_ISSUER="iam-service"
export JWT_PRIVATE_KEY_PEM="$(cat ./keys/jwt_private_key.pem)"
cargo run --example offline_issue_jwt
```
## 5. 测试用例与端到端验证步骤(已验证)
### 5.1 自动化测试(推荐)
auth-kit验证 RS256 + JWKS 拉取与验签:
```bash
cd /home/shay/project/backend/auth-kit
cargo test
```
覆盖用例: [jwks_verify.rs](file:///home/shay/project/backend/auth-kit/tests/jwks_verify.rs)
iam-service验证 JWKS 端点返回的 key 能被 auth-kit 拉取并验签(端到端):
```bash
cd /home/shay/project/backend/iam-service
cargo test jwks_endpoint_allows_rs256_verification_via_auth_kit
```
覆盖用例: [jwks_e2e.rs](file:///home/shay/project/backend/iam-service/tests/jwks_e2e.rs)
### 5.2 手工验证HTTP + JWKS
获取 JWKS
```bash
curl -s http://127.0.0.1:3000/.well-known/jwks.json | jq .
```
检查:
- `keys[].kid` 是否为当前 `JWT_KEY_ID`
- `keys[].alg` 是否为 `RS256`
## 6. 安全配置要求(生产)
- 私钥不得进入镜像层、Git、日志、监控事件
- 私钥文件权限 600目录权限 700仅运行用户可读
- 使用 Secret 管理器下发Vault/KMS/K8s Secret避免人工分发
- 为 JWKS 设置合理缓存与降级auth-kit 已带缓存与 stale-if-error
### 6.1 Docker 部署示例Secret 挂载为文件 + 启动脚本注入 env
目标:
- 镜像中不包含任何密钥
- 宿主机/Secret 管理器以“文件”的方式把 PEM 提供给容器
- 容器启动脚本读取 PEM 文件并注入 `JWT_PRIVATE_KEY_PEM/JWT_PUBLIC_KEY_PEM`
#### 6.1.1 启动脚本entrypoint.sh
示例(容器内路径假定为 `/run/secrets/*`
```bash
#!/usr/bin/env sh
set -eu
JWT_PRIVATE_KEY_FILE="${JWT_PRIVATE_KEY_FILE:-/run/secrets/jwt_private_key.pem}"
JWT_PUBLIC_KEY_FILE="${JWT_PUBLIC_KEY_FILE:-/run/secrets/jwt_public_key.pem}"
if [ ! -r "$JWT_PRIVATE_KEY_FILE" ]; then
echo "missing private key file: $JWT_PRIVATE_KEY_FILE" >&2
exit 1
fi
if [ ! -r "$JWT_PUBLIC_KEY_FILE" ]; then
echo "missing public key file: $JWT_PUBLIC_KEY_FILE" >&2
exit 1
fi
export JWT_PRIVATE_KEY_PEM="$(cat "$JWT_PRIVATE_KEY_FILE")"
export JWT_PUBLIC_KEY_PEM="$(cat "$JWT_PUBLIC_KEY_FILE")"
exec /app/iam-service
```
说明:
- `export VAR="$(cat file)"` 会保留 PEM 的换行,适用于多行 env 注入
- 建议在运行时设置 `JWT_KEY_ID`(例如 `2026-02`)用于轮换
#### 6.1.2 Dockerfile示例
下面示例采用多阶段构建,并把二进制与启动脚本放入最终镜像(不写入任何 key
```dockerfile
# syntax=docker/dockerfile:1
FROM rust:1.93 AS builder
WORKDIR /work
COPY . .
RUN cargo build --release -p iam-service
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /work/target/release/iam-service /app/iam-service
COPY ./docker/entrypoint.sh /app/entrypoint.sh
RUN chmod 755 /app/entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/app/entrypoint.sh"]
```
#### 6.1.3 docker run示例
假设宿主机上密钥位于 `./keys/`
```bash
docker run --rm -p 3000:3000 \
--add-host=host.docker.internal:host-gateway \
-e SERVICE_NAME=iam-service \
-e PORT=3000 \
-e DATABASE_URL='postgres://iam_service_user:iam_service_password@host.docker.internal:5432/iam_service_db' \
-e JWT_SECRET='change-me-refresh-token-pepper' \
-e JWT_KEY_ID='2026-02' \
--mount type=bind,source="$(pwd)/keys/jwt_private_key.pem",target=/run/secrets/jwt_private_key.pem,readonly \
--mount type=bind,source="$(pwd)/keys/jwt_public_key.pem",target=/run/secrets/jwt_public_key.pem,readonly \
iam-service:latest
```
#### 6.1.4 验证步骤Docker 场景)
1) 拉取 JWKS应返回当前 `JWT_KEY_ID` 对应 `kid`
```bash
curl -s http://127.0.0.1:3000/.well-known/jwks.json | jq .
```
2) 走业务登录获取 token并在下游服务如 cms-service通过 JWKS 自动验签(详见本文第 4 节与第 5 节)。
## 7. 故障排查指南(常见失败场景)
### 7.1 `Missing kid in JWT header`
- 诊断token header 未携带 `kid`
- 处理:确认签发方设置 `kid`iam-service 已自动设置)
### 7.2 `jwks:kid_not_found`
- 诊断token 的 `kid` 不在 JWKS 中(常见于轮换窗口未包含旧 key
- 处理:将旧公钥加入 `JWT_JWKS_EXTRA_KEYS_JSON`,并等待旧 token 过期后移除
### 7.3 `Invalid JWT public key pem`
- 诊断:公钥 PEM 格式/换行错误或被转义
- 处理:使用文件注入:`export JWT_PUBLIC_KEY_PEM="$(cat file)"`;并用 openssl 校验
### 7.4 `Invalid issuer`
- 诊断:`iss` 不匹配(本项目默认 issuer 为 `iam-service`
- 处理:确保验签端配置 issuer 为 `iam-service`
### 7.5 JWKS 拉取失败导致验签失败
- 诊断IAM 不可用或网络不通
- 处理:依赖 auth-kit JWKS 缓存与 stale-if-error生产上部署多副本 IAM 与 LB
## 8. ECDSAES256路线说明
当前仓库实现为 RS256RSA。如需使用 ECDSAES256需要在签发与验签侧同时切换算法包括 auth-kit 的 JWKS 解析逻辑),并返回 `kty=EC` 的 JWK。

View File

@@ -0,0 +1,45 @@
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
tenant_id: String,
exp: usize,
iat: usize,
iss: String,
}
fn main() {
let issuer = std::env::var("JWT_ISSUER").unwrap_or_else(|_| "iam-service".to_string());
let kid = std::env::var("JWT_KEY_ID").unwrap_or_else(|_| "default".to_string());
let private_pem = std::env::var("JWT_PRIVATE_KEY_PEM").expect("JWT_PRIVATE_KEY_PEM is required");
let tenant_id = std::env::var("TENANT_ID").unwrap_or_else(|_| Uuid::new_v4().to_string());
let user_id = std::env::var("USER_ID").unwrap_or_else(|_| Uuid::new_v4().to_string());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize;
let claims = Claims {
sub: user_id,
tenant_id,
exp: now + 15 * 60,
iat: now,
iss: issuer.clone(),
};
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(kid);
let token = encode(
&header,
&claims,
&EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("invalid private key pem"),
)
.expect("failed to sign token");
println!("{}", token);
}

View File

@@ -7,7 +7,7 @@ use crate::models::{
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery,
UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest,
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, AuthorizationCheckRequest, AuthorizationCheckResponse,
};
use serde_json::Value;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
@@ -140,6 +140,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
handlers::auth::login_handler,
handlers::auth::refresh_handler,
handlers::authorization::my_permissions_handler,
handlers::authorization::authorization_check_handler,
handlers::permission::list_permissions_handler,
handlers::platform::get_tenant_enabled_apps_handler,
handlers::platform::set_tenant_enabled_apps_handler,
@@ -211,6 +212,8 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
RequestAppStatusChangeRequest,
ApproveAppStatusChangeRequest,
AppStatusChangeRequest
,AuthorizationCheckRequest
,AuthorizationCheckResponse
)
),
tags(

View File

@@ -1,9 +1,10 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use axum::extract::State;
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse};
#[utoipa::path(
get,
@@ -58,3 +59,49 @@ pub async fn my_permissions_handler(
.await?;
Ok(AppResponse::ok(permissions))
}
#[utoipa::path(
post,
path = "/authorize/check",
tag = "Policy",
security(
("bearer_auth" = [])
),
request_body = AuthorizationCheckRequest,
responses(
(status = 200, description = "鉴权校验结果", body = AuthorizationCheckResponse),
(status = 401, description = "未认证"),
(status = 403, description = "租户不匹配")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state, body))]
pub async fn authorization_check_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(body): Json<AuthorizationCheckRequest>,
) -> Result<AppResponse<AuthorizationCheckResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
let allowed = match state
.authorization_service
.require_permission(tenant_id, user_id, body.permission.as_str())
.await
{
Ok(()) => true,
Err(AppError::PermissionDenied(_)) => false,
Err(e) => return Err(e),
};
Ok(AppResponse::ok(AuthorizationCheckResponse { allowed }))
}

93
src/handlers/jwks.rs Normal file
View File

@@ -0,0 +1,93 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Serialize)]
struct Jwk {
kty: &'static str,
kid: String,
#[serde(rename = "use")]
use_field: &'static str,
alg: &'static str,
n: String,
e: String,
}
#[derive(Deserialize)]
struct ExtraJwkKey {
kid: String,
public_key_pem: String,
}
fn build_jwks(extra_json: Option<&str>) -> Result<Jwks, &'static str> {
let keys = crate::utils::keys::get_keys();
let mut jwk_keys = vec![Jwk {
kty: "RSA",
kid: keys.kid.clone(),
use_field: "sig",
alg: "RS256",
n: keys.public_n.clone(),
e: keys.public_e.clone(),
}];
if let Some(extra_json) = extra_json {
let extra: Vec<ExtraJwkKey> =
serde_json::from_str(extra_json).map_err(|_| "Invalid JWT_JWKS_EXTRA_KEYS_JSON")?;
for k in extra {
let (n, e) = crate::utils::keys::jwk_components_from_public_pem(&k.public_key_pem)
.map_err(|_| "Invalid public_key_pem in JWT_JWKS_EXTRA_KEYS_JSON")?;
jwk_keys.push(Jwk {
kty: "RSA",
kid: k.kid,
use_field: "sig",
alg: "RS256",
n,
e,
});
}
}
Ok(Jwks { keys: jwk_keys })
}
pub async fn jwks_handler() -> Response {
let extra_json = std::env::var("JWT_JWKS_EXTRA_KEYS_JSON").ok();
match build_jwks(extra_json.as_deref()) {
Ok(jwks) => (StatusCode::OK, Json(jwks)).into_response(),
Err(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::build_jwks;
#[test]
fn build_jwks_rejects_invalid_extra_json() {
assert!(matches!(
build_jwks(Some("not-json")),
Err("Invalid JWT_JWKS_EXTRA_KEYS_JSON")
));
}
#[test]
fn build_jwks_accepts_extra_key() {
let active_public_pem = crate::utils::keys::get_keys().public_pem.clone();
let extra_json = serde_json::json!([{
"kid": "extra-kid",
"public_key_pem": active_public_pem
}])
.to_string();
let jwks = build_jwks(Some(&extra_json)).unwrap();
assert!(jwks.keys.iter().any(|k| k.kid == "extra-kid"));
assert!(jwks.keys.len() >= 2);
}
}

View File

@@ -1,6 +1,7 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod jwks;
pub mod permission;
pub mod platform;
pub mod role;
@@ -18,7 +19,8 @@ pub use app::{
request_app_status_change_handler, update_app_handler,
};
pub use auth::{login_handler, refresh_handler, register_handler};
pub use authorization::my_permissions_handler;
pub use authorization::{authorization_check_handler, my_permissions_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};
pub use role::{

View File

@@ -11,14 +11,16 @@ use axum::{
Router,
http::StatusCode,
middleware::from_fn,
middleware::from_fn_with_state,
routing::{get, post},
};
use config::AppConfig;
use handlers::{
AppState, approve_app_status_change_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,
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,
@@ -35,6 +37,7 @@ use std::net::SocketAddr;
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
// 引入 models 下的所有结构体以生成文档
use auth_kit::jwt::JwtVerifyConfig;
use common_telemetry::telemetry::{self, TelemetryConfig};
use docs::ApiDoc;
@@ -94,8 +97,33 @@ async fn main() {
permission_service,
};
let auth_cfg = middleware::auth::AuthMiddlewareConfig {
skip_exact_paths: vec![
"/.well-known/jwks.json".to_string(),
"/tenants/register".to_string(),
"/auth/register".to_string(),
"/auth/login".to_string(),
"/auth/refresh".to_string(),
],
skip_path_prefixes: vec!["/scalar".to_string()],
jwt: JwtVerifyConfig::rs256_from_pem(
"iam-service",
&crate::utils::keys::get_keys().public_pem,
)
.expect("invalid JWT_PUBLIC_KEY_PEM"),
};
let tenant_cfg = middleware::TenantMiddlewareConfig {
skip_exact_paths: vec![
"/.well-known/jwks.json".to_string(),
"/tenants/register".to_string(),
"/auth/refresh".to_string(),
],
skip_path_prefixes: vec!["/scalar".to_string()],
};
// 5. 构建路由
let api = Router::new()
.route("/.well-known/jwks.json", get(jwks_handler))
.route("/tenants/register", post(create_tenant_handler))
.route(
"/tenants/me",
@@ -123,6 +151,7 @@ async fn main() {
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route("/me/permissions", get(my_permissions_handler))
.route("/authorize/check", post(authorization_check_handler))
.route("/users", get(list_users_handler))
.route("/users/me/password/reset", post(reset_my_password_handler))
.route("/permissions", get(list_permissions_handler))
@@ -157,8 +186,14 @@ async fn main() {
)
.route("/roles/{id}/users/grant", post(grant_role_users_handler))
.route("/roles/{id}/users/revoke", post(revoke_role_users_handler))
.layer(from_fn(middleware::resolve_tenant))
.layer(from_fn(middleware::auth::authenticate))
.layer(from_fn_with_state(
tenant_cfg.clone(),
middleware::resolve_tenant_with_config,
))
.layer(from_fn_with_state(
auth_cfg.clone(),
middleware::auth::authenticate_with_config,
))
.layer(from_fn(
common_telemetry::axum_middleware::trace_http_request,
));
@@ -194,7 +229,10 @@ async fn main() {
"/platform/app-status-change-requests/{request_id}/reject",
post(reject_app_status_change_handler),
)
.layer(from_fn(middleware::auth::authenticate))
.layer(from_fn_with_state(
auth_cfg,
middleware::auth::authenticate_with_config,
))
.layer(from_fn(
common_telemetry::axum_middleware::trace_http_request,
));

View File

@@ -1,68 +1,3 @@
use axum::{
extract::{FromRequestParts, Request},
http::request::Parts,
middleware::Next,
response::Response,
pub use auth_kit::middleware::auth::{
AuthContext, AuthMiddlewareConfig, authenticate_with_config,
};
use common_telemetry::AppError;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct AuthContext {
pub tenant_id: Uuid,
pub user_id: Uuid,
pub roles: Vec<String>,
pub permissions: Vec<String>,
}
pub async fn authenticate(mut req: Request, next: Next) -> Result<Response, AppError> {
let path = req.uri().path();
if path.starts_with("/scalar")
|| path == "/tenants/register"
|| path == "/auth/register"
|| path == "/auth/login"
|| path == "/auth/refresh"
{
return Ok(next.run(req).await);
}
let token = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::MissingAuthHeader)?;
let claims = crate::utils::verify(token)?;
let tenant_id = Uuid::parse_str(&claims.tenant_id)
.map_err(|_| AppError::AuthError("Invalid tenant_id claim".into()))?;
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::AuthError("Invalid sub claim".into()))?;
tracing::Span::current().record("tenant_id", tracing::field::display(tenant_id));
tracing::Span::current().record("user_id", tracing::field::display(user_id));
req.extensions_mut().insert(AuthContext {
tenant_id,
user_id,
roles: claims.roles,
permissions: claims.permissions,
});
Ok(next.run(req).await)
}
impl<S> FromRequestParts<S> for AuthContext
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthContext>()
.cloned()
.ok_or(AppError::MissingAuthHeader)
}
}

View File

@@ -1,89 +1,4 @@
pub mod auth;
pub mod rate_limit;
use axum::extract::FromRequestParts;
use axum::{extract::Request, middleware::Next, response::Response};
use common_telemetry::AppError;
use http::request::Parts;
use uuid::Uuid;
// --- 1. 租户 ID 提取器 ---
#[derive(Clone, Debug)] // 这是一个类型安全的 Wrapper用于在 Handler 中注入
pub struct TenantId(pub Uuid);
pub async fn resolve_tenant(mut req: Request, next: Next) -> Result<Response, AppError> {
let path = req.uri().path();
if path.starts_with("/scalar") || path == "/tenants/register" || path == "/auth/refresh" {
return Ok(next.run(req).await);
}
if let Some(auth_tenant_id) = req
.extensions()
.get::<auth::AuthContext>()
.map(|ctx| ctx.tenant_id)
{
if let Some(header_value) = req
.headers()
.get("X-Tenant-ID")
.and_then(|val| val.to_str().ok())
{
let header_tenant_id = Uuid::parse_str(header_value)
.map_err(|_| AppError::BadRequest("Invalid X-Tenant-ID format".into()))?;
if header_tenant_id != auth_tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
}
tracing::Span::current().record("tenant_id", tracing::field::display(auth_tenant_id));
req.extensions_mut().insert(TenantId(auth_tenant_id));
return Ok(next.run(req).await);
}
// 尝试从 Header 获取 X-Tenant-ID
let tenant_id_str = req
.headers()
.get("X-Tenant-ID")
.and_then(|val| val.to_str().ok());
match tenant_id_str {
Some(id_str) => {
if let Ok(uuid) = Uuid::parse_str(id_str) {
tracing::Span::current().record("tenant_id", tracing::field::display(uuid));
// 验证成功,注入到 Extension 中
req.extensions_mut().insert(TenantId(uuid));
Ok(next.run(req).await)
} else {
Err(AppError::BadRequest("Invalid X-Tenant-ID format".into()))
}
}
None => {
// 如果是公开接口(如登录注册),可能不需要 TenantID视业务而定
// 这里假设严格模式,必须带 TenantID
Err(AppError::BadRequest("Missing X-Tenant-ID header".into()))
}
}
}
// 实现 FromRequestParts 让 Handler 可以直接写 `tid: TenantId`
impl<S> FromRequestParts<S> for TenantId
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(tid) = parts.extensions.get::<TenantId>().cloned() {
return Ok(tid);
}
let tenant_id_str = parts
.headers
.get("X-Tenant-ID")
.and_then(|val| val.to_str().ok());
match tenant_id_str {
Some(id_str) => uuid::Uuid::parse_str(id_str)
.map(TenantId)
.map_err(|_| AppError::BadRequest("Invalid X-Tenant-ID format".into())),
None => Err(AppError::BadRequest("Missing X-Tenant-ID header".into())),
}
}
}
pub use auth_kit::middleware::tenant::{TenantId, TenantMiddlewareConfig, resolve_tenant_with_config};

View File

@@ -407,3 +407,15 @@ pub struct RoleUsersRequest {
#[serde(default)]
pub user_ids: Vec<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct AuthorizationCheckRequest {
#[serde(default)]
pub permission: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
pub struct AuthorizationCheckResponse {
#[serde(default)]
pub allowed: bool,
}

View File

@@ -50,8 +50,9 @@ pub fn sign(
};
let keys = get_keys();
encode(&Header::new(Algorithm::RS256), &claims, &keys.encoding_key)
.map_err(|e| AppError::AuthError(e.to_string()))
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(keys.kid.clone());
encode(&header, &claims, &keys.encoding_key).map_err(|e| AppError::AuthError(e.to_string()))
}
pub fn verify(token: &str) -> Result<Claims, AppError> {

View File

@@ -1,40 +1,76 @@
use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rsa::pkcs1::DecodeRsaPublicKey;
use rsa::pkcs8::{DecodePublicKey, EncodePrivateKey, EncodePublicKey};
use rsa::rand_core::OsRng;
use rsa::traits::PublicKeyParts;
use rsa::{RsaPrivateKey, RsaPublicKey, pkcs1::LineEnding};
use std::sync::OnceLock;
pub struct KeyPair {
pub encoding_key: jsonwebtoken::EncodingKey,
pub decoding_key: jsonwebtoken::DecodingKey,
pub kid: String,
pub public_n: String,
pub public_e: String,
pub public_pem: String,
}
static KEYS: OnceLock<KeyPair> = OnceLock::new();
pub fn get_keys() -> &'static KeyPair {
KEYS.get_or_init(|| {
// In a real production app, you would load these from files or ENV variables
// defined in your AppConfig.
// For now, we generate a fresh key pair on startup.
let kid = std::env::var("JWT_KEY_ID").unwrap_or_else(|_| "default".to_string());
let private_pem = std::env::var("JWT_PRIVATE_KEY_PEM").ok();
let public_pem = std::env::var("JWT_PUBLIC_KEY_PEM").ok();
let (private_pem, public_pem, public_key) = match (private_pem, public_pem) {
(Some(priv_pem), Some(pub_pem)) => {
let public_key = RsaPublicKey::from_pkcs1_pem(&pub_pem)
.or_else(|_| RsaPublicKey::from_public_key_pem(&pub_pem))
.expect("invalid JWT_PUBLIC_KEY_PEM");
(priv_pem, pub_pem, public_key)
}
_ => {
let bits = 2048;
let private_key = RsaPrivateKey::new(&mut OsRng, bits).expect("failed to generate a key");
let private_key =
RsaPrivateKey::new(&mut OsRng, bits).expect("failed to generate a key");
let public_key = RsaPublicKey::from(&private_key);
let private_pem = private_key
.to_pkcs1_pem(LineEnding::LF)
.expect("failed to encode private key");
.to_pkcs8_pem(LineEnding::LF)
.expect("failed to encode private key")
.to_string();
let public_pem = public_key
.to_pkcs1_pem(LineEnding::LF)
.expect("failed to encode public key");
.to_public_key_pem(LineEnding::LF)
.expect("failed to encode public key")
.to_string();
(private_pem, public_pem, public_key)
}
};
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
.expect("failed to create encoding key");
let decoding_key = jsonwebtoken::DecodingKey::from_rsa_pem(public_pem.as_bytes())
.expect("failed to create decoding key");
let public_n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
let public_e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
KeyPair {
encoding_key,
decoding_key,
kid,
public_n,
public_e,
public_pem,
}
})
}
pub fn jwk_components_from_public_pem(public_pem: &str) -> Result<(String, String), String> {
let public_key = RsaPublicKey::from_pkcs1_pem(public_pem)
.or_else(|_| RsaPublicKey::from_public_key_pem(public_pem))
.map_err(|_| "Invalid public key pem".to_string())?;
let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
Ok((n, e))
}

41
tests/jwks_e2e.rs Normal file
View File

@@ -0,0 +1,41 @@
use axum::{Router, routing::get};
use uuid::Uuid;
#[tokio::test]
async fn jwks_endpoint_allows_rs256_verification_via_auth_kit() {
let app = Router::new().route(
"/.well-known/jwks.json",
get(iam_service::handlers::jwks_handler),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let token = iam_service::utils::sign(
Uuid::new_v4(),
Uuid::new_v4(),
vec!["Admin".to_string()],
vec!["tenant:read".to_string()],
vec![],
0,
)
.unwrap();
let cfg = auth_kit::jwt::JwtVerifyConfig::rs256_from_jwks(
"iam-service",
&format!("{}/.well-known/jwks.json", base_url),
)
.unwrap();
let claims = auth_kit::jwt::verify(&token, &cfg).await.unwrap();
assert_eq!(claims.iss, "iam-service");
assert!(!claims.sub.is_empty());
assert!(!claims.tenant_id.is_empty());
handle.abort();
}