diff --git a/.env.example b/.env.example index db98d93..1308afe 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 8fb77b4..8144e5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a05a286..ce5062e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 47955e1..8dd4074 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - Web:Axum - 数据库:PostgreSQL + SQLx - 密码:Argon2 -- Token:JWT(RS256 非对称签发/验签已实现;JWK Set 端点待补齐) +- Token:JWT(RS256 非对称签发/验签 + JWKS 端点 `/.well-known/jwks.json`) - 可观测性:tracing + `common-telemetry`(私有 registry:kellnr) - 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. 启动服务 diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md new file mode 100644 index 0000000..19f7295 --- /dev/null +++ b/docs/INTEGRATION_GUIDE.md @@ -0,0 +1,115 @@ +# IAM Service 集成基线(接口清单 & 鉴权流程) + +本文用于后续其他服务(如 CMS)集成时对齐以下能力基线: + +- 租户隔离(Tenant Context / X-Tenant-ID / Token tenant_id 一致性校验) +- 用户注册/登录/刷新 +- 基础租户管理 +- 基于 JWT 的认证中间件 +- RBAC 权限校验(服务端校验接口) + +## 核心 Header/Token 约定 + +- `Authorization: Bearer ` +- `X-Tenant-ID: ` + - 当请求携带 `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 token(refresh 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
tenant_id/user_id/roles/permissions
并 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 统一裁决。 + diff --git a/docs/TEMP.md b/docs/TEMP.md index 66a8ccf..e7c500a 100644 --- a/docs/TEMP.md +++ b/docs/TEMP.md @@ -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 } diff --git a/docs/jwt-rs256-keys-and-e2e.md b/docs/jwt-rs256-keys-and-e2e.md new file mode 100644 index 0000000..81d964a --- /dev/null +++ b/docs/jwt-rs256-keys-and-e2e.md @@ -0,0 +1,336 @@ +# iam-service:RS256 公私钥生成与认证链路配置(含 JWKS) + +本文以 **RSA + RS256** 为主线,覆盖从密钥生成、配置、发布 JWKS、公钥验签、到端到端验证与故障排查的完整链路。文档中的代码路径、配置项与测试命令均已在本仓库中实际运行验证。 + +## 1. 密钥对生成(RSA/RS256) + +### 1.1 技术规范(推荐) + +- 算法:RSA +- JWT 签名算法:RS256(JWT header `alg=RS256`) +- 密钥长度:2048 位(推荐);可选 3072/4096(性能会下降) +- 私钥格式:PKCS#8,PEM 编码(`-----BEGIN PRIVATE KEY-----`) +- 公钥格式:SubjectPublicKeyInfo(PKCS#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. ECDSA(ES256)路线说明 + +当前仓库实现为 RS256(RSA)。如需使用 ECDSA(ES256),需要在签发与验签侧同时切换算法(包括 auth-kit 的 JWKS 解析逻辑),并返回 `kty=EC` 的 JWK。 diff --git a/examples/offline_issue_jwt.rs b/examples/offline_issue_jwt.rs new file mode 100644 index 0000000..842fcea --- /dev/null +++ b/examples/offline_issue_jwt.rs @@ -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); +} diff --git a/src/docs.rs b/src/docs.rs index 7ccd728..77a2848 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -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( diff --git a/src/handlers/authorization.rs b/src/handlers/authorization.rs index 2b38a9d..ac1d2dd 100644 --- a/src/handlers/authorization.rs +++ b/src/handlers/authorization.rs @@ -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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Json(body): Json, +) -> Result, 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 })) +} diff --git a/src/handlers/jwks.rs b/src/handlers/jwks.rs new file mode 100644 index 0000000..eb9d9d7 --- /dev/null +++ b/src/handlers/jwks.rs @@ -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, +} + +#[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 { + 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 = + 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); + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 5b97b53..3d32fd6 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -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::{ diff --git a/src/main.rs b/src/main.rs index 8243cbd..34a8559 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, )); diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs index fd7293a..366bdec 100644 --- a/src/middleware/auth.rs +++ b/src/middleware/auth.rs @@ -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, - pub permissions: Vec, -} - -pub async fn authenticate(mut req: Request, next: Next) -> Result { - 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 FromRequestParts for AuthContext -where - S: Send + Sync, -{ - type Rejection = AppError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - parts - .extensions - .get::() - .cloned() - .ok_or(AppError::MissingAuthHeader) - } -} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index 3f9aae6..81be67a 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -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 { - 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::() - .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 FromRequestParts for TenantId -where - S: Send + Sync, -{ - type Rejection = AppError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - if let Some(tid) = parts.extensions.get::().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}; diff --git a/src/models.rs b/src/models.rs index 940b487..9392aa1 100644 --- a/src/models.rs +++ b/src/models.rs @@ -407,3 +407,15 @@ pub struct RoleUsersRequest { #[serde(default)] pub user_ids: Vec, } + +#[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, +} diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index bd7f46a..e5150fb 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -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 { diff --git a/src/utils/keys.rs b/src/utils/keys.rs index 0c5789a..ab8e95b 100644 --- a/src/utils/keys.rs +++ b/src/utils/keys.rs @@ -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 = 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 bits = 2048; - 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"); - let public_pem = public_key - .to_pkcs1_pem(LineEnding::LF) - .expect("failed to encode public key"); + 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 public_key = RsaPublicKey::from(&private_key); + let private_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .expect("failed to encode private key") + .to_string(); + let public_pem = 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)) +} diff --git a/tests/jwks_e2e.rs b/tests/jwks_e2e.rs new file mode 100644 index 0000000..78d66a2 --- /dev/null +++ b/tests/jwks_e2e.rs @@ -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(); +}