diff --git a/.env.example b/.env.example index 1308afe..1c395e9 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,32 @@ LOG_DIR=./log LOG_FILE_NAME=iam.log DATABASE_URL=postgres://iam_service_user:iam_service_password@localhost:5432/iam_service_db +REDIS_URL=redis://localhost:6379/0 + +# JWT_SECRET:服务端私密随机串(不是 RS256 私钥) +# 作用:作为 refresh token 指纹(HMAC)pepper,用于 refresh_tokens.token_fingerprint 计算与校验。 +# 要求:生产环境必须使用高强度随机值,定期轮换会导致现有 refresh token 全部失效(通常是可接受的)。 +# openssl rand -base64 64 JWT_SECRET=please_replace_with_a_secure_random_string + +# AUTH_CODE_JWT_SECRET:授权码(code)JWT 的对称签名密钥(HS256)。 +# 作用:iam-service 用它签发/验签 5 分钟一次性 code(/auth/login-code 与 /auth/code2token)。 +# 要求:生产环境必须使用高强度随机值并妥善保管。 +AUTH_CODE_JWT_SECRET=please_replace_with_a_secure_random_string + +# CLIENT_SECRET_PREV_TTL_DAYS:clientSecret 轮换后的旧密钥宽限期(天)。 +# 作用:允许业务方平滑切换新密钥,宽限期内新旧 clientSecret 都可用于 /auth/code2token。 +CLIENT_SECRET_PREV_TTL_DAYS=7 + +# JWT_KEY_ID:RS256 key id(kid),会出现在 JWT header 与 JWKS 中,用于多 Key 管理与轮换。 JWT_KEY_ID=default + +# JWT_PRIVATE_KEY_PEM / JWT_PUBLIC_KEY_PEM:RS256 私钥/公钥(PEM 文本)。 +# 作用:签发 access token(私钥)与提供 JWKS(公钥);业务服务通常使用 JWKS 验签。 JWT_PRIVATE_KEY_PEM= JWT_PUBLIC_KEY_PEM= + +# JWT_JWKS_EXTRA_KEYS_JSON:可选,额外 JWKS keys(JSON 数组),用于灰度轮换/多公钥共存。 JWT_JWKS_EXTRA_KEYS_JSON= PORT=3000 diff --git a/Cargo.lock b/Cargo.lock index 8144e5d..ef5a27f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arc-swap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -174,6 +183,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.22.1" @@ -277,6 +295,20 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "common-telemetry" version = "0.1.5" @@ -664,6 +696,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -708,6 +755,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -732,8 +790,10 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1013,7 +1073,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -1039,7 +1099,9 @@ dependencies = [ "http", "ipnet", "jsonwebtoken", + "percent-encoding", "rand 0.9.2", + "redis", "rsa", "serde", "serde_json", @@ -1051,6 +1113,7 @@ dependencies = [ "tower_governor", "tracing", "tracing-subscriber", + "url", "utoipa", "utoipa-scalar", "uuid", @@ -1210,6 +1273,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1787,7 +1859,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -1824,7 +1896,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -1912,6 +1984,32 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2257,6 +2355,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2330,6 +2434,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -2758,7 +2872,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2858,7 +2972,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2", + "socket2 0.6.2", "sync_wrapper", "tokio", "tokio-stream", diff --git a/Cargo.toml b/Cargo.toml index ce5062e..3abe7d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ rsa = "0.9.10" uuid = { version = "1", features = ["v4", "serde"] } hmac = "0.12.1" sha2 = "0.10.9" +redis = { version = "0.27.6", features = ["tokio-comp", "connection-manager"] } # API 文档 (OpenAPI/Scalar) utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } @@ -68,6 +69,8 @@ tower_governor = { version = "0.8.0", features = ["axum"] } # 时间处理 chrono = "0.4.43" +url = "2.5.4" +percent-encoding = "2.3.2" [dev-dependencies] # 测试工具 diff --git a/LOCAL_E2E_RUNBOOK.md b/LOCAL_E2E_RUNBOOK.md new file mode 100644 index 0000000..794b727 --- /dev/null +++ b/LOCAL_E2E_RUNBOOK.md @@ -0,0 +1,211 @@ +# 本地端到端联调(iam-service + cms-service) + +目标: + +- 生成临时 RSA 公私钥供 iam-service 使用(RS256) +- 启动 iam-service(签发 token、发布 JWKS、提供权限裁决 `/authorize/check`) +- 启动 cms-service(本地验签 + 调用 IAM 做权限裁决) +- 在两个服务的 Scalar 页面完成:创建租户 → 注册用户 → 登录 → 调用 CMS 资源接口,验证“认证/权限由 IAM 控制” + +> 本文不要求修改任何业务代码;只需要本地导出环境变量或创建 `.env` 文件。 + +## 0. 前置条件 + +- Rust(支持 edition 2024)与 Cargo +- PostgreSQL(本机或 Docker),并可使用 `psql` +- `openssl` + +端口默认: + +- iam-service:3000 +- cms-service:3100 + +## 1. 准备数据库(推荐:Docker 一键起 PostgreSQL) + +启动 PostgreSQL: + +```bash +docker run --rm -d --name local-pg \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + postgres:16 +``` + +创建 iam/cms 两个数据库与用户(示例账号,可按需调整): + +```bash +docker exec -i local-pg psql -U postgres <<'SQL' +CREATE USER iam_service_user WITH PASSWORD 'iam_service_password'; +CREATE DATABASE iam_service_db OWNER iam_service_user; +GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user; + +CREATE USER cms_service_user WITH PASSWORD 'cms_service_password'; +CREATE DATABASE cms_service_db OWNER cms_service_user; +GRANT ALL PRIVILEGES ON DATABASE cms_service_db TO cms_service_user; +SQL +``` + +准备连接串(后续会分别用于两服务): + +- IAM:`postgres://iam_service_user:iam_service_password@127.0.0.1:5432/iam_service_db` +- CMS:`postgres://cms_service_user:cms_service_password@127.0.0.1:5432/cms_service_db` + +## 2. 生成临时 RSA 公私钥(RS256) + +```bash +KEY_DIR="$(mktemp -d)" +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$KEY_DIR/jwt_private_key.pem" +openssl pkey -in "$KEY_DIR/jwt_private_key.pem" -pubout -out "$KEY_DIR/jwt_public_key.pem" + +chmod 600 "$KEY_DIR/jwt_private_key.pem" +chmod 644 "$KEY_DIR/jwt_public_key.pem" + +echo "KEY_DIR=$KEY_DIR" +``` + +说明: + +- 私钥:PKCS#8 PEM(`BEGIN PRIVATE KEY`) +- 公钥:PKCS#8 公钥 PEM(`BEGIN PUBLIC KEY`) +- 该目录是临时目录,重启/清理后会消失;适合本地联调 + +## 3. 启动 iam-service(签发 token + JWKS + 权限裁决) + +在一个终端中执行(建议在 `iam-service` 目录下): + +```bash +cd /home/shay/project/backend/iam-service + +export PORT=3000 +export DATABASE_URL='postgres://iam_service_user:iam_service_password@127.0.0.1:5432/iam_service_db' + +# 用于 refresh token 指纹(不是 JWT 签名密钥),本地随便填一个即可 +export JWT_SECRET='local-dev-refresh-token-pepper' + +export JWT_KEY_ID='local-dev' +export JWT_PRIVATE_KEY_PEM="$(cat "$KEY_DIR/jwt_private_key.pem")" +export JWT_PUBLIC_KEY_PEM="$(cat "$KEY_DIR/jwt_public_key.pem")" + +# 初始化/重建 DB(会清库重建,适合开发环境) +BACKUP=0 ./scripts/db/rebuild_iam_db.sh + +cargo run +``` + +启动后: + +- IAM Scalar:`http://127.0.0.1:3000/scalar` +- JWKS:`http://127.0.0.1:3000/.well-known/jwks.json` + +## 4. 启动 cms-service(本地验签 + 调 IAM 裁权) + +在另一个终端中执行(建议在 `cms-service` 目录下): + +```bash +cd /home/shay/project/backend/cms-service + +export PORT=3100 +export DATABASE_URL='postgres://cms_service_user:cms_service_password@127.0.0.1:5432/cms_service_db' + +export IAM_BASE_URL='http://127.0.0.1:3000' + +# CMS 默认会走 IAM_BASE_URL + /.well-known/jwks.json 拉取公钥 +# 如需显式指定: +# export IAM_JWKS_URL='http://127.0.0.1:3000/.well-known/jwks.json' + +# 初始化/重建 DB(会回滚并重建 cms schema;适合开发环境) +./scripts/db/rebuild_cms_db.sh + +cargo run +``` + +启动后: + +- CMS Scalar:`http://127.0.0.1:3100/scalar` + +## 5. 在 Scalar 中跑通“租户 → 注册 → 登录 → 调用 CMS 接口” + +### 5.1 在 IAM Scalar 创建租户 + +打开 `http://127.0.0.1:3000/scalar`: + +1) 调用 `POST /tenants/register` + +示例 body: + +```json +{ "name": "Tenant A" } +``` + +从响应中拿到 `tenant_id`(UUID)。 + +### 5.2 在 IAM Scalar 注册首个用户(自动 Admin + CMS 权限) + +调用 `POST /auth/register`: + +- Header:`X-Tenant-ID: ` +- Body: + +```json +{ "email": "admin@example.com", "password": "Passw0rd!" } +``` + +重要说明: + +- 该租户的**首个用户**会自动触发 bootstrap:创建/获取 `Admin` 角色,并授予该租户下的全量非 `iam:*` 权限(包含 `cms:*` 权限),再绑定给该用户。 + +### 5.3 在 IAM Scalar 登录拿 token + +调用 `POST /auth/login`: + +- Header:`X-Tenant-ID: ` +- Body: + +```json +{ "email": "admin@example.com", "password": "Passw0rd!" } +``` + +保存响应中的: + +- `access_token` +- `refresh_token`(可选,用于测试刷新) + +### 5.4 在 CMS Scalar 调用资源接口(验证由 IAM 裁权) + +打开 `http://127.0.0.1:3100/scalar`,选择任意需要权限的接口,例如: + +- `POST /v1/columns`(需要 `cms:column:write`) + +在请求中设置: + +- `Authorization: Bearer ` +- `X-Tenant-ID: ` + +若一切正常: + +- CMS 会先本地验签 token(auth-kit) +- 然后调用 IAM 的 `POST /authorize/check` 判断权限 +- IAM 返回 allowed 后,CMS 才会执行业务写入 + +验证建议: + +- 观察 iam-service 控制台日志:应能看到 `/authorize/check` 的请求 +- 如将 `access_token` 改成无效字符串,CMS 会直接返回 401(本地验签失败,不会去请求 IAM) + +## 6. 常见问题(本地联调) + +### 6.1 CMS 返回 401 MissingAuthHeader + +- 检查是否在 CMS 请求里设置了 `Authorization: Bearer ` +- 检查 token 是否复制完整(不要带引号/空格) + +### 6.2 CMS 返回 400 Missing X-Tenant-ID header + +- CMS 需要 `X-Tenant-ID` +- 若同时提供 token + header,但 tenant_id 不一致会返回 403(tenant:mismatch) + +### 6.3 CMS 返回 403 PermissionDenied + +- 表示 IAM 裁权拒绝(例如用户没有 `cms:*` 权限) +- 你可以在 IAM 中调用 `GET /me/permissions` 查看当前用户权限 + diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 19f7295..ea1e39e 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -19,13 +19,20 @@ ### 文档 - `GET /scalar`:Scalar UI +- SSO 授权码接入:`docs/SSO_INTEGRATION.md` ### Auth(公开) - `POST /tenants/register`:创建租户(初始租户管理员账号由后续 `/auth/register` + 首用户 bootstrap 完成) - `POST /auth/register`:用户注册(需要 `X-Tenant-ID`) - `POST /auth/login`:用户登录(需要 `X-Tenant-ID`) +- `POST /auth/login-code`:用户名密码签发一次性授权码(SSO,需要 `X-Tenant-ID`,并校验 redirectUri allowlist) - `POST /auth/refresh`:刷新 access token(refresh token 一次性轮换) +- `POST /auth/code2token`:授权码换取 token(SSO) + +### Auth(需认证) + +- `POST /auth/logout`:退出登录(吊销 refresh token) ### Tenant(需认证 + 权限) @@ -73,6 +80,10 @@ - `GET /platform/tenants/{tenant_id}/enabled-apps` - `PUT /platform/tenants/{tenant_id}/enabled-apps` +- `GET /platform/clients` +- `POST /platform/clients` +- `PUT /platform/clients/{client_id}/redirect-uris` +- `POST /platform/clients/{client_id}/rotate-secret` - `GET /platform/apps` - `POST /platform/apps` - `GET /platform/apps/{app_id}` @@ -112,4 +123,3 @@ flowchart TD - `authenticate`(解析 token 并注入 user/tenant 字段到 span) - `resolve_tenant`(统一 TenantId 注入,并校验 X-Tenant-ID 与 token tenant 一致性) - 权限校验禁止在业务侧实现一套 RBAC 聚合逻辑;应通过 `POST /authorize/check` 由 IAM 统一裁决。 - diff --git a/docs/SSO_INTEGRATION.md b/docs/SSO_INTEGRATION.md new file mode 100644 index 0000000..9a82c10 --- /dev/null +++ b/docs/SSO_INTEGRATION.md @@ -0,0 +1,162 @@ +# 业务服务接入指引(SSO 授权码模式) + +本指引描述业务服务(如 CMS/TMS)如何接入统一登录页(iam-front)与 IAM(iam-service),实现单点登录(Authorization Code → Token)。 + +## 1. 关键约定 + +- 登录页:`{IAM_FRONT_BASE_URL}/login?clientId={clientId}&tenantId={tenantId}&callback={encodeURIComponent(redirectUri)}` +- 授权码(code):JWT(HS256),有效期 5 分钟,Redis 单次使用 +- 换取 token:业务服务端携带 `clientSecret` 调用 + - `POST {IAM_SERVICE_BASE_URL}/iam/api/v1/auth/code2token` +- 签发授权码:由 IAM 服务端完成(校验 redirectUri 是否在该 clientId 的 allowlist 中) + - `POST {IAM_SERVICE_BASE_URL}/iam/api/v1/auth/login-code` +- token 刷新:`POST {IAM_SERVICE_BASE_URL}/iam/api/v1/auth/refresh` +- 退出:`POST {IAM_SERVICE_BASE_URL}/iam/api/v1/auth/logout` + +## 2. 业务 Next.js(前端)接入:中间件跳转登录 + +### 2.1 最小示例:缺少 token 则 302 去登录 + +在业务项目根目录创建 `/src/middleware.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server" + +function isExpired(jwt: string): boolean { + const parts = jwt.split(".") + if (parts.length < 2) return true + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/") + const padLen = (4 - (normalized.length % 4)) % 4 + const json = atob(normalized + "=".repeat(padLen)) + const payload = JSON.parse(json) as { exp?: number } + if (!payload.exp) return true + return Math.floor(Date.now() / 1000) >= payload.exp +} + +export function middleware(req: NextRequest) { + const tenantId = req.headers.get("x-tenant-id") ?? "" + const accessToken = req.cookies.get("accessToken")?.value ?? "" + + if (!accessToken || isExpired(accessToken)) { + const currentUrl = req.nextUrl.clone() + const callback = `${process.env.CMS_SERVICE_BASE_URL}/auth/callback?next=${encodeURIComponent( + currentUrl.toString(), + )}` + const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent( + process.env.CMS_CLIENT_ID ?? "", + )}&tenantId=${encodeURIComponent( + tenantId, + )}&callback=${encodeURIComponent(callback)}` + return NextResponse.redirect(loginUrl, 302) + } + + return NextResponse.next() +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|api/public).*)"], +} +``` + +说明: +- 示例使用 cookie `accessToken`;你也可以改为本地存储或自定义 header。 +- 校验是否过期仅做 payload 解析(不验签);服务端真正鉴权仍应使用 `auth-kit` + RS256 验签。 + +## 3. 业务 Axum(后端)接入:code → token(服务端通道) + +### 3.1 业务后端回调接口(接收 code) + +用户从 iam-front 回跳到 `callbackUrl?code=...` 后,建议业务服务由后端处理 code 换 token,并以 HttpOnly Cookie 存储 refresh token;若换取失败,可重定向到业务前端的错误页(如 `/auth-error`)给出提示。 + +redirectUri allowlist 建议配置为“固定回调地址(scheme+host+port+path)”,不要把 `next=...` 这类动态参数写进 allowlist,例如: + +- ✅ `http://localhost:5031/auth/callback` +- ❌ `http://localhost:5031/auth/callback?next=http%3A%2F%2Flocalhost%3A6031%2F` + +```rust +use axum::{extract::Query, response::Redirect, routing::get, Router}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct CallbackQuery { + code: String, +} + +async fn sso_callback(Query(q): Query) -> Redirect { + let iam_base = std::env::var("IAM_SERVICE_BASE_URL").unwrap(); + let client_id = std::env::var("IAM_CLIENT_ID").unwrap(); + let client_secret = std::env::var("IAM_CLIENT_SECRET").unwrap(); + + let http = reqwest::Client::new(); + let resp = http + .post(format!("{}/iam/api/v1/auth/code2token", iam_base.trim_end_matches('/'))) + .json(&serde_json::json!({ + "code": q.code, + "clientId": client_id, + "clientSecret": client_secret + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let data = &resp["data"]; + let access_token = data["accessToken"].as_str().unwrap_or_default(); + let refresh_token = data["refreshToken"].as_str().unwrap_or_default(); + + let target = format!("/?loggedIn=1"); + let mut r = Redirect::temporary(&target).into_response(); + r.headers_mut().append( + axum::http::header::SET_COOKIE, + format!("accessToken={}; Path=/; Secure; SameSite=Strict", access_token) + .parse() + .unwrap(), + ); + r.headers_mut().append( + axum::http::header::SET_COOKIE, + format!("refreshToken={}; HttpOnly; Path=/; Secure; SameSite=Strict", refresh_token) + .parse() + .unwrap(), + ); + r +} + +pub fn router() -> Router { + Router::new().route("/auth/callback", get(sso_callback)) +} +``` + +### 3.2 后端鉴权与自动刷新 + +推荐: +- 业务后端对受保护接口强制 `Authorization: Bearer `(来自 cookie 或前端 header)。 +- 过期时由业务后端调用 `POST /iam/api/v1/auth/refresh`,轮换 refresh token,并回写 cookie。 + +## 4. clientId / clientSecret 管理 + +由平台管理员通过 IAM 平台接口创建与轮换(仅示例,实际需要具备平台权限码 `iam:client:*`): + +```bash +curl -X POST "$IAM_SERVICE_BASE_URL/iam/api/v1/platform/clients" \ + -H "Authorization: Bearer $PLATFORM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "clientId": "cms", "name": "CMS", "redirectUris": ["https://cms-api.example.com/auth/callback"] }' +``` + +轮换: + +```bash +curl -X POST "$IAM_SERVICE_BASE_URL/iam/api/v1/platform/clients/cms/rotate-secret" \ + -H "Authorization: Bearer $PLATFORM_TOKEN" +``` + +更新允许回调地址(redirectUris): + +```bash +curl -X PUT "$IAM_SERVICE_BASE_URL/iam/api/v1/platform/clients/cms/redirect-uris" \ + -H "Authorization: Bearer $PLATFORM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "redirectUris": ["https://cms-api.example.com/auth/callback"] }' +``` diff --git a/docs/TEMP.md b/docs/TEMP.md index e7c500a..20cb556 100644 --- a/docs/TEMP.md +++ b/docs/TEMP.md @@ -20,14 +20,21 @@ `shay7sev@gmail.com` `tenantdev1` +```json +{ + "clientId": "cms", + "clientSecret": "2adbc0d720b687a6d05df32942c2919b0adcbd579c23ecd9cbb27f7a7a7e3326" +} +``` + ```json { "code": 0, "message": "Success", "data": { - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwMTQxNTIsImlhdCI6MTc3MDAxMzI1MiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnBhc3N3b3JkOnJlc2V0OmFueSIsInVzZXI6cmVhZCIsInVzZXI6d3JpdGUiXSwiYXBwcyI6WyJjbXMiXSwiYXBwc192ZXJzaW9uIjoxfQ.Myh55kW5xknQNvECzz4U3Ojq-T-gmulaJJAkpa92gF66CDbN9lCLlK0hZOAbzpsABSOBtH0VKFCZJ0rfX1PamWxyp0nl3SBHiQMR5owy0dMqJsA8UDvaibL_-MxXRQgIL3Bpm3nd1l6GtrnnRXL4qBy5UleD_d89RqUPhF0FV34T-RwSHYSPs_0h33DNI4gD564Jjkn6-t6Z4CpR34OnqeMRuR6OugZzyoa1w0D2xzC6qohfwyIQMffP99OejvJUPis36vcvxOY3SILXwdooFc_CLT0glx2IRoeerJJpoQF40Dz3lWAhBI5-4CfORztwPfxuC441uzA3PaR2DqI-kw", - "refresh_token": "d19095aca07c32fb8e660b93a2cc5c0660e78ae490dac3a56b93f755761b5e50", + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwMjU5OTQsImlhdCI6MTc3MDAyNTA5NCwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.NNfdO14PRxkLa5Kkiz5SZ0tDnbrXvVTgOsU65Xg8jSbowrRsbdO3N_fBpEaSxJ3n2DhtD0uYZyRABuCBVWCncxk0RDUWXhoHVXucEFA1Br6I4niZTfIbnv-L1M-Q1fNvPGZE2DQ8Os9K5b2F91kpcwkaR-vQgE9oyFeq1xhQ-MR7YeQLXgLk9UQpWyD2Yj3VIWyFYiG94JX9eI6iJsOJZayqSXaeid50c5R4Z9lq9SQ07ZmFTqZFitCrPrQRY_wh6OeeQrHF33HMKC3yQ1jq4XyiNlDIzLIzDerUpK5UtLdz9Cntt31yg-2tsj2nSMUZLssllMZZaPjFUTMFeu0egQ", + "refresh_token": "bd60d869926bac781dd04ad4b340f79624c4da35373c85865bd4627093714e2e", "token_type": "Bearer", "expires_in": 900 } @@ -51,5 +58,4 @@ ``` ```text -permission项你是推荐数据库迁移的方式新增吗?permission项的管理的最佳方式是什么? ``` \ No newline at end of file diff --git a/scripts/db/migrations/0007_oauth_clients.sql b/scripts/db/migrations/0007_oauth_clients.sql new file mode 100644 index 0000000..f8b2757 --- /dev/null +++ b/scripts/db/migrations/0007_oauth_clients.sql @@ -0,0 +1,28 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id VARCHAR(64) PRIMARY KEY, + name VARCHAR(255), + secret_hash VARCHAR(255) NOT NULL, + prev_secret_hash VARCHAR(255), + prev_expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_oauth_clients_updated_at ON oauth_clients(updated_at); + +INSERT INTO permissions (code, description, resource, action) VALUES +('iam:client:read', 'List OAuth clients', 'client', 'read'), +('iam:client:write', 'Create/Rotate OAuth clients', 'client', 'write') +ON CONFLICT (code) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code IN ('iam:client:read', 'iam:client:write') +WHERE r.is_system = TRUE +ON CONFLICT DO NOTHING; + +COMMIT; + diff --git a/scripts/db/migrations/0008_oauth_client_redirect_uris.sql b/scripts/db/migrations/0008_oauth_client_redirect_uris.sql new file mode 100644 index 0000000..f26fa81 --- /dev/null +++ b/scripts/db/migrations/0008_oauth_client_redirect_uris.sql @@ -0,0 +1,7 @@ +BEGIN; + +ALTER TABLE oauth_clients + ADD COLUMN IF NOT EXISTS redirect_uris JSONB NOT NULL DEFAULT '[]'::jsonb; + +COMMIT; + diff --git a/scripts/db/rollback/0007.down.sql b/scripts/db/rollback/0007.down.sql new file mode 100644 index 0000000..93c6ca5 --- /dev/null +++ b/scripts/db/rollback/0007.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +DELETE FROM permissions +WHERE code IN ('iam:client:read', 'iam:client:write'); + +DROP TABLE IF EXISTS oauth_clients; + +COMMIT; + diff --git a/scripts/db/rollback/0008.down.sql b/scripts/db/rollback/0008.down.sql new file mode 100644 index 0000000..5bdd973 --- /dev/null +++ b/scripts/db/rollback/0008.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +ALTER TABLE oauth_clients + DROP COLUMN IF EXISTS redirect_uris; + +COMMIT; + diff --git a/scripts/db/verify/0007_oauth_clients.sql b/scripts/db/verify/0007_oauth_clients.sql new file mode 100644 index 0000000..9f550c0 --- /dev/null +++ b/scripts/db/verify/0007_oauth_clients.sql @@ -0,0 +1,15 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'oauth_clients' + ) THEN + RAISE EXCEPTION 'missing oauth_clients table'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'iam:client:write') THEN + RAISE EXCEPTION 'missing iam client permissions seed'; + END IF; +END $$; + diff --git a/scripts/db/verify/0008_oauth_client_redirect_uris.sql b/scripts/db/verify/0008_oauth_client_redirect_uris.sql new file mode 100644 index 0000000..2e7fb43 --- /dev/null +++ b/scripts/db/verify/0008_oauth_client_redirect_uris.sql @@ -0,0 +1,8 @@ +BEGIN; + +SELECT redirect_uris +FROM oauth_clients +LIMIT 1; + +ROLLBACK; + diff --git a/src/config/mod.rs b/src/config/mod.rs index 1b1d3d2..3513197 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,9 +8,12 @@ pub struct AppConfig { pub log_dir: String, pub log_file_name: String, pub database_url: String, + pub redis_url: String, pub db_max_connections: u32, pub db_min_connections: u32, pub jwt_secret: String, + pub auth_code_jwt_secret: String, + pub client_secret_prev_ttl_days: u32, pub port: u16, } @@ -25,9 +28,16 @@ impl AppConfig { log_dir: env::var("LOG_DIR").unwrap_or_else(|_| "./log".into()), log_file_name: env::var("LOG_FILE_NAME").unwrap_or_else(|_| "iam.log".into()), database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL environment variable is required")?, + redis_url: env::var("REDIS_URL").map_err(|_| "REDIS_URL environment variable is required")?, db_max_connections: env::var("DB_MAX_CONNECTIONS").unwrap_or("20".into()).parse().map_err(|_| "DB_MAX_CONNECTIONS must be a number")?, db_min_connections: env::var("DB_MIN_CONNECTIONS").unwrap_or("5".into()).parse().map_err(|_| "DB_MIN_CONNECTIONS must be a number")?, jwt_secret: env::var("JWT_SECRET").map_err(|_| "JWT_SECRET environment variable is required")?, + auth_code_jwt_secret: env::var("AUTH_CODE_JWT_SECRET") + .map_err(|_| "AUTH_CODE_JWT_SECRET environment variable is required")?, + client_secret_prev_ttl_days: env::var("CLIENT_SECRET_PREV_TTL_DAYS") + .unwrap_or_else(|_| "7".to_string()) + .parse() + .map_err(|_| "CLIENT_SECRET_PREV_TTL_DAYS must be a number")?, port: env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() diff --git a/src/docs.rs b/src/docs.rs index 77a2848..9dea45c 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -2,12 +2,15 @@ use crate::handlers; use crate::models::{ AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest, ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest, - CreateUserRequest, ListAppsQuery, LoginRequest, LoginResponse, - RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant, - TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery, + ClientSummary, Code2TokenRequest, Code2TokenResponse, CreateClientRequest, CreateClientResponse, + CreateUserRequest, ListAppsQuery, ListPermissionsQuery, LoginRequest, LoginResponse, Permission, + RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, + RotateClientSecretResponse, Tenant, TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest, UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest, UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, AuthorizationCheckRequest, AuthorizationCheckResponse, + UpdateClientRedirectUrisRequest, + LoginCodeRequest, LoginCodeResponse, }; use serde_json::Value; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; @@ -138,7 +141,14 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg: paths( handlers::auth::register_handler, handlers::auth::login_handler, + handlers::sso::login_code_handler, handlers::auth::refresh_handler, + handlers::auth::logout_handler, + handlers::sso::code2token_handler, + handlers::client::create_client_handler, + handlers::client::rotate_client_secret_handler, + handlers::client::list_clients_handler, + handlers::client::update_client_redirect_uris_handler, handlers::authorization::my_permissions_handler, handlers::authorization::authorization_check_handler, handlers::permission::list_permissions_handler, @@ -185,7 +195,16 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg: UpdateUserRequest, LoginRequest, LoginResponse, + LoginCodeRequest, + LoginCodeResponse, RefreshTokenRequest, + Code2TokenRequest, + Code2TokenResponse, + CreateClientRequest, + CreateClientResponse, + RotateClientSecretResponse, + ClientSummary, + UpdateClientRedirectUrisRequest, Permission, ListPermissionsQuery, Role, @@ -218,6 +237,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg: ), tags( (name = "Auth", description = "认证:注册/登录/令牌"), + (name = "Client", description = "客户端:clientId/clientSecret 管理(平台级)"), (name = "Tenant", description = "租户:创建/查询/更新/状态/删除"), (name = "User", description = "用户:查询/列表/更新/删除(需权限)"), (name = "Role", description = "角色:创建/列表(需权限)"), diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 25ad50c..3a49e11 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,5 +1,6 @@ use crate::handlers::AppState; use crate::middleware::TenantId; +use crate::middleware::auth::AuthContext; use crate::models::{ CreateUserRequest, LoginRequest, LoginResponse, RefreshTokenRequest, UserResponse, }; @@ -93,3 +94,29 @@ pub async fn refresh_handler( .await?; Ok(AppResponse::ok(response)) } + +/// Logout (revoke all refresh tokens for current user). +/// 退出登录(吊销当前用户所有 refresh token)。 +#[utoipa::path( + post, + path = "/auth/logout", + tag = "Auth", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "Logged out"), + (status = 401, description = "Unauthorized") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)") + ) +)] +#[instrument(skip(state))] +pub async fn logout_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, +) -> Result, AppError> { + state.auth_service.logout(user_id).await?; + Ok(AppResponse::ok(serde_json::json!({}))) +} diff --git a/src/handlers/client.rs b/src/handlers/client.rs new file mode 100644 index 0000000..9d30583 --- /dev/null +++ b/src/handlers/client.rs @@ -0,0 +1,185 @@ +use crate::handlers::AppState; +use crate::middleware::auth::AuthContext; +use crate::models::{ + ClientSummary, CreateClientRequest, CreateClientResponse, RotateClientSecretResponse, + UpdateClientRedirectUrisRequest, +}; +use axum::{ + Json, + extract::{Path, State}, +}; +use common_telemetry::{AppError, AppResponse}; +use tracing::instrument; + +/// Create a new client and return its secret (shown once). +/// 创建 client 并返回 clientSecret(仅展示一次)。 +#[utoipa::path( + post, + path = "/platform/clients", + tag = "Client", + security( + ("bearer_auth" = []) + ), + request_body = CreateClientRequest, + responses( + (status = 201, description = "Created", body = CreateClientResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)") + ) +)] +#[instrument(skip(state, payload))] +pub async fn create_client_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Json(payload): Json, +) -> Result, AppError> { + state + .authorization_service + .require_platform_permission(user_id, "iam:client:write") + .await?; + + let secret = state + .client_service + .create_client( + payload.client_id.clone(), + payload.name.clone(), + payload.redirect_uris.clone(), + ) + .await?; + + Ok(AppResponse::created(CreateClientResponse { + client_id: payload.client_id, + client_secret: secret, + })) +} + +/// Update allowed redirect URIs for a client. +/// 更新 client 的允许回调地址(redirectUris)。 +#[utoipa::path( + put, + path = "/platform/clients/{client_id}/redirect-uris", + tag = "Client", + security( + ("bearer_auth" = []) + ), + request_body = UpdateClientRedirectUrisRequest, + responses( + (status = 200, description = "Updated"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("client_id" = String, Path, description = "clientId") + ) +)] +#[instrument(skip(state, payload))] +pub async fn update_client_redirect_uris_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(client_id): Path, + Json(payload): Json, +) -> Result, AppError> { + state + .authorization_service + .require_platform_permission(user_id, "iam:client:write") + .await?; + + state + .client_service + .set_redirect_uris(client_id, payload.redirect_uris) + .await?; + + Ok(AppResponse::ok(serde_json::json!({}))) +} + +/// Rotate client secret (previous secret stays valid for grace period). +/// 轮换 clientSecret(旧密钥在宽限期内仍可用)。 +#[utoipa::path( + post, + path = "/platform/clients/{client_id}/rotate-secret", + tag = "Client", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "Rotated", body = RotateClientSecretResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("client_id" = String, Path, description = "clientId") + ) +)] +#[instrument(skip(state))] +pub async fn rotate_client_secret_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(client_id): Path, +) -> Result, AppError> { + state + .authorization_service + .require_platform_permission(user_id, "iam:client:write") + .await?; + + let secret = state + .client_service + .rotate_secret(client_id.clone()) + .await?; + Ok(AppResponse::ok(RotateClientSecretResponse { + client_id, + client_secret: secret, + })) +} + +/// List clients (secrets are never returned). +/// 查询 client 列表(不返回 secret)。 +#[utoipa::path( + get, + path = "/platform/clients", + tag = "Client", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "OK", body = [ClientSummary]), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)") + ) +)] +#[instrument(skip(state))] +pub async fn list_clients_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, +) -> Result>, AppError> { + state + .authorization_service + .require_platform_permission(user_id, "iam:client:read") + .await?; + + let rows = state.client_service.list_clients().await?; + let clients = rows + .into_iter() + .map( + |(client_id, name, redirect_uris, created_at, updated_at)| ClientSummary { + client_id, + name, + redirect_uris, + created_at, + updated_at, + }, + ) + .collect(); + + Ok(AppResponse::ok(clients)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 3d32fd6..5f6c0e3 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,25 +1,32 @@ pub mod app; pub mod auth; pub mod authorization; +pub mod client; pub mod jwks; pub mod permission; pub mod platform; pub mod role; +pub mod sso; pub mod tenant; pub mod user; use crate::services::{ - AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService, - UserService, + AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService, + TenantService, UserService, }; +use redis::aio::ConnectionManager; pub use app::{ approve_app_status_change_handler, create_app_handler, delete_app_handler, get_app_handler, list_app_status_change_requests_handler, list_apps_handler, reject_app_status_change_handler, request_app_status_change_handler, update_app_handler, }; -pub use auth::{login_handler, refresh_handler, register_handler}; +pub use auth::{login_handler, logout_handler, refresh_handler, register_handler}; pub use authorization::{authorization_check_handler, my_permissions_handler}; +pub use client::{ + create_client_handler, list_clients_handler, rotate_client_secret_handler, + update_client_redirect_uris_handler, +}; pub use jwks::jwks_handler; pub use permission::list_permissions_handler; pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler}; @@ -28,6 +35,7 @@ pub use role::{ grant_role_users_handler, list_roles_handler, revoke_role_permissions_handler, revoke_role_users_handler, update_role_handler, }; +pub use sso::{code2token_handler, login_code_handler}; pub use tenant::{ create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler, update_tenant_status_handler, @@ -42,10 +50,13 @@ pub use user::{ #[derive(Clone)] pub struct AppState { pub auth_service: AuthService, + pub client_service: ClientService, pub user_service: UserService, pub role_service: RoleService, pub tenant_service: TenantService, pub authorization_service: AuthorizationService, pub app_service: AppService, pub permission_service: PermissionService, + pub redis: ConnectionManager, + pub auth_code_jwt_secret: String, } diff --git a/src/handlers/sso.rs b/src/handlers/sso.rs new file mode 100644 index 0000000..7922438 --- /dev/null +++ b/src/handlers/sso.rs @@ -0,0 +1,219 @@ +use crate::handlers::AppState; +use crate::middleware::TenantId; +use crate::models::{Code2TokenRequest, Code2TokenResponse, LoginCodeRequest, LoginCodeResponse}; +use anyhow::anyhow; +use axum::{Json, extract::State}; +use common_telemetry::{AppError, AppResponse}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use redis::AsyncCommands; +use redis::Script; +use serde::Deserialize; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Debug, Deserialize, serde::Serialize)] +struct AuthCodeClaims { + sub: String, + tenant_id: String, + client_id: Option, + redirect_uri: Option, + exp: usize, + iat: usize, + iss: String, + jti: String, +} + +#[derive(Debug, Deserialize)] +struct AuthCodeRedisValue { + user_id: String, + tenant_id: String, + client_id: Option, + redirect_uri: Option, +} + +fn redis_key(jti: &str) -> String { + format!("iam:auth_code:{}", jti) +} + +/// Exchange one-time authorization code to access/refresh token. +/// 授权码换取 token(一次性 code,5 分钟有效,单次使用)。 +#[utoipa::path( + post, + path = "/auth/code2token", + tag = "Auth", + request_body = Code2TokenRequest, + responses( + (status = 200, description = "Token issued", body = Code2TokenResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ) +)] +#[instrument(skip(state, payload))] +pub async fn code2token_handler( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + state + .client_service + .verify_client_secret(&payload.client_id, &payload.client_secret) + .await?; + + if payload.code.trim().is_empty() { + return Err(AppError::BadRequest("code is required".into())); + } + + let mut validation = Validation::new(Algorithm::HS256); + validation.set_issuer(&["iam-front", "iam-service"]); + + let token_data = decode::( + payload.code.trim(), + &DecodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()), + &validation, + ) + .map_err(|e| AppError::AuthError(e.to_string()))?; + + let claims = token_data.claims; + if let Some(cid) = &claims.client_id { + if cid != payload.client_id.trim() { + return Err(AppError::AuthError("Invalid code".into())); + } + } + let jti = claims.jti.trim(); + if jti.is_empty() { + return Err(AppError::AuthError("Invalid code".into())); + } + + let script = Script::new( + r#" + local v = redis.call('GET', KEYS[1]) + if v then + redis.call('DEL', KEYS[1]) + end + return v + "#, + ); + + let key = redis_key(jti); + let mut conn = state.redis.clone(); + let val: Option = script + .key(key) + .invoke_async(&mut conn) + .await + .map_err(|e| AppError::AnyhowError(anyhow!(e)))?; + + let Some(val) = val else { + return Err(AppError::AuthError("Invalid or used code".into())); + }; + + let stored: AuthCodeRedisValue = + serde_json::from_str(&val).map_err(|_| AppError::AuthError("Invalid code".into()))?; + + if let Some(cid) = stored.client_id.as_deref() { + if cid != payload.client_id.trim() { + return Err(AppError::AuthError("Invalid code".into())); + } + } + + if stored.user_id != claims.sub || stored.tenant_id != claims.tenant_id { + return Err(AppError::AuthError("Invalid code".into())); + } + + let user_id = + Uuid::parse_str(&stored.user_id).map_err(|_| AppError::AuthError("Invalid code".into()))?; + let tenant_id = Uuid::parse_str(&stored.tenant_id) + .map_err(|_| AppError::AuthError("Invalid code".into()))?; + + let tokens = state + .auth_service + .issue_tokens_for_user(tenant_id, user_id, 7200) + .await?; + + Ok(AppResponse::ok(Code2TokenResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + tenant_id: tenant_id.to_string(), + user_id: user_id.to_string(), + })) +} + +/// Login with username/password and issue one-time authorization code. +/// 用户账户密码登录并签发一次性授权码(用于 SSO 授权码模式)。 +#[utoipa::path( + post, + path = "/auth/login-code", + tag = "Auth", + request_body = LoginCodeRequest, + responses( + (status = 200, description = "Code issued", body = LoginCodeResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 429, description = "Too many requests") + ), + params( + ("X-Tenant-ID" = String, Header, description = "Tenant UUID") + ) +)] +#[instrument(skip(state, payload))] +pub async fn login_code_handler( + TenantId(tenant_id): TenantId, + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let redirect_uri = state + .client_service + .assert_redirect_uri_allowed(&payload.client_id, &payload.redirect_uri) + .await?; + + let user = state + .auth_service + .verify_user_credentials(tenant_id, payload.email, payload.password) + .await?; + + let now = chrono::Utc::now().timestamp() as usize; + let exp = now + 5 * 60; + let jti = Uuid::new_v4().to_string(); + + let claims = AuthCodeClaims { + sub: user.id.to_string(), + tenant_id: user.tenant_id.to_string(), + client_id: Some(payload.client_id), + redirect_uri: Some(redirect_uri.clone()), + exp, + iat: now, + iss: "iam-service".to_string(), + jti: jti.clone(), + }; + + let header = Header::new(Algorithm::HS256); + let code = encode( + &header, + &claims, + &EncodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()), + ) + .map_err(|e| AppError::AnyhowError(anyhow!(e)))?; + + let value = serde_json::json!({ + "user_id": user.id.to_string(), + "tenant_id": user.tenant_id.to_string(), + "client_id": claims.client_id, + "redirect_uri": claims.redirect_uri + }) + .to_string(); + + let mut conn = state.redis.clone(); + let _: () = conn + .set_ex(redis_key(&jti), value, 5 * 60) + .await + .map_err(|e| AppError::AnyhowError(anyhow!(e)))?; + + let mut u = url::Url::parse(&redirect_uri) + .map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?; + u.query_pairs_mut().append_pair("code", &code); + + Ok(AppResponse::ok(LoginCodeResponse { + redirect_to: u.to_string(), + expires_at: exp, + })) +} diff --git a/src/main.rs b/src/main.rs index 34a8559..db469e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod docs; mod handlers; mod middleware; mod models; +mod redis; mod services; mod utils; @@ -12,26 +13,27 @@ use axum::{ http::StatusCode, middleware::from_fn, middleware::from_fn_with_state, - routing::{get, post}, + routing::{get, post, put}, }; use config::AppConfig; use handlers::{ - AppState, approve_app_status_change_handler, authorization_check_handler, create_app_handler, - create_role_handler, create_tenant_handler, delete_app_handler, delete_role_handler, - delete_tenant_handler, delete_user_handler, get_app_handler, get_role_handler, - get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler, - grant_role_permissions_handler, grant_role_users_handler, jwks_handler, - list_app_status_change_requests_handler, list_apps_handler, list_permissions_handler, - list_roles_handler, list_user_roles_handler, list_users_handler, login_handler, - my_permissions_handler, refresh_handler, register_handler, reject_app_status_change_handler, - request_app_status_change_handler, reset_my_password_handler, reset_user_password_handler, - revoke_role_permissions_handler, revoke_role_users_handler, set_tenant_enabled_apps_handler, - set_user_roles_handler, update_app_handler, update_role_handler, update_tenant_handler, - update_tenant_status_handler, update_user_handler, + AppState, approve_app_status_change_handler, authorization_check_handler, code2token_handler, + create_app_handler, create_client_handler, create_role_handler, create_tenant_handler, + delete_app_handler, delete_role_handler, delete_tenant_handler, delete_user_handler, + get_app_handler, get_role_handler, get_tenant_enabled_apps_handler, get_tenant_handler, + get_user_handler, grant_role_permissions_handler, grant_role_users_handler, jwks_handler, + list_app_status_change_requests_handler, list_apps_handler, list_clients_handler, + list_permissions_handler, list_roles_handler, list_user_roles_handler, list_users_handler, + login_code_handler, login_handler, logout_handler, my_permissions_handler, refresh_handler, + register_handler, reject_app_status_change_handler, request_app_status_change_handler, + reset_my_password_handler, reset_user_password_handler, revoke_role_permissions_handler, + revoke_role_users_handler, rotate_client_secret_handler, set_tenant_enabled_apps_handler, + set_user_roles_handler, update_app_handler, update_client_redirect_uris_handler, + update_role_handler, update_tenant_handler, update_tenant_status_handler, update_user_handler, }; use services::{ - AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService, - UserService, + AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService, + TenantService, UserService, }; use std::net::SocketAddr; use utoipa::OpenApi; @@ -80,21 +82,32 @@ async fn main() { // 4. 初始化 Service 和 AppState let auth_service = AuthService::new(pool.clone(), config.jwt_secret.clone()); + let client_service = ClientService::new(pool.clone(), config.client_secret_prev_ttl_days); let user_service = UserService::new(pool.clone()); let role_service = RoleService::new(pool.clone()); let tenant_service = TenantService::new(pool.clone()); let authorization_service = AuthorizationService::new(pool.clone()); let app_service = AppService::new(pool.clone()); let permission_service = PermissionService::new(pool.clone()); + let redis = match redis::init_manager(&config).await { + Ok(m) => m, + Err(e) => { + tracing::error!(%e, "Fatal error: Failed to connect to redis!"); + std::process::exit(1); + } + }; let state = AppState { auth_service, + client_service, user_service, role_service, tenant_service, authorization_service, app_service, permission_service, + redis, + auth_code_jwt_secret: config.auth_code_jwt_secret.clone(), }; let auth_cfg = middleware::auth::AuthMiddlewareConfig { @@ -103,7 +116,16 @@ async fn main() { "/tenants/register".to_string(), "/auth/register".to_string(), "/auth/login".to_string(), + "/auth/login-code".to_string(), "/auth/refresh".to_string(), + "/auth/code2token".to_string(), + "/iam/api/v1/.well-known/jwks.json".to_string(), + "/iam/api/v1/tenants/register".to_string(), + "/iam/api/v1/auth/register".to_string(), + "/iam/api/v1/auth/login".to_string(), + "/iam/api/v1/auth/login-code".to_string(), + "/iam/api/v1/auth/refresh".to_string(), + "/iam/api/v1/auth/code2token".to_string(), ], skip_path_prefixes: vec!["/scalar".to_string()], jwt: JwtVerifyConfig::rs256_from_pem( @@ -117,6 +139,11 @@ async fn main() { "/.well-known/jwks.json".to_string(), "/tenants/register".to_string(), "/auth/refresh".to_string(), + "/auth/code2token".to_string(), + "/iam/api/v1/.well-known/jwks.json".to_string(), + "/iam/api/v1/tenants/register".to_string(), + "/iam/api/v1/auth/refresh".to_string(), + "/iam/api/v1/auth/code2token".to_string(), ], skip_path_prefixes: vec!["/scalar".to_string()], }; @@ -144,12 +171,20 @@ async fn main() { .layer(middleware::rate_limit::login_rate_limiter()) .layer(from_fn(middleware::rate_limit::log_rate_limit_login)), ) + .route( + "/auth/login-code", + post(login_code_handler) + .layer(middleware::rate_limit::login_rate_limiter()) + .layer(from_fn(middleware::rate_limit::log_rate_limit_login)), + ) .route( "/auth/refresh", post(refresh_handler) .layer(middleware::rate_limit::login_rate_limiter()) .layer(from_fn(middleware::rate_limit::log_rate_limit_login)), ) + .route("/auth/logout", post(logout_handler)) + .route("/auth/code2token", post(code2token_handler)) .route("/me/permissions", get(my_permissions_handler)) .route("/authorize/check", post(authorization_check_handler)) .route("/users", get(list_users_handler)) @@ -203,6 +238,18 @@ async fn main() { "/platform/tenants/{tenant_id}/enabled-apps", get(get_tenant_enabled_apps_handler).put(set_tenant_enabled_apps_handler), ) + .route( + "/platform/clients", + get(list_clients_handler).post(create_client_handler), + ) + .route( + "/platform/clients/{client_id}/rotate-secret", + post(rotate_client_secret_handler), + ) + .route( + "/platform/clients/{client_id}/redirect-uris", + put(update_client_redirect_uris_handler), + ) .route( "/platform/apps", get(list_apps_handler).post(create_app_handler), @@ -237,11 +284,12 @@ async fn main() { common_telemetry::axum_middleware::trace_http_request, )); + let v1 = Router::new().merge(platform_api).merge(api); let app = Router::new() .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT })) .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) - .merge(platform_api) - .merge(api) + .merge(v1.clone()) + .nest("/iam/api/v1", v1) .with_state(state); // 6. 启动服务器 diff --git a/src/models.rs b/src/models.rs index 9392aa1..a0e73d7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -360,6 +360,139 @@ pub struct RefreshTokenRequest { pub refresh_token: String, } +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct LoginCodeRequest { + #[schema(default = "", example = "user@example.com")] + #[serde(default)] + pub email: String, + #[schema(default = "", example = "password")] + #[serde(default)] + pub password: String, + #[schema(default = "", example = "cms")] + #[serde(default)] + pub client_id: String, + #[schema( + default = "", + example = "https://cms-api.example.com/auth/callback?next=https%3A%2F%2Fcms.example.com%2F" + )] + #[serde(default)] + pub redirect_uri: String, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct LoginCodeResponse { + #[schema( + default = "", + example = "https://cms-api.example.com/auth/callback?next=...&code=..." + )] + #[serde(default)] + pub redirect_to: String, + #[schema(default = 1700000000, example = 1700000000)] + #[serde(default)] + pub expires_at: usize, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct Code2TokenRequest { + #[schema(default = "", example = "one_time_code_jwt")] + #[serde(default)] + pub code: String, + #[schema(default = "", example = "cms")] + #[serde(default)] + pub client_id: String, + #[schema(default = "", example = "client_secret")] + #[serde(default)] + pub client_secret: String, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct Code2TokenResponse { + #[schema(default = "", example = "access_token")] + #[serde(default)] + pub access_token: String, + #[schema(default = "", example = "refresh_token")] + #[serde(default)] + pub refresh_token: String, + #[schema(default = "Bearer", example = "Bearer")] + #[serde(default = "default_token_type")] + pub token_type: String, + #[schema(default = 7200, example = 7200)] + #[serde(default)] + pub expires_in: usize, + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default)] + pub tenant_id: String, + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default)] + pub user_id: String, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct CreateClientRequest { + #[schema(default = "", example = "cms")] + #[serde(default)] + pub client_id: String, + #[serde(default)] + pub name: Option, + #[schema(example = "[\"https://cms.example.com/auth/callback\"]")] + #[serde(default)] + pub redirect_uris: Option>, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct CreateClientResponse { + #[schema(default = "", example = "cms")] + #[serde(default)] + pub client_id: String, + #[schema(default = "", example = "client_secret")] + #[serde(default)] + pub client_secret: String, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct RotateClientSecretResponse { + #[schema(default = "", example = "cms")] + #[serde(default)] + pub client_id: String, + #[schema(default = "", example = "new_client_secret")] + #[serde(default)] + pub client_secret: String, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct ClientSummary { + #[schema(default = "", example = "cms")] + #[serde(default)] + pub client_id: String, + #[serde(default)] + pub name: Option, + #[schema(example = "[\"https://cms.example.com/auth/callback\"]")] + #[serde(default)] + pub redirect_uris: Vec, + #[schema(default = "", example = "2026-02-02T12:00:00Z")] + #[serde(default)] + pub created_at: String, + #[schema(default = "", example = "2026-02-02T12:00:00Z")] + #[serde(default)] + pub updated_at: String, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct UpdateClientRedirectUrisRequest { + #[schema(example = "[\"https://cms.example.com/auth/callback\"]")] + #[serde(default)] + pub redirect_uris: Vec, +} + #[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)] pub struct Permission { #[serde(default = "default_uuid")] diff --git a/src/redis.rs b/src/redis.rs new file mode 100644 index 0000000..8091e01 --- /dev/null +++ b/src/redis.rs @@ -0,0 +1,8 @@ +use crate::config::AppConfig; +use redis::aio::ConnectionManager; + +pub async fn init_manager(config: &AppConfig) -> Result { + let client = redis::Client::open(config.redis_url.clone())?; + ConnectionManager::new(client).await +} + diff --git a/src/services/auth.rs b/src/services/auth.rs index 0b42e4f..c362a0b 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -1,6 +1,6 @@ use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User}; use crate::utils::authz::filter_permissions_by_enabled_apps; -use crate::utils::{hash_password, sign, verify_password}; +use crate::utils::{hash_password, sign, sign_with_ttl, verify_password}; use common_telemetry::AppError; use hmac::{Hmac, Mac}; use rand::RngCore; @@ -12,7 +12,6 @@ use uuid::Uuid; #[derive(Clone)] pub struct AuthService { pool: PgPool, - // jwt_secret removed, using RS256 keys refresh_token_pepper: String, } @@ -20,7 +19,8 @@ impl AuthService { /// 创建认证服务实例。 /// /// 说明: - /// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留。 + /// - Access Token 使用 RS256 密钥对进行签发与校验,不使用对称密钥(HS256)。 + /// - 但仍需要一个服务端 Secret 作为 Refresh Token 指纹(HMAC)pepper,因此保留 `_jwt_secret` 入参(对齐环境变量名 `JWT_SECRET`)。 pub fn new(pool: PgPool, _jwt_secret: String) -> Self { Self { pool, @@ -35,6 +35,92 @@ impl AuthService { Ok(hex::encode(mac.finalize().into_bytes())) } + #[instrument(skip(self))] + pub async fn issue_tokens_for_user( + &self, + tenant_id: Uuid, + user_id: Uuid, + access_ttl_secs: usize, + ) -> Result { + let roles = sqlx::query_scalar::<_, String>( + r#" + SELECT r.name + FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE r.tenant_id = $1 AND ur.user_id = $2 + "#, + ) + .bind(tenant_id) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + let permissions = sqlx::query_scalar::<_, String>( + r#" + SELECT DISTINCT p.code + FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + JOIN roles r ON r.id = ur.role_id + WHERE r.tenant_id = $1 AND ur.user_id = $2 + "#, + ) + .bind(tenant_id) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec, i32)>( + r#" + SELECT enabled_apps, version + FROM tenant_entitlements + WHERE tenant_id = $1 + "#, + ) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await? + .unwrap_or_else(|| (vec![], 0)); + + let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps); + + let access_token = sign_with_ttl( + user_id, + tenant_id, + roles, + permissions, + enabled_apps, + apps_version, + access_ttl_secs, + )?; + + let mut refresh_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut refresh_bytes); + let refresh_token = hex::encode(refresh_bytes); + + let refresh_token_hash = + hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?; + let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?; + let expires_at = chrono::Utc::now() + chrono::Duration::days(30); + + sqlx::query( + "INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)", + ) + .bind(user_id) + .bind(refresh_token_hash) + .bind(refresh_token_fingerprint) + .bind(expires_at) + .execute(&self.pool) + .await?; + + Ok(LoginResponse { + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: access_ttl_secs, + }) + } + // 注册业务 #[instrument(skip(self, req))] /// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。 @@ -115,101 +201,47 @@ impl AuthService { tenant_id: Uuid, req: LoginRequest, ) -> Result { - // 1. 查找用户 (带 tenant_id 防止跨租户登录) + let user = self + .verify_user_credentials(tenant_id, req.email, req.password) + .await?; + + self.issue_tokens_for_user(user.tenant_id, user.id, 15 * 60).await + } + + #[instrument(skip(self, password))] + pub async fn verify_user_credentials( + &self, + tenant_id: Uuid, + email: String, + password: String, + ) -> Result { + let email = email.trim().to_string(); + if email.is_empty() || password.is_empty() { + return Err(AppError::BadRequest("email and password are required".into())); + } + let query = "SELECT * FROM users WHERE tenant_id = $1 AND email = $2"; let user = sqlx::query_as::<_, User>(query) .bind(tenant_id) - .bind(&req.email) + .bind(&email) .fetch_optional(&self.pool) .await? .ok_or(AppError::NotFound("User not found".into()))?; - // 2. 验证密码 - if !verify_password(&req.password, &user.password_hash) { + if !verify_password(&password, &user.password_hash) { return Err(AppError::InvalidCredentials); } - let roles = sqlx::query_scalar::<_, String>( - r#" - SELECT r.name - FROM roles r - JOIN user_roles ur ON ur.role_id = r.id - WHERE r.tenant_id = $1 AND ur.user_id = $2 - "#, - ) - .bind(user.tenant_id) - .bind(user.id) - .fetch_all(&self.pool) - .await?; + Ok(user) + } - let permissions = sqlx::query_scalar::<_, String>( - r#" - SELECT DISTINCT p.code - FROM permissions p - JOIN role_permissions rp ON rp.permission_id = p.id - JOIN user_roles ur ON ur.role_id = rp.role_id - JOIN roles r ON r.id = ur.role_id - WHERE r.tenant_id = $1 AND ur.user_id = $2 - "#, - ) - .bind(user.tenant_id) - .bind(user.id) - .fetch_all(&self.pool) - .await?; - - let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec, i32)>( - r#" - SELECT enabled_apps, version - FROM tenant_entitlements - WHERE tenant_id = $1 - "#, - ) - .bind(user.tenant_id) - .fetch_optional(&self.pool) - .await? - .unwrap_or_else(|| (vec![], 0)); - - let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps); - - // 3. 签发 Access Token - let access_token = sign( - user.id, - user.tenant_id, - roles, - permissions, - enabled_apps, - apps_version, - )?; - - // 4. 生成 Refresh Token - let mut refresh_bytes = [0u8; 32]; - rand::rng().fill_bytes(&mut refresh_bytes); - let refresh_token = hex::encode(refresh_bytes); - - // Hash refresh token for storage - let refresh_token_hash = - hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?; - let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?; - - // 5. 存储 Refresh Token (30天过期) - let expires_at = chrono::Utc::now() + chrono::Duration::days(30); - - sqlx::query( - "INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)", - ) - .bind(user.id) - .bind(refresh_token_hash) - .bind(refresh_token_fingerprint) - .bind(expires_at) - .execute(&self.pool) - .await?; - - Ok(LoginResponse { - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: 15 * 60, // 15 mins - }) + #[instrument(skip(self))] + pub async fn logout(&self, user_id: Uuid) -> Result<(), AppError> { + sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1") + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) } async fn bootstrap_tenant_admin( diff --git a/src/services/client.rs b/src/services/client.rs new file mode 100644 index 0000000..108acdd --- /dev/null +++ b/src/services/client.rs @@ -0,0 +1,365 @@ +use crate::utils::{hash_password, verify_password}; +use anyhow::anyhow; +use common_telemetry::AppError; +use percent_encoding::percent_decode_str; +use rand::RngCore; +use serde_json::Value; +use sqlx::PgPool; +use tracing::instrument; + +#[derive(Clone)] +pub struct ClientService { + pool: PgPool, + prev_ttl_days: u32, +} + +impl ClientService { + pub fn new(pool: PgPool, prev_ttl_days: u32) -> Self { + Self { + pool, + prev_ttl_days, + } + } + + fn generate_secret(&self) -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) + } + + fn normalize_redirect_uri(&self, raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err(AppError::BadRequest("redirectUri is required".into())); + } + + if raw.contains('\r') || raw.contains('\n') { + return Err(AppError::BadRequest("redirectUri is invalid".into())); + } + + let mut url = match url::Url::parse(raw) { + Ok(u) => u, + Err(_) => { + if raw.contains('%') { + let decoded = percent_decode_str(raw).decode_utf8_lossy().to_string(); + if decoded.contains('\r') || decoded.contains('\n') { + return Err(AppError::BadRequest("redirectUri is invalid".into())); + } + url::Url::parse(&decoded) + .map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))? + } else { + return Err(AppError::BadRequest("redirectUri is invalid".into())); + } + } + }; + url.set_fragment(None); + + let host = url.host_str().unwrap_or_default(); + let is_localhost = host == "localhost" || host == "127.0.0.1"; + let is_allowed_scheme = url.scheme() == "https" || (is_localhost && url.scheme() == "http"); + if !is_allowed_scheme { + return Err(AppError::BadRequest( + "redirectUri must be https (or http://localhost in dev)".into(), + )); + } + + Ok(url.to_string()) + } + + fn normalize_redirect_uris(&self, raw: Vec) -> Result, AppError> { + let mut out = Vec::new(); + for u in raw { + out.push(self.normalize_redirect_uri(&u)?); + } + out.sort(); + out.dedup(); + Ok(out) + } + + #[instrument(skip(self))] + pub async fn create_client( + &self, + client_id: String, + name: Option, + redirect_uris: Option>, + ) -> Result { + let client_id = client_id.trim().to_string(); + if client_id.is_empty() { + return Err(AppError::BadRequest("clientId is required".into())); + } + + let secret = self.generate_secret(); + let secret_hash = hash_password(&secret).map_err(|e| AppError::AnyhowError(anyhow!(e)))?; + let redirect_uris = redirect_uris + .map(|v| self.normalize_redirect_uris(v)) + .transpose()? + .unwrap_or_default(); + let redirect_uris_json = + Value::Array(redirect_uris.into_iter().map(Value::String).collect()); + + let inserted = sqlx::query( + r#" + INSERT INTO oauth_clients (client_id, name, secret_hash, redirect_uris) + VALUES ($1, $2, $3, $4) + ON CONFLICT (client_id) DO NOTHING + "#, + ) + .bind(&client_id) + .bind(name) + .bind(secret_hash) + .bind(redirect_uris_json) + .execute(&self.pool) + .await? + .rows_affected(); + + if inserted == 0 { + return Err(AppError::BadRequest("clientId already exists".into())); + } + + Ok(secret) + } + + #[instrument(skip(self))] + pub async fn rotate_secret(&self, client_id: String) -> Result { + let client_id = client_id.trim().to_string(); + if client_id.is_empty() { + return Err(AppError::BadRequest("clientId is required".into())); + } + + let row = sqlx::query_as::<_, (String,)>( + "SELECT secret_hash FROM oauth_clients WHERE client_id = $1", + ) + .bind(&client_id) + .fetch_optional(&self.pool) + .await?; + + let Some((current_hash,)) = row else { + return Err(AppError::NotFound("client not found".into())); + }; + + let new_secret = self.generate_secret(); + let new_secret_hash = + hash_password(&new_secret).map_err(|e| AppError::AnyhowError(anyhow!(e)))?; + + let prev_expires_at = + chrono::Utc::now() + chrono::Duration::days(self.prev_ttl_days as i64); + + sqlx::query( + r#" + UPDATE oauth_clients + SET prev_secret_hash = $1, + prev_expires_at = $2, + secret_hash = $3, + updated_at = NOW() + WHERE client_id = $4 + "#, + ) + .bind(current_hash) + .bind(prev_expires_at) + .bind(new_secret_hash) + .bind(&client_id) + .execute(&self.pool) + .await?; + + Ok(new_secret) + } + + #[instrument(skip(self))] + pub async fn set_redirect_uris( + &self, + client_id: String, + redirect_uris: Vec, + ) -> Result<(), AppError> { + let client_id = client_id.trim().to_string(); + if client_id.is_empty() { + return Err(AppError::BadRequest("clientId is required".into())); + } + + let redirect_uris = self.normalize_redirect_uris(redirect_uris)?; + let redirect_uris_json = + Value::Array(redirect_uris.into_iter().map(Value::String).collect()); + + let rows = sqlx::query( + r#" + UPDATE oauth_clients + SET redirect_uris = $1, + updated_at = NOW() + WHERE client_id = $2 + "#, + ) + .bind(redirect_uris_json) + .bind(&client_id) + .execute(&self.pool) + .await? + .rows_affected(); + + if rows == 0 { + return Err(AppError::NotFound("client not found".into())); + } + + Ok(()) + } + + #[instrument(skip(self))] + pub async fn assert_redirect_uri_allowed( + &self, + client_id: &str, + redirect_uri: &str, + ) -> Result { + let client_id = client_id.trim(); + if client_id.is_empty() { + return Err(AppError::BadRequest("clientId is required".into())); + } + + let normalized = self.normalize_redirect_uri(redirect_uri)?; + let row = sqlx::query_as::<_, (Value,)>( + "SELECT redirect_uris FROM oauth_clients WHERE client_id = $1", + ) + .bind(client_id) + .fetch_optional(&self.pool) + .await?; + + let Some((v,)) = row else { + return Err(AppError::AuthError("Invalid client credentials".into())); + }; + + let Some(arr) = v.as_array() else { + return Err(AppError::ConfigError( + "Invalid oauth_clients.redirect_uris".into(), + )); + }; + + let requested = url::Url::parse(&normalized) + .map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?; + let requested_host = requested.host_str().unwrap_or_default().to_string(); + let requested_port = requested.port_or_known_default().unwrap_or(0); + let requested_path = requested.path().to_string(); + let requested_scheme = requested.scheme().to_string(); + + let allowed = arr.iter().filter_map(|x| x.as_str()).any(|raw_allowed| { + let allowed_norm = match self.normalize_redirect_uri(raw_allowed) { + Ok(v) => v, + Err(_) => return false, + }; + let allowed_url = match url::Url::parse(&allowed_norm) { + Ok(v) => v, + Err(_) => return false, + }; + + let host = allowed_url.host_str().unwrap_or_default(); + let port = allowed_url.port_or_known_default().unwrap_or(0); + + if allowed_url.scheme() != requested_scheme + || host != requested_host + || port != requested_port + || allowed_url.path() != requested_path + { + return false; + } + + let q = allowed_url.query().unwrap_or_default(); + if q.is_empty() { + return true; + } + + allowed_norm == normalized + }); + if !allowed { + return Err(AppError::AuthError("redirectUri is not allowed".into())); + } + + Ok(normalized) + } + + #[instrument(skip(self, client_secret))] + pub async fn verify_client_secret( + &self, + client_id: &str, + client_secret: &str, + ) -> Result<(), AppError> { + if client_id.trim().is_empty() || client_secret.trim().is_empty() { + return Err(AppError::AuthError("Invalid client credentials".into())); + } + + let row = sqlx::query_as::< + _, + ( + String, + Option, + Option>, + ), + >( + r#" + SELECT secret_hash, prev_secret_hash, prev_expires_at + FROM oauth_clients + WHERE client_id = $1 + "#, + ) + .bind(client_id.trim()) + .fetch_optional(&self.pool) + .await?; + + let Some((secret_hash, prev_hash, prev_expires_at)) = row else { + return Err(AppError::AuthError("Invalid client credentials".into())); + }; + + if verify_password(client_secret.trim(), &secret_hash) { + return Ok(()); + } + + if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at) { + if chrono::Utc::now() < prev_expires_at + && verify_password(client_secret.trim(), &prev_hash) + { + return Ok(()); + } + } + + Err(AppError::AuthError("Invalid client credentials".into())) + } + + #[instrument(skip(self))] + pub async fn list_clients( + &self, + ) -> Result, Vec, String, String)>, AppError> { + let rows = sqlx::query_as::< + _, + ( + String, + Option, + Value, + chrono::DateTime, + chrono::DateTime, + ), + >( + r#" + SELECT client_id, name, redirect_uris, created_at, updated_at + FROM oauth_clients + ORDER BY client_id ASC + "#, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|(id, name, redirect_uris, created_at, updated_at)| { + let uris = redirect_uris + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + ( + id, + name, + uris, + created_at.to_rfc3339(), + updated_at.to_rfc3339(), + ) + }) + .collect()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index ef4419b..959bd62 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod auth; pub mod authorization; +pub mod client; pub mod permission; pub mod role; pub mod tenant; @@ -9,6 +10,7 @@ pub mod user; pub use app::AppService; pub use auth::AuthService; pub use authorization::AuthorizationService; +pub use client::ClientService; pub use permission::PermissionService; pub use role::RoleService; pub use tenant::TenantService; diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index e5150fb..808627a 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -29,13 +29,33 @@ pub fn sign( permissions: Vec, apps: Vec, apps_version: i32, +) -> Result { + sign_with_ttl( + user_id, + tenant_id, + roles, + permissions, + apps, + apps_version, + 15 * 60, + ) +} + +pub fn sign_with_ttl( + user_id: Uuid, + tenant_id: Uuid, + roles: Vec, + permissions: Vec, + apps: Vec, + apps_version: i32, + ttl_secs: usize, ) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as usize; - let expiration = now + 15 * 60; // 15 minutes access token + let expiration = now + ttl_secs; let claims = Claims { sub: user_id.to_string(), diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9a2f3dd..9546c8b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,7 +1,7 @@ -pub mod keys; -pub mod jwt; -pub mod password; pub mod authz; +pub mod jwt; +pub mod keys; +pub mod password; +pub use jwt::{sign, sign_with_ttl, verify}; pub use password::{hash_password, verify_password}; -pub use jwt::{sign, verify};