diff --git a/docs/SSO_INTEGRATION.md b/docs/SSO_INTEGRATION.md index 9a82c10..2a0d437 100644 --- a/docs/SSO_INTEGRATION.md +++ b/docs/SSO_INTEGRATION.md @@ -7,11 +7,12 @@ - 登录页:`{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` + - `POST {IAM_SERVICE_BASE_URL}/api/v1/auth/code2token`(外部第三方:必须携带 tenant_id 并校验 clientId/clientSecret) + - `POST {IAM_SERVICE_BASE_URL}/api/v1/internal/auth/code2token`(内部服务:需 `X-Internal-Token` 预共享密钥,不强制 tenant_id) - 签发授权码:由 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` + - `POST {IAM_SERVICE_BASE_URL}/api/v1/auth/login-code` +- token 刷新:`POST {IAM_SERVICE_BASE_URL}/api/v1/auth/refresh` +- 退出:`POST {IAM_SERVICE_BASE_URL}/api/v1/auth/logout` ## 2. 业务 Next.js(前端)接入:中间件跳转登录 @@ -20,45 +21,46 @@ 在业务项目根目录创建 `/src/middleware.ts`: ```ts -import { NextRequest, NextResponse } from "next/server" +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 + 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 ?? "" + 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 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) + )}&callback=${encodeURIComponent(callback)}`; + return NextResponse.redirect(loginUrl, 302); } - return NextResponse.next() + return NextResponse.next(); } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|api/public).*)"], -} +}; ``` 说明: + - 示例使用 cookie `accessToken`;你也可以改为本地存储或自定义 header。 - 校验是否过期仅做 payload 解析(不验签);服务端真正鉴权仍应使用 `auth-kit` + RS256 验签。 @@ -89,7 +91,7 @@ async fn sso_callback(Query(q): Query) -> Redirect { let http = reqwest::Client::new(); let resp = http - .post(format!("{}/iam/api/v1/auth/code2token", iam_base.trim_end_matches('/'))) + .post(format!("{}/api/v1/auth/code2token", iam_base.trim_end_matches('/'))) .json(&serde_json::json!({ "code": q.code, "clientId": client_id, @@ -131,15 +133,16 @@ pub fn router() -> Router { ### 3.2 后端鉴权与自动刷新 推荐: + - 业务后端对受保护接口强制 `Authorization: Bearer `(来自 cookie 或前端 header)。 -- 过期时由业务后端调用 `POST /iam/api/v1/auth/refresh`,轮换 refresh token,并回写 cookie。 +- 过期时由业务后端调用 `POST /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" \ +curl -X POST "$IAM_SERVICE_BASE_URL/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"] }' @@ -148,14 +151,14 @@ curl -X POST "$IAM_SERVICE_BASE_URL/iam/api/v1/platform/clients" \ 轮换: ```bash -curl -X POST "$IAM_SERVICE_BASE_URL/iam/api/v1/platform/clients/cms/rotate-secret" \ +curl -X POST "$IAM_SERVICE_BASE_URL/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" \ +curl -X PUT "$IAM_SERVICE_BASE_URL/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 20cb556..05b6f3c 100644 --- a/docs/TEMP.md +++ b/docs/TEMP.md @@ -25,6 +25,10 @@ "clientId": "cms", "clientSecret": "2adbc0d720b687a6d05df32942c2919b0adcbd579c23ecd9cbb27f7a7a7e3326" } +{ + "clientId": "cms2", + "clientSecret": "76ac6841b28271389ba1ff133fb9295a08c197edadc6d7cfdadb8e155ef30dc1" +} ``` @@ -33,8 +37,8 @@ "code": 0, "message": "Success", "data": { - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwMjU5OTQsImlhdCI6MTc3MDAyNTA5NCwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.NNfdO14PRxkLa5Kkiz5SZ0tDnbrXvVTgOsU65Xg8jSbowrRsbdO3N_fBpEaSxJ3n2DhtD0uYZyRABuCBVWCncxk0RDUWXhoHVXucEFA1Br6I4niZTfIbnv-L1M-Q1fNvPGZE2DQ8Os9K5b2F91kpcwkaR-vQgE9oyFeq1xhQ-MR7YeQLXgLk9UQpWyD2Yj3VIWyFYiG94JX9eI6iJsOJZayqSXaeid50c5R4Z9lq9SQ07ZmFTqZFitCrPrQRY_wh6OeeQrHF33HMKC3yQ1jq4XyiNlDIzLIzDerUpK5UtLdz9Cntt31yg-2tsj2nSMUZLssllMZZaPjFUTMFeu0egQ", - "refresh_token": "bd60d869926bac781dd04ad4b340f79624c4da35373c85865bd4627093714e2e", + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwOTc2NzYsImlhdCI6MTc3MDA5Njc3NiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.Tt7fEQj8wtS5XzJ-GuwRF5yiOrtGaRr9P_V5RqNZagLXj0eRikf9U4oNkFS2uy8Pp75Ks4jL816DzeLXQsXZJfWEtvlTp0QmwyOhYIN1p-yyGS9Pitl0gb7wobjStGDyMSD-ctbHgEsU41qLrQd6ZQkpFl2IpaCSfCN0JZCpc7_3BeI6YLwAw_K4-TFF_1OTNRPm4sT3RnEZYOHXm6EcOUk-MsDBy1itADCdEEUdCSoslK6FGHIpbhkgA56Z7Qy5909BxXW34I21c2rZX-R_iB9q_eKzWd0GqBMIZi33ITbRy4F7_CtCQSwFNUt6-lvVvzXYLHsVQchcpOdYtj3h6w", + "refresh_token": "7483624e8942aee112e62b2b58ec902fb01731dd7b098b4af6001e350b2303f0", "token_type": "Bearer", "expires_in": 900 } diff --git a/scripts/db/migrations/0009_tenant_oauth_clients.sql b/scripts/db/migrations/0009_tenant_oauth_clients.sql new file mode 100644 index 0000000..3be680e --- /dev/null +++ b/scripts/db/migrations/0009_tenant_oauth_clients.sql @@ -0,0 +1,14 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS tenant_oauth_clients ( + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (tenant_id, client_id) +); + +CREATE INDEX IF NOT EXISTS idx_tenant_oauth_clients_tenant_id ON tenant_oauth_clients(tenant_id); +CREATE INDEX IF NOT EXISTS idx_tenant_oauth_clients_client_id ON tenant_oauth_clients(client_id); + +COMMIT; + diff --git a/scripts/db/migrations/0010_backfill_tenant_oauth_clients.sql b/scripts/db/migrations/0010_backfill_tenant_oauth_clients.sql new file mode 100644 index 0000000..5f8045a --- /dev/null +++ b/scripts/db/migrations/0010_backfill_tenant_oauth_clients.sql @@ -0,0 +1,10 @@ +BEGIN; + +INSERT INTO tenant_oauth_clients (tenant_id, client_id) +SELECT t.id, c.client_id +FROM tenants t +CROSS JOIN oauth_clients c +ON CONFLICT (tenant_id, client_id) DO NOTHING; + +COMMIT; + diff --git a/scripts/db/rollback/0009.down.sql b/scripts/db/rollback/0009.down.sql new file mode 100644 index 0000000..0807fbe --- /dev/null +++ b/scripts/db/rollback/0009.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP TABLE IF EXISTS tenant_oauth_clients; + +COMMIT; + diff --git a/scripts/db/rollback/0010.down.sql b/scripts/db/rollback/0010.down.sql new file mode 100644 index 0000000..b9b73fb --- /dev/null +++ b/scripts/db/rollback/0010.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DELETE FROM tenant_oauth_clients; + +COMMIT; + diff --git a/scripts/db/verify/0009_tenant_oauth_clients.sql b/scripts/db/verify/0009_tenant_oauth_clients.sql new file mode 100644 index 0000000..d1b5a74 --- /dev/null +++ b/scripts/db/verify/0009_tenant_oauth_clients.sql @@ -0,0 +1,11 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'tenant_oauth_clients' + ) THEN + RAISE EXCEPTION 'tenant_oauth_clients table does not exist'; + END IF; +END $$; + diff --git a/scripts/db/verify/0010_backfill_tenant_oauth_clients.sql b/scripts/db/verify/0010_backfill_tenant_oauth_clients.sql new file mode 100644 index 0000000..d1b5a74 --- /dev/null +++ b/scripts/db/verify/0010_backfill_tenant_oauth_clients.sql @@ -0,0 +1,11 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'tenant_oauth_clients' + ) THEN + RAISE EXCEPTION 'tenant_oauth_clients table does not exist'; + END IF; +END $$; + diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..05db0a8 --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,2 @@ +pub mod services; +pub mod use_cases; diff --git a/src/services/app.rs b/src/application/services/app.rs similarity index 99% rename from src/services/app.rs rename to src/application/services/app.rs index c1b9e8a..0321277 100644 --- a/src/services/app.rs +++ b/src/application/services/app.rs @@ -1,5 +1,6 @@ use crate::models::{ - App, AppStatusChangeRequest, CreateAppRequest, ListAppsQuery, UpdateAppRequest, + App, AppStatusChangeRequest, CreateAppRequest, ListAppsQuery, RequestAppStatusChangeRequest, + UpdateAppRequest, }; use common_telemetry::AppError; use sqlx::PgPool; @@ -109,10 +110,10 @@ impl AppService { .fetch_one(&mut *tx) .await .map_err(|e| { - if let sqlx::Error::Database(db) = &e { - if db.is_unique_violation() { - return AppError::AlreadyExists("App already exists".into()); - } + if let sqlx::Error::Database(db) = &e + && db.is_unique_violation() + { + return AppError::AlreadyExists("App already exists".into()); } e.into() })?; diff --git a/src/services/auth.rs b/src/application/services/auth.rs similarity index 82% rename from src/services/auth.rs rename to src/application/services/auth.rs index c362a0b..d956c29 100644 --- a/src/services/auth.rs +++ b/src/application/services/auth.rs @@ -16,15 +16,10 @@ pub struct AuthService { } impl AuthService { - /// 创建认证服务实例。 - /// - /// 说明: - /// - Access Token 使用 RS256 密钥对进行签发与校验,不使用对称密钥(HS256)。 - /// - 但仍需要一个服务端 Secret 作为 Refresh Token 指纹(HMAC)pepper,因此保留 `_jwt_secret` 入参(对齐环境变量名 `JWT_SECRET`)。 - pub fn new(pool: PgPool, _jwt_secret: String) -> Self { + pub fn new(pool: PgPool, jwt_secret: String) -> Self { Self { pool, - refresh_token_pepper: _jwt_secret, + refresh_token_pepper: jwt_secret, } } @@ -83,7 +78,6 @@ impl AuthService { .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, @@ -121,48 +115,24 @@ impl AuthService { }) } - // 注册业务 #[instrument(skip(self, req))] - /// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。 - /// - /// 业务规则: - /// - 用户必须绑定到 `tenant_id`,禁止跨租户注册写入。 - /// - 密码以安全哈希形式存储,不回传明文。 - /// - 若该租户用户数为 1(首个用户),自动创建/获取 `Admin` 系统角色并授予全量权限,同时绑定到该用户。 - /// - /// 输入: - /// - `tenant_id`:目标租户 - /// - `req.email` / `req.password`:注册信息 - /// - /// 输出: - /// - 返回创建后的 `User` 记录(包含 `id/tenant_id/email` 等字段) - /// - /// 异常: - /// - 数据库写入失败(如唯一约束冲突、连接错误等) - /// - 密码哈希失败 - pub async fn register( - &self, - tenant_id: Uuid, - req: CreateUserRequest, - ) -> Result { + pub async fn register(&self, tenant_id: Uuid, req: CreateUserRequest) -> Result { let mut tx = self.pool.begin().await?; - - // 1. 哈希密码 let hashed = hash_password(&req.password).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?; - // 2. 存入数据库 (带上 tenant_id) - let query = r#" + let user = sqlx::query_as::<_, User>( + r#" INSERT INTO users (tenant_id, email, password_hash) VALUES ($1, $2, $3) RETURNING id, tenant_id, email, password_hash - "#; - let user = sqlx::query_as::<_, User>(query) - .bind(tenant_id) - .bind(&req.email) - .bind(&hashed) - .fetch_one(&mut *tx) - .await?; + "#, + ) + .bind(tenant_id) + .bind(&req.email) + .bind(&hashed) + .fetch_one(&mut *tx) + .await?; let user_count: i64 = sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1") .bind(tenant_id) @@ -178,33 +148,11 @@ impl AuthService { Ok(user) } - // 登录业务 #[instrument(skip(self, req))] - /// 在指定租户内完成用户认证并签发访问令牌与刷新令牌。 - /// - /// 业务规则: - /// - 仅在当前租户内按 `email` 查找用户,防止跨租户登录。 - /// - 密码校验失败返回 `InvalidCredentials`。 - /// - 登录成功后生成: - /// - `access_token`:JWT(包含租户、用户、角色与权限) - /// - `refresh_token`:随机生成并哈希后入库(默认 30 天过期) - /// - /// 输出: - /// - `LoginResponse`(token_type 固定为 `Bearer`,`expires_in` 当前为 15 分钟) - /// - /// 异常: - /// - 用户不存在(404) - /// - 密码错误(401) - /// - Token 签发失败或数据库写入失败 - pub async fn login( - &self, - tenant_id: Uuid, - req: LoginRequest, - ) -> Result { + pub async fn login(&self, tenant_id: Uuid, req: LoginRequest) -> Result { 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 } @@ -220,8 +168,7 @@ impl AuthService { 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) + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE tenant_id = $1 AND email = $2") .bind(tenant_id) .bind(&email) .fetch_optional(&self.pool) @@ -303,7 +250,6 @@ impl AuthService { let fingerprint = self.refresh_token_fingerprint(refresh_token.trim())?; let mut tx = self.pool.begin().await?; - let row = sqlx::query_as::< _, ( @@ -356,7 +302,6 @@ impl AuthService { .fetch_optional(&mut *tx) .await? .ok_or_else(|| AppError::NotFound("User not found".into()))?; - if tenant_id == Uuid::nil() { return Err(AppError::NotFound("User not found".into())); } @@ -467,3 +412,4 @@ impl AuthService { }) } } + diff --git a/src/services/authorization.rs b/src/application/services/authorization.rs similarity index 100% rename from src/services/authorization.rs rename to src/application/services/authorization.rs diff --git a/src/services/client.rs b/src/application/services/client.rs similarity index 93% rename from src/services/client.rs rename to src/application/services/client.rs index 108acdd..e65f10b 100644 --- a/src/services/client.rs +++ b/src/application/services/client.rs @@ -13,6 +13,8 @@ pub struct ClientService { prev_ttl_days: u32, } +pub type ClientListItem = (String, Option, Vec, String, String); + impl ClientService { pub fn new(pool: PgPool, prev_ttl_days: u32) -> Self { Self { @@ -97,6 +99,7 @@ impl ClientService { let redirect_uris_json = Value::Array(redirect_uris.into_iter().map(Value::String).collect()); + let mut tx = self.pool.begin().await?; let inserted = sqlx::query( r#" INSERT INTO oauth_clients (client_id, name, secret_hash, redirect_uris) @@ -108,7 +111,7 @@ impl ClientService { .bind(name) .bind(secret_hash) .bind(redirect_uris_json) - .execute(&self.pool) + .execute(&mut *tx) .await? .rows_affected(); @@ -116,6 +119,19 @@ impl ClientService { return Err(AppError::BadRequest("clientId already exists".into())); } + sqlx::query( + r#" + INSERT INTO tenant_oauth_clients (tenant_id, client_id) + SELECT id, $1 + FROM tenants + ON CONFLICT (tenant_id, client_id) DO NOTHING + "#, + ) + .bind(&client_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; Ok(secret) } @@ -307,21 +323,18 @@ impl ClientService { 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(()); - } + if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at) + && 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> { + pub async fn list_clients(&self) -> Result, AppError> { let rows = sqlx::query_as::< _, ( @@ -363,3 +376,4 @@ impl ClientService { .collect()) } } + diff --git a/src/services/mod.rs b/src/application/services/mod.rs similarity index 82% rename from src/services/mod.rs rename to src/application/services/mod.rs index 959bd62..8dd4b55 100644 --- a/src/services/mod.rs +++ b/src/application/services/mod.rs @@ -1,5 +1,7 @@ -pub mod app; +#![allow(unused_imports)] + pub mod auth; +pub mod app; pub mod authorization; pub mod client; pub mod permission; @@ -10,7 +12,7 @@ pub mod user; pub use app::AppService; pub use auth::AuthService; pub use authorization::AuthorizationService; -pub use client::ClientService; +pub use client::{ClientListItem, ClientService}; pub use permission::PermissionService; pub use role::RoleService; pub use tenant::TenantService; diff --git a/src/services/permission.rs b/src/application/services/permission.rs similarity index 95% rename from src/services/permission.rs rename to src/application/services/permission.rs index 862c17c..21a9be8 100644 --- a/src/services/permission.rs +++ b/src/application/services/permission.rs @@ -14,7 +14,10 @@ impl PermissionService { } #[instrument(skip(self))] - pub async fn list_permissions(&self, query: ListPermissionsQuery) -> Result, AppError> { + pub async fn list_permissions( + &self, + query: ListPermissionsQuery, + ) -> Result, AppError> { let page = query.page.unwrap_or(1); let page_size = query.page_size.unwrap_or(20); if page == 0 || page_size == 0 || page_size > 200 { diff --git a/src/services/role.rs b/src/application/services/role.rs similarity index 97% rename from src/services/role.rs rename to src/application/services/role.rs index dbc6475..14a47d8 100644 --- a/src/services/role.rs +++ b/src/application/services/role.rs @@ -43,10 +43,10 @@ impl RoleService { .fetch_one(&mut *tx) .await .map_err(|e| { - if let sqlx::Error::Database(db) = &e { - if db.is_unique_violation() { - return AppError::AlreadyExists("Role name already exists".into()); - } + if let sqlx::Error::Database(db) = &e + && db.is_unique_violation() + { + return AppError::AlreadyExists("Role name already exists".into()); } AppError::DbError(e) })?; @@ -78,7 +78,7 @@ impl RoleService { .bind(tenant_id) .fetch_all(&self.pool) .await - .map_err(|e| AppError::DbError(e)) + .map_err(AppError::DbError) } #[instrument(skip(self))] @@ -135,10 +135,10 @@ impl RoleService { .fetch_one(&mut *tx) .await .map_err(|e| { - if let sqlx::Error::Database(db) = &e { - if db.is_unique_violation() { - return AppError::AlreadyExists("Role name already exists".into()); - } + if let sqlx::Error::Database(db) = &e + && db.is_unique_violation() + { + return AppError::AlreadyExists("Role name already exists".into()); } AppError::DbError(e) })?; @@ -503,6 +503,7 @@ impl RoleService { } #[instrument(skip(self, role_ids))] + #[allow(dead_code)] pub async fn list_roles_by_ids( &self, tenant_id: Uuid, @@ -517,7 +518,7 @@ impl RoleService { .bind(role_ids) .fetch_all(&self.pool) .await - .map_err(|e| AppError::DbError(e)) + .map_err(AppError::DbError) } #[instrument(skip(self))] @@ -537,7 +538,7 @@ impl RoleService { .bind(target_user_id) .fetch_all(&self.pool) .await - .map_err(|e| AppError::DbError(e)) + .map_err(AppError::DbError) } #[instrument(skip(self, role_ids))] diff --git a/src/services/tenant.rs b/src/application/services/tenant.rs similarity index 81% rename from src/services/tenant.rs rename to src/application/services/tenant.rs index a6eb7f0..8330bb1 100644 --- a/src/services/tenant.rs +++ b/src/application/services/tenant.rs @@ -24,7 +24,6 @@ pub struct TenantService { } impl TenantService { - /// 创建租户服务实例。 pub fn new(pool: PgPool) -> Self { Self { pool, @@ -33,17 +32,6 @@ impl TenantService { } #[instrument(skip(self, req))] - /// 创建新租户并初始化默认状态与配置。 - /// - /// 业务规则: - /// - 默认 `status=active`。 - /// - `config` 未提供时默认 `{}`。 - /// - /// 输出: - /// - 返回新建租户记录(含 `id`) - /// - /// 异常: - /// - 数据库写入失败(如连接异常、约束失败等) pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result { let mut config = req .config @@ -58,17 +46,18 @@ impl TenantService { Value::Number(serde_json::Number::from(0)), ); } - let query = r#" + let mut tx = self.pool.begin().await?; + let tenant = sqlx::query_as::<_, Tenant>( + r#" INSERT INTO tenants (name, status, config) VALUES ($1, 'active', $2) RETURNING id, name, status, config - "#; - let mut tx = self.pool.begin().await?; - let tenant = sqlx::query_as::<_, Tenant>(query) - .bind(req.name) - .bind(config) - .fetch_one(&mut *tx) - .await?; + "#, + ) + .bind(req.name) + .bind(config) + .fetch_one(&mut *tx) + .await?; sqlx::query( r#" @@ -86,13 +75,8 @@ impl TenantService { } #[instrument(skip(self))] - /// 根据租户 ID 查询租户信息。 - /// - /// 异常: - /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 pub async fn get_tenant(&self, tenant_id: Uuid) -> Result { - let query = "SELECT id, name, status, config FROM tenants WHERE id = $1"; - sqlx::query_as::<_, Tenant>(query) + sqlx::query_as::<_, Tenant>("SELECT id, name, status, config FROM tenants WHERE id = $1") .bind(tenant_id) .fetch_optional(&self.pool) .await? @@ -100,19 +84,13 @@ impl TenantService { } #[instrument(skip(self, req))] - /// 更新租户基础信息(名称 / 配置)。 - /// - /// 说明: - /// - 仅更新 `UpdateTenantRequest` 中提供的字段,未提供字段保持不变。 - /// - /// 异常: - /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 pub async fn update_tenant( &self, tenant_id: Uuid, req: UpdateTenantRequest, ) -> Result { - let query = r#" + sqlx::query_as::<_, Tenant>( + r#" UPDATE tenants SET name = COALESCE($1, name), @@ -120,47 +98,40 @@ impl TenantService { updated_at = NOW() WHERE id = $3 RETURNING id, name, status, config - "#; - sqlx::query_as::<_, Tenant>(query) - .bind(req.name) - .bind(req.config) - .bind(tenant_id) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| AppError::NotFound("Tenant not found".into())) + "#, + ) + .bind(req.name) + .bind(req.config) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| AppError::NotFound("Tenant not found".into())) } #[instrument(skip(self, req))] - /// 更新租户状态字段(如 active / disabled)。 - /// - /// 异常: - /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 pub async fn update_tenant_status( &self, tenant_id: Uuid, req: UpdateTenantStatusRequest, ) -> Result { - let query = r#" + sqlx::query_as::<_, Tenant>( + r#" UPDATE tenants SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING id, name, status, config - "#; - sqlx::query_as::<_, Tenant>(query) - .bind(req.status) - .bind(tenant_id) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| AppError::NotFound("Tenant not found".into())) + "#, + ) + .bind(req.status) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| AppError::NotFound("Tenant not found".into())) } #[instrument(skip(self))] - /// 删除指定租户。 - /// - /// 异常: - /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 pub async fn delete_tenant(&self, tenant_id: Uuid) -> Result<(), AppError> { let result = sqlx::query("DELETE FROM tenants WHERE id = $1") .bind(tenant_id) @@ -184,10 +155,9 @@ impl TenantService { .await .get(&tenant_id) .cloned() + && hit.expires_at > now { - if hit.expires_at > now { - return Ok((hit.enabled_apps, hit.version, hit.updated_at)); - } + return Ok((hit.enabled_apps, hit.version, hit.updated_at)); } let row = sqlx::query_as::<_, (Vec, i32, chrono::DateTime)>( @@ -242,7 +212,6 @@ impl TenantService { self.validate_apps_exist(&normalized).await?; let mut tx = self.pool.begin().await?; - let current = sqlx::query_as::<_, (Vec, i32)>( r#" SELECT enabled_apps, version @@ -276,12 +245,12 @@ impl TenantService { } let (before_apps, before_version) = current.unwrap_or_else(|| (vec![], 0)); - if let Some(ev) = expected_version { - if ev != before_version { - return Err(AppError::AlreadyExists( - "enabled_apps:version_conflict".into(), - )); - } + if let Some(ev) = expected_version + && ev != before_version + { + return Err(AppError::AlreadyExists( + "enabled_apps:version_conflict".into(), + )); } let (new_version, updated_at): (i32, chrono::DateTime) = sqlx::query_as( @@ -397,3 +366,4 @@ fn normalize_apps(enabled_apps: Vec) -> Vec { out.sort(); out } + diff --git a/src/services/user.rs b/src/application/services/user.rs similarity index 93% rename from src/services/user.rs rename to src/application/services/user.rs index fff7f4d..1081bad 100644 --- a/src/services/user.rs +++ b/src/application/services/user.rs @@ -34,7 +34,7 @@ impl UserService { .bind(user_id) .fetch_optional(&self.pool) .await - .map_err(|e| AppError::DbError(e))? + .map_err(AppError::DbError)? .ok_or_else(|| AppError::NotFound("User not found".into())) } @@ -60,7 +60,7 @@ impl UserService { .bind(offset as i64) .fetch_all(&self.pool) .await - .map_err(|e| AppError::DbError(e)) + .map_err(AppError::DbError) } #[instrument(skip(self))] @@ -87,7 +87,7 @@ impl UserService { .bind(user_id) .fetch_optional(&self.pool) .await - .map_err(|e| AppError::DbError(e))? + .map_err(AppError::DbError)? .ok_or_else(|| AppError::NotFound("User not found".into())) } @@ -103,7 +103,7 @@ impl UserService { .bind(user_id) .execute(&self.pool) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".into())); @@ -135,7 +135,7 @@ impl UserService { .bind(user_id) .fetch_optional(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; let Some(stored_hash) = stored_hash else { return Err(AppError::NotFound("User not found".into())); @@ -156,13 +156,13 @@ impl UserService { .bind(user_id) .execute(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1") .bind(user_id) .execute(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; sqlx::query( r#" @@ -175,7 +175,7 @@ impl UserService { .bind(json!({ "target_user_id": user_id })) .execute(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; tx.commit().await?; Ok(()) @@ -216,7 +216,7 @@ impl UserService { .bind(target_user_id) .execute(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".into())); @@ -226,7 +226,7 @@ impl UserService { .bind(target_user_id) .execute(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; sqlx::query( r#" @@ -239,7 +239,7 @@ impl UserService { .bind(json!({ "target_user_id": target_user_id })) .execute(&mut *tx) .await - .map_err(|e| AppError::DbError(e))?; + .map_err(AppError::DbError)?; tx.commit().await?; Ok(temp) diff --git a/src/application/use_cases/exchange_code.rs b/src/application/use_cases/exchange_code.rs new file mode 100644 index 0000000..2e86346 --- /dev/null +++ b/src/application/use_cases/exchange_code.rs @@ -0,0 +1,138 @@ +use async_trait::async_trait; +use redis::Script; +use uuid::Uuid; + +use crate::domain::DomainError; +use crate::models::Code2TokenRequest; +use crate::application::services::AuthService; + +#[derive(Clone)] +pub struct ExchangeCodeUseCase { + pub auth_service: AuthService, + pub redis: redis::aio::ConnectionManager, + pub auth_code_jwt_secret: String, +} + +pub struct ExchangeCodeResult { + pub tenant_id: Uuid, + pub user_id: Uuid, + pub access_token: String, + pub refresh_token: String, + pub token_type: String, + pub expires_in: usize, +} + +#[async_trait] +pub trait Execute { + async fn execute(&self, req: Code2TokenRequest) -> Result; +} + +#[derive(serde::Deserialize)] +struct AuthCodeClaims { + sub: String, + tenant_id: String, + client_id: Option, + #[allow(dead_code)] + exp: usize, + #[allow(dead_code)] + iat: usize, + #[allow(dead_code)] + iss: String, + jti: String, +} + +#[derive(serde::Deserialize)] +struct AuthCodeRedisValue { + user_id: String, + tenant_id: String, + client_id: Option, +} + +fn redis_key(jti: &str) -> String { + format!("iam:auth_code:{}", jti) +} + +#[async_trait] +impl Execute for ExchangeCodeUseCase { + async fn execute(&self, req: Code2TokenRequest) -> Result { + if req.code.trim().is_empty() { + return Err(DomainError::InvalidArgument("code".into())); + } + + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); + validation.set_issuer(&["iam-front", "iam-service"]); + + let token_data = jsonwebtoken::decode::( + req.code.trim(), + &jsonwebtoken::DecodingKey::from_secret(self.auth_code_jwt_secret.as_bytes()), + &validation, + ) + .map_err(|_| DomainError::Unauthorized)?; + + let claims = token_data.claims; + if let Some(cid) = &claims.client_id + && cid != req.client_id.trim() + { + return Err(DomainError::Unauthorized); + } + + let jti = claims.jti.trim(); + if jti.is_empty() { + return Err(DomainError::Unauthorized); + } + + 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 = self.redis.clone(); + let val: Option = script + .key(key) + .invoke_async(&mut conn) + .await + .map_err(|_| DomainError::Unexpected)?; + + let Some(val) = val else { + return Err(DomainError::Unauthorized); + }; + + let stored: AuthCodeRedisValue = + serde_json::from_str(&val).map_err(|_| DomainError::Unauthorized)?; + + if let Some(cid) = stored.client_id.as_deref() + && cid != req.client_id.trim() + { + return Err(DomainError::Unauthorized); + } + + if stored.user_id != claims.sub || stored.tenant_id != claims.tenant_id { + return Err(DomainError::Unauthorized); + } + + let user_id = Uuid::parse_str(&stored.user_id).map_err(|_| DomainError::Unauthorized)?; + let tenant_id = + Uuid::parse_str(&stored.tenant_id).map_err(|_| DomainError::Unauthorized)?; + + let tokens = self + .auth_service + .issue_tokens_for_user(tenant_id, user_id, 7200) + .await + .map_err(|_| DomainError::Unexpected)?; + + Ok(ExchangeCodeResult { + tenant_id, + user_id, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + }) + } +} diff --git a/src/application/use_cases/mod.rs b/src/application/use_cases/mod.rs new file mode 100644 index 0000000..b82eb25 --- /dev/null +++ b/src/application/use_cases/mod.rs @@ -0,0 +1,2 @@ +pub mod exchange_code; + diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..d4a215b --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,2 @@ +pub const CANONICAL_BASE: &str = "/api/v1"; + diff --git a/src/docs.rs b/src/docs.rs index 9dea45c..8357d6d 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -1,4 +1,4 @@ -use crate::handlers; +use crate::presentation::http::handlers; use crate::models::{ AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest, ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest, @@ -137,6 +137,9 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg: version = "0.1.0", description = include_str!("../docs/SCALAR_GUIDE.md") ), + servers( + (url = "/api/v1", description = "Canonical API base") + ), paths( handlers::auth::register_handler, @@ -145,6 +148,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg: handlers::auth::refresh_handler, handlers::auth::logout_handler, handlers::sso::code2token_handler, + handlers::sso::internal_code2token_handler, handlers::client::create_client_handler, handlers::client::rotate_client_secret_handler, handlers::client::list_clients_handler, diff --git a/src/domain/aggregates/mod.rs b/src/domain/aggregates/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/domain/aggregates/mod.rs @@ -0,0 +1 @@ + diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/domain/entities/mod.rs @@ -0,0 +1 @@ + diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..70e0cb5 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,13 @@ +pub mod aggregates; +pub mod entities; +pub mod repositories; + +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + #[error("unauthorized")] + Unauthorized, + #[error("invalid argument: {0}")] + InvalidArgument(String), + #[error("unexpected error")] + Unexpected, +} diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs new file mode 100644 index 0000000..c78386b --- /dev/null +++ b/src/domain/repositories/mod.rs @@ -0,0 +1,2 @@ +pub mod tenant_config_repo; + diff --git a/src/domain/repositories/tenant_config_repo.rs b/src/domain/repositories/tenant_config_repo.rs new file mode 100644 index 0000000..def8c66 --- /dev/null +++ b/src/domain/repositories/tenant_config_repo.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::domain::DomainError; + +#[async_trait] +pub trait TenantConfigRepo: Send + Sync { + async fn validate_client_pair( + &self, + tenant_id: Uuid, + client_id: &str, + client_secret: &str, + ) -> Result; +} + diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs deleted file mode 100644 index 5f6c0e3..0000000 --- a/src/handlers/mod.rs +++ /dev/null @@ -1,62 +0,0 @@ -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, 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, 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}; -pub use role::{ - create_role_handler, delete_role_handler, get_role_handler, grant_role_permissions_handler, - 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, -}; -pub use user::{ - delete_user_handler, get_user_handler, list_user_roles_handler, list_users_handler, - reset_my_password_handler, reset_user_password_handler, set_user_roles_handler, - update_user_handler, -}; - -// 状态对象,包含 Service -#[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/permission.rs b/src/handlers/permission.rs deleted file mode 100644 index 52704ae..0000000 --- a/src/handlers/permission.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::handlers::AppState; -use crate::middleware::TenantId; -use crate::middleware::auth::AuthContext; -use crate::models::{ListPermissionsQuery, Permission}; -use axum::extract::{Query, State}; -use common_telemetry::{AppError, AppResponse}; -use tracing::instrument; - -#[utoipa::path( - get, - path = "/permissions", - tag = "Permission", - security( - ("bearer_auth" = []) - ), - responses( - (status = 200, description = "Permission list", body = [Permission]), - (status = 400, description = "Bad request"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden") - ), - params( - ("Authorization" = String, Header, description = "Bearer (访问令牌)"), - ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"), - ListPermissionsQuery - ) -)] -#[instrument(skip(state))] -/// 查询权限列表(Permission Catalog)。 -/// -/// 返回全局权限目录(`permissions` 表)的分页结果,用于: -/// - 后台展示可分配权限; -/// - 角色绑定权限前的检索与校验; -/// - 按应用(`app_code`)/资源(`resource`)/动作(`action`)筛选。 -/// -/// 权限编码规范为 `${app_code}:${resource}:${action}`,例如 `cms:article:publish`。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID(用于鉴权与跨租户隔离)。 -/// - `State(state): State`:应用状态,包含 `PermissionService`/`AuthorizationService`。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。 -/// - `Query(query): Query`:查询参数(分页/搜索/筛选/排序)。 -/// - `page: Option`:页码(默认 1)。 -/// - `page_size: Option`:每页条数(默认 20,范围 1..=200)。 -/// - `search: Option`:按 `code` 或 `description` 模糊搜索。 -/// - `app_code/resource/action`:精确筛选(用于权限目录分组)。 -/// -/// ## Returns -/// - `Ok(AppResponse>)`:成功返回 `200`,`data` 为权限列表。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:read"))`:调用方缺少 `role:read` 权限(用于控制“查看权限目录”的能力)。 -/// - `Err(AppError::BadRequest(_))`:分页参数非法(如 `page_size>200` 或为 0)。 -/// - `Err(AppError::DbError(_))`:数据库查询失败。 -/// -/// ## Example -/// ```rust,ignore -/// // GET /permissions?page=1&page_size=20&app_code=cms&search=article -/// // Headers: -/// // Authorization: Bearer -/// // X-Tenant-ID: // 可选,但若提供必须与 token 中 tenant_id 一致 -/// ``` -pub async fn list_permissions_handler( - TenantId(tenant_id): TenantId, - State(state): State, - AuthContext { - tenant_id: auth_tenant_id, - user_id, - .. - }: AuthContext, - Query(query): Query, -) -> Result>, AppError> { - if auth_tenant_id != tenant_id { - return Err(AppError::PermissionDenied("tenant:mismatch".into())); - } - state - .authorization_service - .require_permission(tenant_id, user_id, "role:read") - .await?; - let rows = state.permission_service.list_permissions(query).await?; - Ok(AppResponse::ok(rows)) -} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..dbf745a --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod repositories; + diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs new file mode 100644 index 0000000..c78386b --- /dev/null +++ b/src/infrastructure/repositories/mod.rs @@ -0,0 +1,2 @@ +pub mod tenant_config_repo; + diff --git a/src/infrastructure/repositories/tenant_config_repo.rs b/src/infrastructure/repositories/tenant_config_repo.rs new file mode 100644 index 0000000..c4c3ab6 --- /dev/null +++ b/src/infrastructure/repositories/tenant_config_repo.rs @@ -0,0 +1,67 @@ +use async_trait::async_trait; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::repositories::tenant_config_repo::TenantConfigRepo; +use crate::domain::DomainError; +use crate::utils::verify_password; + +#[derive(Clone)] +pub struct TenantConfigRepoPg { + pool: PgPool, +} + +impl TenantConfigRepoPg { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl TenantConfigRepo for TenantConfigRepoPg { + async fn validate_client_pair( + &self, + tenant_id: Uuid, + client_id: &str, + client_secret: &str, + ) -> Result { + let client_id = client_id.trim(); + let client_secret = client_secret.trim(); + if client_id.is_empty() || client_secret.is_empty() { + return Ok(false); + } + + let allowed: Option<(String, Option, Option>)> = + sqlx::query_as( + r#" + SELECT c.secret_hash, c.prev_secret_hash, c.prev_expires_at + FROM oauth_clients c + JOIN tenant_oauth_clients tc + ON tc.client_id = c.client_id + WHERE tc.tenant_id = $1 AND c.client_id = $2 + "#, + ) + .bind(tenant_id) + .bind(client_id) + .fetch_optional(&self.pool) + .await + .map_err(|_| DomainError::Unexpected)?; + + let Some((secret_hash, prev_hash, prev_expires_at)) = allowed else { + return Ok(false); + }; + + if verify_password(client_secret, &secret_hash) { + return Ok(true); + } + + if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at) + && chrono::Utc::now() < prev_expires_at + && verify_password(client_secret, &prev_hash) + { + return Ok(true); + } + + Ok(false) + } +} diff --git a/src/lib.rs b/src/lib.rs index 93327b2..9ddfa57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ +pub mod application; pub mod config; +pub mod constants; pub mod db; pub mod docs; -pub mod handlers; +pub mod domain; +pub mod infrastructure; pub mod middleware; pub mod models; -pub mod services; +pub mod presentation; pub mod utils; - diff --git a/src/main.rs b/src/main.rs index db469e2..a39ff02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,26 @@ +mod application; mod config; +mod constants; mod db; // 声明 db 模块 mod docs; -mod handlers; +mod domain; +mod infrastructure; mod middleware; mod models; +mod presentation; mod redis; -mod services; mod utils; -use axum::{ - Router, - http::StatusCode, - middleware::from_fn, - middleware::from_fn_with_state, - routing::{get, post, put}, -}; +use common_telemetry::telemetry::{self, TelemetryConfig}; use config::AppConfig; -use handlers::{ - 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::{ +use constants::CANONICAL_BASE; +use application::services::{ AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService, TenantService, UserService, }; +use presentation::http::api; +use presentation::http::state::AppState; 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; #[tokio::main] async fn main() { @@ -89,6 +68,9 @@ async fn main() { let authorization_service = AuthorizationService::new(pool.clone()); let app_service = AppService::new(pool.clone()); let permission_service = PermissionService::new(pool.clone()); + let tenant_config_repo = std::sync::Arc::new( + infrastructure::repositories::tenant_config_repo::TenantConfigRepoPg::new(pool.clone()), + ); let redis = match redis::init_manager(&config).await { Ok(m) => m, Err(e) => { @@ -108,189 +90,17 @@ async fn main() { permission_service, redis, auth_code_jwt_secret: config.auth_code_jwt_secret.clone(), + tenant_config_repo, }; - 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/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( - "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(), - "/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()], - }; + if std::env::args().any(|a| a == "--print-routes") { + for (method, path) in api::routes() { + println!("{} {}{}", method, CANONICAL_BASE, path); + } + return; + } - // 5. 构建路由 - let api = Router::new() - .route("/.well-known/jwks.json", get(jwks_handler)) - .route("/tenants/register", post(create_tenant_handler)) - .route( - "/tenants/me", - get(get_tenant_handler) - .patch(update_tenant_handler) - .delete(delete_tenant_handler), - ) - .route("/tenants/me/status", post(update_tenant_status_handler)) - .route( - "/auth/register", - post(register_handler) - .layer(middleware::rate_limit::register_rate_limiter()) - .layer(from_fn(middleware::rate_limit::log_rate_limit_register)), - ) - .route( - "/auth/login", - post(login_handler) - .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)) - .route("/users/me/password/reset", post(reset_my_password_handler)) - .route("/permissions", get(list_permissions_handler)) - .route( - "/users/{id}", - get(get_user_handler) - .patch(update_user_handler) - .delete(delete_user_handler), - ) - .route( - "/users/{id}/password/reset", - post(reset_user_password_handler), - ) - .route( - "/users/{id}/roles", - get(list_user_roles_handler).put(set_user_roles_handler), - ) - .route("/roles", get(list_roles_handler).post(create_role_handler)) - .route( - "/roles/{id}", - get(get_role_handler) - .patch(update_role_handler) - .delete(delete_role_handler), - ) - .route( - "/roles/{id}/permissions/grant", - post(grant_role_permissions_handler), - ) - .route( - "/roles/{id}/permissions/revoke", - post(revoke_role_permissions_handler), - ) - .route("/roles/{id}/users/grant", post(grant_role_users_handler)) - .route("/roles/{id}/users/revoke", post(revoke_role_users_handler)) - .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, - )); - - let platform_api = Router::new() - .route( - "/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), - ) - .route( - "/platform/apps/{app_id}", - get(get_app_handler) - .patch(update_app_handler) - .delete(delete_app_handler), - ) - .route( - "/platform/apps/{app_id}/status-change-requests", - post(request_app_status_change_handler), - ) - .route( - "/platform/app-status-change-requests", - get(list_app_status_change_requests_handler), - ) - .route( - "/platform/app-status-change-requests/{request_id}/approve", - post(approve_app_status_change_handler), - ) - .route( - "/platform/app-status-change-requests/{request_id}/reject", - post(reject_app_status_change_handler), - ) - .layer(from_fn_with_state( - auth_cfg, - middleware::auth::authenticate_with_config, - )) - .layer(from_fn( - 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(v1.clone()) - .nest("/iam/api/v1", v1) - .with_state(state); + let app = api::build_app(state); // 6. 启动服务器 let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); diff --git a/src/middleware/auth_tenant.rs b/src/middleware/auth_tenant.rs new file mode 100644 index 0000000..9bb957f --- /dev/null +++ b/src/middleware/auth_tenant.rs @@ -0,0 +1,41 @@ +use axum::{extract::Request, middleware::Next, response::Response}; +use common_telemetry::AppError; +use uuid::Uuid; + +use crate::middleware::TenantId; + +fn parse_tenant_id_from_query(req: &Request) -> Option { + let q = req.uri().query()?; + url::form_urlencoded::parse(q.as_bytes()) + .find_map(|(k, v)| (k == "tenant_id").then_some(v)) + .and_then(|v| Uuid::parse_str(v.as_ref()).ok()) +} + +fn parse_tenant_id_from_headers(req: &Request) -> Option { + req.headers() + .get("X-Tenant-ID") + .or_else(|| req.headers().get("X-Tenant-Id")) + .and_then(|v| v.to_str().ok()) + .and_then(|v| Uuid::parse_str(v).ok()) +} + +pub async fn validate_auth_tenant(req: Request, next: Next) -> Result { + let path = req.uri().path(); + if path.ends_with("/auth/refresh") { + return Ok(next.run(req).await); + } + + let tenant_id = parse_tenant_id_from_headers(&req).or_else(|| parse_tenant_id_from_query(&req)); + let Some(tenant_id) = tenant_id else { + return Err(AppError::BadRequest( + "Missing X-Tenant-ID header or tenant_id query".into(), + )); + }; + + tracing::Span::current().record("tenant_id", tracing::field::display(tenant_id)); + + let mut req = req; + req.extensions_mut().insert(TenantId(tenant_id)); + Ok(next.run(req).await) +} + diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index 81be67a..a3d58f9 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1,4 +1,7 @@ pub mod auth; +pub mod auth_tenant; pub mod rate_limit; -pub use auth_kit::middleware::tenant::{TenantId, TenantMiddlewareConfig, resolve_tenant_with_config}; +pub use auth_kit::middleware::tenant::{ + TenantId, TenantMiddlewareConfig, resolve_tenant_with_config, +}; diff --git a/src/middleware/rate_limit.rs b/src/middleware/rate_limit.rs index cfc1177..bce0125 100644 --- a/src/middleware/rate_limit.rs +++ b/src/middleware/rate_limit.rs @@ -17,7 +17,7 @@ use tower_governor::key_extractor::{KeyExtractor, PeerIpKeyExtractor, SmartIpKey use tracing::Instrument; #[derive(Clone, Debug)] -pub(crate) struct TrustedProxySmartIpKeyExtractor { +pub struct TrustedProxySmartIpKeyExtractor { trusted_proxies: Vec, } diff --git a/src/presentation/http/api.rs b/src/presentation/http/api.rs new file mode 100644 index 0000000..abd9e7d --- /dev/null +++ b/src/presentation/http/api.rs @@ -0,0 +1,270 @@ +use axum::{ + Router, + routing::{get, post, put}, +}; +use utoipa::OpenApi; +use utoipa_scalar::{Scalar, Servable}; + +use crate::constants::CANONICAL_BASE; +use crate::docs::ApiDoc; +use crate::middleware as core_middleware; +use crate::presentation::http::handlers::{ + app, auth, authorization, client, jwks, permission, platform, role, sso, tenant, user, +}; +use crate::presentation::http::state::AppState; + +pub fn routes() -> Vec<(&'static str, &'static str)> { + vec![ + ("GET", "/.well-known/jwks.json"), + ("POST", "/tenants/register"), + ("POST", "/auth/register"), + ("POST", "/auth/login"), + ("POST", "/auth/login-code"), + ("POST", "/auth/refresh"), + ("POST", "/auth/logout"), + ("POST", "/auth/code2token"), + ("POST", "/internal/auth/code2token"), + ("GET", "/me/permissions"), + ("POST", "/authorize/check"), + ("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"), + ("GET", "/permissions"), + ("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"), + ("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}"), + ("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", + ), + ] +} + +pub fn build_app(state: AppState) -> Router { + let tenant_required_auth = Router::new() + .route( + "/register", + post(auth::register_handler) + .layer(core_middleware::rate_limit::register_rate_limiter()) + .layer(axum::middleware::from_fn( + core_middleware::rate_limit::log_rate_limit_register, + )), + ) + .route( + "/login", + post(auth::login_handler) + .layer(core_middleware::rate_limit::login_rate_limiter()) + .layer(axum::middleware::from_fn( + core_middleware::rate_limit::log_rate_limit_login, + )), + ) + .route( + "/login-code", + post(sso::login_code_handler) + .layer(core_middleware::rate_limit::login_rate_limiter()) + .layer(axum::middleware::from_fn( + core_middleware::rate_limit::log_rate_limit_login, + )), + ) + .route("/code2token", post(sso::code2token_handler)) + .layer(axum::middleware::from_fn( + core_middleware::auth_tenant::validate_auth_tenant, + )); + + let no_tenant_auth = Router::new().route( + "/refresh", + post(auth::refresh_handler) + .layer(core_middleware::rate_limit::login_rate_limiter()) + .layer(axum::middleware::from_fn( + core_middleware::rate_limit::log_rate_limit_login, + )), + ); + + let public_v1 = Router::new() + .route("/.well-known/jwks.json", get(jwks::jwks_handler)) + .route("/tenants/register", post(tenant::create_tenant_handler)) + .route( + "/internal/auth/code2token", + post(sso::internal_code2token_handler), + ) + .nest( + "/auth", + Router::new() + .merge(tenant_required_auth) + .merge(no_tenant_auth), + ) + .with_state(state.clone()); + + let auth_cfg = core_middleware::auth::AuthMiddlewareConfig { + skip_exact_paths: vec![], + skip_path_prefixes: vec![], + jwt: auth_kit::jwt::JwtVerifyConfig::rs256_from_pem( + "iam-service", + &crate::utils::keys::get_keys().public_pem, + ) + .expect("invalid JWT_PUBLIC_KEY_PEM"), + }; + let tenant_cfg = core_middleware::TenantMiddlewareConfig { + skip_exact_paths: vec![], + skip_path_prefixes: vec![], + }; + + let protected_v1 = Router::new() + .route("/auth/logout", post(auth::logout_handler)) + .route("/me/permissions", get(authorization::my_permissions_handler)) + .route( + "/authorize/check", + post(authorization::authorization_check_handler), + ) + .route("/users", get(user::list_users_handler)) + .route( + "/users/me/password/reset", + post(user::reset_my_password_handler), + ) + .route("/permissions", get(permission::list_permissions_handler)) + .route( + "/users/{id}", + get(user::get_user_handler) + .patch(user::update_user_handler) + .delete(user::delete_user_handler), + ) + .route( + "/users/{id}/password/reset", + post(user::reset_user_password_handler), + ) + .route( + "/users/{id}/roles", + get(user::list_user_roles_handler).put(user::set_user_roles_handler), + ) + .route( + "/roles", + get(role::list_roles_handler).post(role::create_role_handler), + ) + .route( + "/roles/{id}", + get(role::get_role_handler) + .patch(role::update_role_handler) + .delete(role::delete_role_handler), + ) + .route( + "/roles/{id}/permissions/grant", + post(role::grant_role_permissions_handler), + ) + .route( + "/roles/{id}/permissions/revoke", + post(role::revoke_role_permissions_handler), + ) + .route("/roles/{id}/users/grant", post(role::grant_role_users_handler)) + .route( + "/roles/{id}/users/revoke", + post(role::revoke_role_users_handler), + ) + .route( + "/tenants/me", + get(tenant::get_tenant_handler) + .patch(tenant::update_tenant_handler) + .delete(tenant::delete_tenant_handler), + ) + .route("/tenants/me/status", post(tenant::update_tenant_status_handler)) + .layer(axum::middleware::from_fn_with_state( + auth_cfg.clone(), + core_middleware::auth::authenticate_with_config, + )) + .layer(axum::middleware::from_fn_with_state( + tenant_cfg.clone(), + core_middleware::resolve_tenant_with_config, + )) + .with_state(state.clone()); + + let platform_v1 = Router::new() + .route( + "/platform/tenants/{tenant_id}/enabled-apps", + get(platform::get_tenant_enabled_apps_handler) + .put(platform::set_tenant_enabled_apps_handler), + ) + .route( + "/platform/clients", + get(client::list_clients_handler).post(client::create_client_handler), + ) + .route( + "/platform/clients/{client_id}/rotate-secret", + post(client::rotate_client_secret_handler), + ) + .route( + "/platform/clients/{client_id}/redirect-uris", + put(client::update_client_redirect_uris_handler), + ) + .route("/platform/apps", get(app::list_apps_handler).post(app::create_app_handler)) + .route( + "/platform/apps/{app_id}", + get(app::get_app_handler) + .patch(app::update_app_handler) + .delete(app::delete_app_handler), + ) + .route( + "/platform/apps/{app_id}/status-change-requests", + post(app::request_app_status_change_handler), + ) + .route( + "/platform/app-status-change-requests", + get(app::list_app_status_change_requests_handler), + ) + .route( + "/platform/app-status-change-requests/{request_id}/approve", + post(app::approve_app_status_change_handler), + ) + .route( + "/platform/app-status-change-requests/{request_id}/reject", + post(app::reject_app_status_change_handler), + ) + .layer(axum::middleware::from_fn_with_state( + auth_cfg, + core_middleware::auth::authenticate_with_config, + )) + .with_state(state.clone()); + + let v1 = Router::new() + .merge(public_v1) + .merge(protected_v1) + .merge(platform_v1) + .layer(axum::middleware::from_fn( + common_telemetry::axum_middleware::trace_http_request, + )); + + Router::new() + .route( + "/favicon.ico", + get(|| async { axum::http::StatusCode::NO_CONTENT }), + ) + .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) + .nest(CANONICAL_BASE, v1) + .with_state(state) +} diff --git a/src/handlers/app.rs b/src/presentation/http/handlers/app.rs similarity index 92% rename from src/handlers/app.rs rename to src/presentation/http/handlers/app.rs index ebf29de..45d2491 100644 --- a/src/handlers/app.rs +++ b/src/presentation/http/handlers/app.rs @@ -1,9 +1,9 @@ -use crate::handlers::AppState; use crate::middleware::auth::AuthContext; use crate::models::{ App, AppStatusChangeRequest, ApproveAppStatusChangeRequest, CreateAppRequest, ListAppsQuery, RequestAppStatusChangeRequest, UpdateAppRequest, }; +use crate::presentation::http::state::AppState; use axum::{ Json, extract::{Path, Query, State}, @@ -14,7 +14,10 @@ use tracing::instrument; use uuid::Uuid; fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> { - let Some(expected) = std::env::var("IAM_SENSITIVE_ACTION_TOKEN").ok().filter(|v| !v.is_empty()) else { + let Some(expected) = std::env::var("IAM_SENSITIVE_ACTION_TOKEN") + .ok() + .filter(|v| !v.is_empty()) + else { return Ok(()); }; let provided = headers @@ -25,12 +28,12 @@ fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> { if provided == expected { Ok(()) } else { - Err(AppError::PermissionDenied("sensitive:token_required".into())) + Err(AppError::PermissionDenied( + "sensitive:token_required".into(), + )) } } -/// Create app (registry). -/// 创建应用(应用注册表)。 #[utoipa::path( post, path = "/platform/apps", @@ -63,8 +66,6 @@ pub async fn create_app_handler( Ok(AppResponse::created(app)) } -/// List apps (registry). -/// 查询应用列表(分页/筛选/排序)。 #[utoipa::path( get, path = "/platform/apps", @@ -97,8 +98,6 @@ pub async fn list_apps_handler( Ok(AppResponse::ok(apps)) } -/// Get app by id (registry). -/// 查询应用详情。 #[utoipa::path( get, path = "/platform/apps/{app_id}", @@ -131,8 +130,6 @@ pub async fn get_app_handler( Ok(AppResponse::ok(app)) } -/// Update app (registry). -/// 更新应用基础信息。 #[utoipa::path( patch, path = "/platform/apps/{app_id}", @@ -171,8 +168,6 @@ pub async fn update_app_handler( Ok(AppResponse::ok(app)) } -/// Request app status change (enable/disable). -/// 申请应用上下线(需要审批,可设置生效时间)。 #[utoipa::path( post, path = "/platform/apps/{app_id}/status-change-requests", @@ -211,8 +206,6 @@ pub async fn request_app_status_change_handler( Ok(AppResponse::created(req)) } -/// List app status change requests. -/// 查询应用状态变更审批单列表。 #[utoipa::path( get, path = "/platform/app-status-change-requests", @@ -245,9 +238,7 @@ pub async fn list_app_status_change_requests_handler( .await?; let status = params.get("status").cloned(); let page = params.get("page").and_then(|v| v.parse::().ok()); - let page_size = params - .get("page_size") - .and_then(|v| v.parse::().ok()); + let page_size = params.get("page_size").and_then(|v| v.parse::().ok()); let rows = state .app_service .list_status_change_requests(status, page, page_size) @@ -255,8 +246,6 @@ pub async fn list_app_status_change_requests_handler( Ok(AppResponse::ok(rows)) } -/// Approve app status change request. -/// 审批通过应用状态变更审批单。 #[utoipa::path( post, path = "/platform/app-status-change-requests/{request_id}/approve", @@ -295,8 +284,6 @@ pub async fn approve_app_status_change_handler( Ok(AppResponse::ok(row)) } -/// Reject app status change request. -/// 驳回应用状态变更审批单。 #[utoipa::path( post, path = "/platform/app-status-change-requests/{request_id}/reject", @@ -336,8 +323,6 @@ pub async fn reject_app_status_change_handler( Ok(AppResponse::ok(row)) } -/// Delete app (soft delete). -/// 删除应用(软删除,标记 status=deleted)。 #[utoipa::path( delete, path = "/platform/apps/{app_id}", @@ -372,4 +357,3 @@ pub async fn delete_app_handler( state.app_service.delete_app(&app_id, user_id).await?; Ok(AppResponse::ok(serde_json::json!({}))) } - diff --git a/src/handlers/auth.rs b/src/presentation/http/handlers/auth.rs similarity index 81% rename from src/handlers/auth.rs rename to src/presentation/http/handlers/auth.rs index 3a49e11..cffb65f 100644 --- a/src/handlers/auth.rs +++ b/src/presentation/http/handlers/auth.rs @@ -1,4 +1,4 @@ -use crate::handlers::AppState; +use crate::presentation::http::state::AppState; use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; use crate::models::{ @@ -8,8 +8,6 @@ use axum::{Json, extract::State}; use common_telemetry::{AppError, AppResponse}; use tracing::instrument; -/// Register (create user in tenant). -/// 注册接口(在租户下创建用户)。 #[utoipa::path( post, path = "/auth/register", @@ -26,26 +24,17 @@ use tracing::instrument; )] #[instrument(skip(state, payload))] pub async fn register_handler( - // 1. 自动注入 TenantId (由中间件解析) TenantId(tenant_id): TenantId, - // 2. 获取全局状态中的 Service State(state): State, - // 3. 获取 Body Json(payload): Json, ) -> Result, AppError> { let user = state.auth_service.register(tenant_id, payload).await?; - - // 转换为 Response DTO (隐藏密码等敏感信息) - let response = UserResponse { + Ok(AppResponse::created(UserResponse { id: user.id, email: user.email.clone(), - }; - - Ok(AppResponse::created(response)) + })) } -/// Login (issue access token). -/// 登录接口(签发访问令牌)。 #[utoipa::path( post, path = "/auth/login", @@ -67,12 +56,9 @@ pub async fn login_handler( Json(payload): Json, ) -> Result, AppError> { let response = state.auth_service.login(tenant_id, payload).await?; - Ok(AppResponse::ok(response)) } -/// Refresh access token (rotate refresh token). -/// 刷新访问令牌(同时轮换 refresh_token,一次性使用)。 #[utoipa::path( post, path = "/auth/refresh", @@ -95,8 +81,6 @@ pub async fn refresh_handler( Ok(AppResponse::ok(response)) } -/// Logout (revoke all refresh tokens for current user). -/// 退出登录(吊销当前用户所有 refresh token)。 #[utoipa::path( post, path = "/auth/logout", diff --git a/src/handlers/authorization.rs b/src/presentation/http/handlers/authorization.rs similarity index 81% rename from src/handlers/authorization.rs rename to src/presentation/http/handlers/authorization.rs index ac1d2dd..a34e0e7 100644 --- a/src/handlers/authorization.rs +++ b/src/presentation/http/handlers/authorization.rs @@ -1,10 +1,10 @@ -use crate::handlers::AppState; use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; +use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse}; +use crate::presentation::http::state::AppState; use axum::{Json, extract::State}; use common_telemetry::{AppError, AppResponse}; use tracing::instrument; -use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse}; #[utoipa::path( get, @@ -24,22 +24,6 @@ use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse}; ) )] #[instrument(skip(state))] -/// List current user's permissions in current tenant. -/// 查询当前登录用户在当前租户下的权限编码列表。 -/// -/// 用途: -/// - 快速自查当前令牌是否携带期望的权限(便于联调与排障)。 -/// -/// 输入: -/// - Header `Authorization: Bearer `(必填) -/// - Header `X-Tenant-ID`(可选;若提供需与 Token 中 tenant_id 一致,否则返回 403) -/// -/// 输出: -/// - `200`:权限字符串数组(如 `user:read`) -/// -/// 异常: -/// - `401`:未携带或无法解析访问令牌 -/// - `403`:租户不匹配或无权访问 pub async fn my_permissions_handler( TenantId(tenant_id): TenantId, State(state): State, diff --git a/src/handlers/client.rs b/src/presentation/http/handlers/client.rs similarity index 89% rename from src/handlers/client.rs rename to src/presentation/http/handlers/client.rs index 9d30583..58848ed 100644 --- a/src/handlers/client.rs +++ b/src/presentation/http/handlers/client.rs @@ -1,4 +1,4 @@ -use crate::handlers::AppState; +use crate::presentation::http::state::AppState; use crate::middleware::auth::AuthContext; use crate::models::{ ClientSummary, CreateClientRequest, CreateClientResponse, RotateClientSecretResponse, @@ -11,8 +11,6 @@ use axum::{ 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", @@ -57,8 +55,6 @@ pub async fn create_client_handler( })) } -/// Update allowed redirect URIs for a client. -/// 更新 client 的允许回调地址(redirectUris)。 #[utoipa::path( put, path = "/platform/clients/{client_id}/redirect-uris", @@ -98,8 +94,6 @@ pub async fn update_client_redirect_uris_handler( 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", @@ -129,18 +123,13 @@ pub async fn rotate_client_secret_handler( .require_platform_permission(user_id, "iam:client:write") .await?; - let secret = state - .client_service - .rotate_secret(client_id.clone()) - .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", diff --git a/src/handlers/jwks.rs b/src/presentation/http/handlers/jwks.rs similarity index 99% rename from src/handlers/jwks.rs rename to src/presentation/http/handlers/jwks.rs index eb9d9d7..8d3d72c 100644 --- a/src/handlers/jwks.rs +++ b/src/presentation/http/handlers/jwks.rs @@ -91,3 +91,4 @@ mod tests { assert!(jwks.keys.len() >= 2); } } + diff --git a/src/presentation/http/handlers/mod.rs b/src/presentation/http/handlers/mod.rs new file mode 100644 index 0000000..b082511 --- /dev/null +++ b/src/presentation/http/handlers/mod.rs @@ -0,0 +1,11 @@ +pub mod auth; +pub mod app; +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; diff --git a/src/presentation/http/handlers/permission.rs b/src/presentation/http/handlers/permission.rs new file mode 100644 index 0000000..d5b221a --- /dev/null +++ b/src/presentation/http/handlers/permission.rs @@ -0,0 +1,48 @@ +use crate::middleware::TenantId; +use crate::middleware::auth::AuthContext; +use crate::models::{ListPermissionsQuery, Permission}; +use crate::presentation::http::state::AppState; +use axum::extract::{Query, State}; +use common_telemetry::{AppError, AppResponse}; +use tracing::instrument; + +#[utoipa::path( + get, + path = "/permissions", + tag = "Permission", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "Permission list", body = [Permission]), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"), + ListPermissionsQuery + ) +)] +#[instrument(skip(state))] +pub async fn list_permissions_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Query(query): Query, +) -> Result>, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "role:read") + .await?; + let rows = state.permission_service.list_permissions(query).await?; + Ok(AppResponse::ok(rows)) +} diff --git a/src/handlers/platform.rs b/src/presentation/http/handlers/platform.rs similarity index 91% rename from src/handlers/platform.rs rename to src/presentation/http/handlers/platform.rs index 37636d2..c03b5c9 100644 --- a/src/handlers/platform.rs +++ b/src/presentation/http/handlers/platform.rs @@ -1,6 +1,6 @@ -use crate::handlers::AppState; use crate::middleware::auth::AuthContext; use crate::models::{TenantEnabledAppsResponse, UpdateTenantEnabledAppsRequest}; +use crate::presentation::http::state::AppState; use axum::{ Json, extract::{Path, State}, @@ -9,8 +9,6 @@ use common_telemetry::{AppError, AppResponse}; use tracing::instrument; use uuid::Uuid; -/// Get tenant enabled apps (platform scope). -/// 平台层:查询租户已开通应用(enabled_apps)。 #[utoipa::path( get, path = "/platform/tenants/{tenant_id}/enabled-apps", @@ -48,8 +46,6 @@ pub async fn get_tenant_enabled_apps_handler( })) } -/// Set tenant enabled apps (platform scope, full replace). -/// 平台层:设置租户已开通应用(enabled_apps,全量覆盖)。 #[utoipa::path( put, path = "/platform/tenants/{tenant_id}/enabled-apps", @@ -93,3 +89,4 @@ pub async fn set_tenant_enabled_apps_handler( updated_at, })) } + diff --git a/src/handlers/role.rs b/src/presentation/http/handlers/role.rs similarity index 55% rename from src/handlers/role.rs rename to src/presentation/http/handlers/role.rs index 57010a8..8d83523 100644 --- a/src/handlers/role.rs +++ b/src/presentation/http/handlers/role.rs @@ -1,9 +1,9 @@ -use crate::handlers::AppState; use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; use crate::models::{ CreateRoleRequest, RolePermissionsRequest, RoleResponse, RoleUsersRequest, UpdateRoleRequest, }; +use crate::presentation::http::state::AppState; use axum::{ Json, extract::{Path, State}, @@ -32,36 +32,6 @@ use uuid::Uuid; ) )] #[instrument(skip(state, payload))] -/// 在当前租户下创建自定义角色(Role)。 -/// -/// 本接口用于创建 **租户级自定义角色**(`roles.tenant_id = 当前租户`)。创建后可继续通过 -/// “角色-权限绑定”接口为角色授予权限,再通过“用户-角色绑定”接口把角色授予用户。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(内部包含 `Uuid`):当前请求的租户 ID(由中间件解析并注入)。 -/// - `State(state): State`:应用状态,包含 `RoleService`/`AuthorizationService` 等依赖。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`: -/// 从 `Authorization: Bearer ` 解析得到的认证上下文,包含调用方用户 ID 与租户 ID。 -/// - `Json(payload): Json`:创建角色的请求体(角色名与描述)。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:创建成功返回 `201`,`data` 为新建角色(含 `id/name/description`)。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:Token 中租户与当前租户不一致(跨租户访问被拒绝)。 -/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。 -/// - `Err(AppError::AlreadyExists(_))`:角色名冲突(同租户下同名角色已存在)。 -/// - `Err(AppError::DbError(_))`:数据库写入失败(连接异常、约束失败等)。 -/// -/// ## Example -/// ```rust,ignore -/// // POST /roles -/// // Headers: -/// // Authorization: Bearer -/// // X-Tenant-ID: // 可选,但若提供必须与 token 中 tenant_id 一致 -/// // Body: -/// // { "name": "ContentAdmin", "description": "CMS content admins" } -/// ``` pub async fn create_role_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -109,23 +79,6 @@ pub async fn create_role_handler( ) )] #[instrument(skip(state))] -/// 查询当前租户下的角色列表(Role)。 -/// -/// 返回当前租户的所有角色(包含系统角色与自定义角色)。系统角色通常 `is_system=true`, -/// 用于内置管理员/平台管理员等能力;自定义角色用于业务场景的权限组合。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前请求的租户 ID。 -/// - `State(state): State>)`:成功返回 `200`,`data` 为角色数组。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:read"))`:调用方缺少 `role:read` 权限。 -/// - `Err(AppError::DbError(_))`:数据库查询失败。 pub async fn list_roles_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -175,24 +128,6 @@ pub async fn list_roles_handler( ) )] #[instrument(skip(state))] -/// 查询角色详情(Role)。 -/// -/// 仅允许读取当前租户内的角色,避免跨租户信息泄露。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为角色信息。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:read"))`:调用方缺少 `role:read` 权限。 -/// - `Err(AppError::NotFound(_))`:角色不存在或不属于当前租户。 -/// - `Err(AppError::DbError(_))`:数据库查询失败。 pub async fn get_role_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -240,28 +175,6 @@ pub async fn get_role_handler( ) )] #[instrument(skip(state, payload))] -/// 更新角色基础信息(Role)。 -/// -/// 仅允许修改 **自定义角色**。当目标角色为系统角色(`is_system=true`)时返回 403, -/// 以防止线上误操作导致全局权限体系漂移。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// - `Json(payload): Json`:可选更新字段(`name`/`description`)。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为更新后的角色信息。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。 -/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可修改。 -/// - `Err(AppError::AlreadyExists(_))`:角色名冲突。 -/// - `Err(AppError::NotFound(_))`:角色不存在。 -/// - `Err(AppError::DbError(_))`:数据库写入失败。 pub async fn update_role_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -311,26 +224,6 @@ pub async fn update_role_handler( ) )] #[instrument(skip(state))] -/// 删除角色(Role)。 -/// -/// 仅允许删除 **自定义角色**;系统角色(`is_system=true`)不可删除。 -/// 删除角色会级联删除 `user_roles` 与 `role_permissions` 关联(由外键约束实现)。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为 `{}`。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。 -/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可删除。 -/// - `Err(AppError::NotFound(_))`:角色不存在。 -/// - `Err(AppError::DbError(_))`:数据库删除失败。 pub async fn delete_role_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -377,38 +270,6 @@ pub async fn delete_role_handler( ) )] #[instrument(skip(state, payload))] -/// 为角色批量授予权限(Role → Permission 绑定)。 -/// -/// 将 `permission_codes` 批量写入 `role_permissions`(幂等:重复绑定不会报错)。 -/// 仅允许对 **自定义角色** 执行此操作;系统角色不可通过 API 修改权限集。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// - `Json(payload): Json`:待授予的权限码数组(`Vec`)。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为 `{}`。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。 -/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可变更权限集。 -/// - `Err(AppError::BadRequest(_))`:存在非法的 `permission_codes`(权限码未在 `permissions` 表中定义)。 -/// - `Err(AppError::NotFound(_))`:角色不存在。 -/// - `Err(AppError::DbError(_))`:数据库写入失败。 -/// -/// ## Example -/// ```rust,ignore -/// // POST /roles/{role_id}/permissions/grant -/// // Body: -/// // { "permission_codes": ["cms:article:create", "cms:article:publish", "cms:*:*"] } -/// // -/// // 说明: -/// // - 通配符权限(如 cms:*:*)只有在 permissions 表中存在并被绑定到角色时才会生效。 -/// ``` pub async fn grant_role_permissions_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -456,27 +317,6 @@ pub async fn grant_role_permissions_handler( ) )] #[instrument(skip(state, payload))] -/// 从角色批量回收权限(Role → Permission 解绑)。 -/// -/// 从 `role_permissions` 删除指定 `permission_codes` 的关联(幂等:不存在的关联不会报错)。 -/// 仅允许对 **自定义角色** 执行此操作;系统角色不可通过 API 修改权限集。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// - `Json(payload): Json`:待回收的权限码数组(`Vec`)。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为 `{}`。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。 -/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可变更权限集。 -/// - `Err(AppError::NotFound(_))`:角色不存在。 -/// - `Err(AppError::DbError(_))`:数据库写入失败。 pub async fn revoke_role_permissions_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -524,34 +364,6 @@ pub async fn revoke_role_permissions_handler( ) )] #[instrument(skip(state, payload))] -/// 批量把角色授予用户(User ← Role 绑定)。 -/// -/// 将 `user_ids` 批量写入 `user_roles`(幂等:重复授予不会报错)。 -/// 该接口用于“按角色”为多个用户授权,适用于组织/部门批量开通权限的场景。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文(`user_id` 为操作者)。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// - `Json(payload): Json`:待授予用户 ID 列表(`Vec`)。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为 `{}`。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("user:write"))`:调用方缺少 `user:write` 权限。 -/// - `Err(AppError::NotFound(_))`:角色不存在。 -/// - `Err(AppError::BadRequest(_))`:存在非法 `user_ids`(用户不在当前租户内)。 -/// - `Err(AppError::DbError(_))`:数据库写入失败。 -/// -/// ## Example -/// ```rust,ignore -/// // POST /roles/{role_id}/users/grant -/// // Body: -/// // { "user_ids": ["", ""] } -/// ``` pub async fn grant_role_users_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -599,25 +411,6 @@ pub async fn grant_role_users_handler( ) )] #[instrument(skip(state, payload))] -/// 批量从用户回收角色(User ← Role 解绑)。 -/// -/// 从 `user_roles` 删除指定 `user_ids` 的角色关联(幂等:不存在的关联不会报错)。 -/// -/// ## Parameters -/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。 -/// - `State(state): State`:应用状态。 -/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文(`user_id` 为操作者)。 -/// - `Path(role_id): Path`:目标角色 ID。 -/// - `Json(payload): Json`:待回收用户 ID 列表(`Vec`)。 -/// -/// ## Returns -/// - `Ok(AppResponse)`:成功返回 `200`,`data` 为 `{}`。 -/// -/// ## Errors -/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。 -/// - `Err(AppError::PermissionDenied("user:write"))`:调用方缺少 `user:write` 权限。 -/// - `Err(AppError::NotFound(_))`:角色不存在。 -/// - `Err(AppError::DbError(_))`:数据库写入失败。 pub async fn revoke_role_users_handler( TenantId(tenant_id): TenantId, State(state): State, diff --git a/src/handlers/sso.rs b/src/presentation/http/handlers/sso.rs similarity index 53% rename from src/handlers/sso.rs rename to src/presentation/http/handlers/sso.rs index 7922438..3c64dae 100644 --- a/src/handlers/sso.rs +++ b/src/presentation/http/handlers/sso.rs @@ -1,16 +1,24 @@ -use crate::handlers::AppState; +use crate::application::use_cases::exchange_code::{ExchangeCodeUseCase, Execute as _}; +use crate::presentation::http::state::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 jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use redis::AsyncCommands; -use redis::Script; use serde::Deserialize; use tracing::instrument; use uuid::Uuid; +fn map_domain_error(e: crate::domain::DomainError) -> AppError { + match e { + crate::domain::DomainError::Unauthorized => AppError::AuthError("Unauthorized".into()), + crate::domain::DomainError::InvalidArgument(s) => AppError::BadRequest(s), + crate::domain::DomainError::Unexpected => AppError::AnyhowError(anyhow!("Unexpected")), + } +} + #[derive(Debug, Deserialize, serde::Serialize)] struct AuthCodeClaims { sub: String, @@ -23,20 +31,10 @@ struct AuthCodeClaims { 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", @@ -46,100 +44,102 @@ fn redis_key(jti: &str) -> String { (status = 200, description = "Token issued", body = Code2TokenResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized") + ), + params( + ("X-Tenant-ID" = String, Header, description = "Tenant UUID (required for external calls)") ) )] #[instrument(skip(state, payload))] pub async fn code2token_handler( + TenantId(expected_tenant_id): TenantId, State(state): State, Json(payload): Json, ) -> Result, AppError> { + let ok = state + .tenant_config_repo + .validate_client_pair( + expected_tenant_id, + &payload.client_id, + &payload.client_secret, + ) + .await + .map_err(map_domain_error)?; + if !ok { + return Err(AppError::AuthError("Invalid client credentials".into())); + } + + let use_case = ExchangeCodeUseCase { + auth_service: state.auth_service.clone(), + redis: state.redis.clone(), + auth_code_jwt_secret: state.auth_code_jwt_secret.clone(), + }; + let res = use_case.execute(payload).await.map_err(map_domain_error)?; + if res.tenant_id != expected_tenant_id { + return Err(AppError::AuthError("Invalid code".into())); + } + + Ok(AppResponse::ok(Code2TokenResponse { + access_token: res.access_token, + refresh_token: res.refresh_token, + token_type: res.token_type, + expires_in: res.expires_in, + tenant_id: res.tenant_id.to_string(), + user_id: res.user_id.to_string(), + })) +} + +#[utoipa::path( + post, + path = "/internal/auth/code2token", + tag = "Auth", + request_body = Code2TokenRequest, + responses( + (status = 200, description = "Token issued", body = Code2TokenResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + params( + ("X-Internal-Token" = String, Header, description = "Pre-shared internal token"), + ("X-Tenant-ID" = Option, Header, description = "Optional tenant UUID") + ) +)] +#[instrument(skip(state, payload))] +pub async fn internal_code2token_handler( + headers: axum::http::HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let expected = std::env::var("INTERNAL_EXCHANGE_PSK") + .map_err(|_| AppError::ConfigError("INTERNAL_EXCHANGE_PSK is required".into()))?; + let token = headers + .get("X-Internal-Token") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if token != expected { + return Err(AppError::AuthError("Invalid internal token".into())); + } + 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 use_case = ExchangeCodeUseCase { + auth_service: state.auth_service.clone(), + redis: state.redis.clone(), + auth_code_jwt_secret: state.auth_code_jwt_secret.clone(), }; - - 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?; - + let res = use_case.execute(payload).await.map_err(map_domain_error)?; 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(), + access_token: res.access_token, + refresh_token: res.refresh_token, + token_type: res.token_type, + expires_in: res.expires_in, + tenant_id: res.tenant_id.to_string(), + user_id: res.user_id.to_string(), })) } -/// Login with username/password and issue one-time authorization code. -/// 用户账户密码登录并签发一次性授权码(用于 SSO 授权码模式)。 #[utoipa::path( post, path = "/auth/login-code", diff --git a/src/handlers/tenant.rs b/src/presentation/http/handlers/tenant.rs similarity index 72% rename from src/handlers/tenant.rs rename to src/presentation/http/handlers/tenant.rs index 9589f3e..b97b350 100644 --- a/src/handlers/tenant.rs +++ b/src/presentation/http/handlers/tenant.rs @@ -1,9 +1,9 @@ -use crate::handlers::AppState; use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; use crate::models::{ CreateTenantRequest, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest, }; +use crate::presentation::http::state::AppState; use axum::{Json, extract::State, http::HeaderMap}; use common_telemetry::{AppError, AppResponse}; use tracing::instrument; @@ -43,21 +43,6 @@ fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> { ) )] #[instrument(skip(state, payload))] -/// Create tenant (public endpoint). -/// 创建租户(公开接口)。 -/// -/// 业务规则: -/// - 新租户默认 `status=active`。 -/// - `config` 未提供时默认 `{}`。 -/// -/// 输入: -/// - Body `CreateTenantRequest`(必填) -/// -/// 输出: -/// - `201`:返回新建租户信息(含 `id`) -/// -/// 异常: -/// - `400`:请求参数错误 pub async fn create_tenant_handler( headers: HeaderMap, State(state): State, @@ -65,13 +50,12 @@ pub async fn create_tenant_handler( ) -> Result, AppError> { require_sensitive_token(&headers)?; let tenant = state.tenant_service.create_tenant(payload).await?; - let response = TenantResponse { + Ok(AppResponse::created(TenantResponse { id: tenant.id, name: tenant.name, status: tenant.status, config: tenant.config, - }; - Ok(AppResponse::created(response)) + })) } #[utoipa::path( @@ -92,23 +76,6 @@ pub async fn create_tenant_handler( ) )] #[instrument(skip(state))] -/// Get current tenant info. -/// 获取当前登录用户所属租户的信息。 -/// -/// 业务规则: -/// - 若同时提供 `X-Tenant-ID` 与 Token 中租户不一致,返回 403(tenant:mismatch)。 -/// - 需要具备 `tenant:read` 权限。 -/// -/// 输入: -/// - Header `Authorization: Bearer `(必填) -/// - Header `X-Tenant-ID`(可选;若提供需与 Token 一致) -/// -/// 输出: -/// - `200`:租户信息 -/// -/// 异常: -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 pub async fn get_tenant_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -126,13 +93,12 @@ pub async fn get_tenant_handler( .require_permission(tenant_id, user_id, "tenant:read") .await?; let tenant = state.tenant_service.get_tenant(tenant_id).await?; - let response = TenantResponse { + Ok(AppResponse::ok(TenantResponse { id: tenant.id, name: tenant.name, status: tenant.status, config: tenant.config, - }; - Ok(AppResponse::ok(response)) + })) } #[utoipa::path( @@ -155,24 +121,6 @@ pub async fn get_tenant_handler( ) )] #[instrument(skip(state, payload))] -/// Update current tenant (name / config). -/// 更新当前租户的基础信息(名称 / 配置)。 -/// -/// 业务规则: -/// - 只允许更新当前登录租户;租户不一致返回 403。 -/// - 需要具备 `tenant:write` 权限。 -/// -/// 输入: -/// - Header `Authorization: Bearer `(必填) -/// - Body `UpdateTenantRequest`:`name` / `config` 为可选字段,未提供则保持不变 -/// -/// 输出: -/// - `200`:更新后的租户信息 -/// -/// 异常: -/// - `400`:请求参数错误 -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 pub async fn update_tenant_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -194,13 +142,12 @@ pub async fn update_tenant_handler( .tenant_service .update_tenant(tenant_id, payload) .await?; - let response = TenantResponse { + Ok(AppResponse::ok(TenantResponse { id: tenant.id, name: tenant.name, status: tenant.status, config: tenant.config, - }; - Ok(AppResponse::ok(response)) + })) } #[utoipa::path( @@ -223,24 +170,6 @@ pub async fn update_tenant_handler( ) )] #[instrument(skip(state, payload))] -/// Update current tenant status (e.g. active / disabled). -/// 更新当前租户状态(如 active / disabled)。 -/// -/// 业务规则: -/// - 只允许更新当前登录租户;租户不一致返回 403。 -/// - 需要具备 `tenant:write` 权限。 -/// -/// 输入: -/// - Header `Authorization: Bearer `(必填) -/// - Body `UpdateTenantStatusRequest.status`(必填) -/// -/// 输出: -/// - `200`:更新后的租户信息 -/// -/// 异常: -/// - `400`:请求参数错误 -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 pub async fn update_tenant_status_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -262,13 +191,12 @@ pub async fn update_tenant_status_handler( .tenant_service .update_tenant_status(tenant_id, payload) .await?; - let response = TenantResponse { + Ok(AppResponse::ok(TenantResponse { id: tenant.id, name: tenant.name, status: tenant.status, config: tenant.config, - }; - Ok(AppResponse::ok(response)) + })) } #[utoipa::path( @@ -290,20 +218,6 @@ pub async fn update_tenant_status_handler( ) )] #[instrument(skip(state))] -/// Delete current tenant. -/// 删除当前租户。 -/// -/// 业务规则: -/// - 只允许删除当前登录租户;租户不一致返回 403。 -/// - 需要具备 `tenant:write` 权限。 -/// -/// 输出: -/// - `200`:删除成功(空响应) -/// -/// 异常: -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 -/// - `404`:租户不存在 pub async fn delete_tenant_handler( TenantId(tenant_id): TenantId, State(state): State, diff --git a/src/handlers/user.rs b/src/presentation/http/handlers/user.rs similarity index 81% rename from src/handlers/user.rs rename to src/presentation/http/handlers/user.rs index a6affad..4cd638d 100644 --- a/src/handlers/user.rs +++ b/src/presentation/http/handlers/user.rs @@ -1,10 +1,10 @@ -use crate::handlers::AppState; use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; use crate::models::{ AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, ResetMyPasswordRequest, RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse, }; +use crate::presentation::http::state::AppState; use axum::{ Json, extract::{Path, Query, State}, @@ -63,25 +63,6 @@ fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> { ) )] #[instrument(skip(state))] -/// List users in tenant with pagination. -/// 分页查询当前租户下的用户列表。 -/// -/// 业务规则: -/// - 仅返回当前租户用户;租户不一致返回 403。 -/// - 需要具备 `user:read` 权限。 -/// - 分页参数约束:`page>=1`,`page_size` 范围 `1..=200`。 -/// -/// 输入: -/// - Header `Authorization: Bearer `(必填) -/// - Query `page` / `page_size`(可选) -/// -/// 输出: -/// - `200`:用户列表 -/// -/// 异常: -/// - `400`:分页参数非法 -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 pub async fn list_users_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -140,24 +121,6 @@ pub async fn list_users_handler( ) )] #[instrument(skip(state))] -/// Get user by id. -/// 根据用户 ID 查询用户详情。 -/// -/// 业务规则: -/// - 仅允许查询当前租户用户;租户不一致返回 403。 -/// - 需要具备 `user:read` 权限。 -/// -/// 输入: -/// - Path `id`:用户 UUID -/// - Header `Authorization: Bearer `(必填) -/// -/// 输出: -/// - `200`:用户信息 -/// -/// 异常: -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 -/// - `404`:用户不存在 pub async fn get_user_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -208,27 +171,6 @@ pub async fn get_user_handler( ) )] #[instrument(skip(state, payload))] -/// Update user (currently supports updating email). -/// 更新指定用户信息(目前支持更新邮箱)。 -/// -/// 业务规则: -/// - 仅允许更新当前租户用户;租户不一致返回 403。 -/// - 需要具备 `user:write` 权限。 -/// - `UpdateUserRequest` 中未提供的字段保持不变。 -/// -/// 输入: -/// - Path `id`:用户 UUID -/// - Header `Authorization: Bearer `(必填) -/// - Body `UpdateUserRequest`(必填) -/// -/// 输出: -/// - `200`:更新后的用户信息 -/// -/// 异常: -/// - `400`:请求参数错误 -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 -/// - `404`:用户不存在 pub async fn update_user_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -278,24 +220,6 @@ pub async fn update_user_handler( ) )] #[instrument(skip(state))] -/// Delete user. -/// 删除指定用户。 -/// -/// 业务规则: -/// - 仅允许删除当前租户用户;租户不一致返回 403。 -/// - 需要具备 `user:write` 权限。 -/// -/// 输入: -/// - Path `id`:用户 UUID -/// - Header `Authorization: Bearer `(必填) -/// -/// 输出: -/// - `200`:删除成功(空响应) -/// -/// 异常: -/// - `401`:未认证 -/// - `403`:租户不匹配或无权限 -/// - `404`:用户不存在 pub async fn delete_user_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -313,7 +237,6 @@ pub async fn delete_user_handler( .authorization_service .require_permission(tenant_id, user_id, "user:write") .await?; - state .user_service .delete_user(tenant_id, target_user_id) @@ -341,15 +264,6 @@ pub async fn delete_user_handler( ) )] #[instrument(skip(state))] -/// List roles bound to a user. -/// 查询用户已绑定的角色列表。 -/// -/// 业务规则: -/// - 仅允许在当前租户内查询;租户不一致返回 403。 -/// - 需要具备 `user:read` 权限。 -/// -/// 输出: -/// - `200`:角色列表(角色名称与描述) pub async fn list_user_roles_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -410,14 +324,6 @@ pub async fn list_user_roles_handler( ) )] #[instrument(skip(state, payload))] -/// Set user's roles (full replace, idempotent). -/// 设置用户的角色绑定(全量覆盖,幂等)。 -/// -/// 业务规则: -/// - 仅允许在当前租户内操作;租户不一致返回 403。 -/// - 需要具备 `user:write` 权限。 -/// - `role_ids` 必须全部属于当前租户,否则返回 400。 -/// - 该接口为“全量覆盖”:会先清空用户在当前租户下的角色绑定,再按 `role_ids` 重新写入。 pub async fn set_user_roles_handler( TenantId(tenant_id): TenantId, State(state): State, @@ -453,8 +359,6 @@ pub async fn set_user_roles_handler( Ok(AppResponse::ok(response)) } -/// Reset my password (requires current password). -/// 重置自己的密码(需要提供旧密码)。 #[utoipa::path( post, path = "/users/me/password/reset", @@ -500,8 +404,6 @@ pub async fn reset_my_password_handler( Ok(AppResponse::ok(serde_json::json!({}))) } -/// Reset a user's password as tenant admin (generates temporary password). -/// 租户管理员重置任意用户密码(生成临时密码)。 #[utoipa::path( post, path = "/users/{id}/password/reset", diff --git a/src/presentation/http/mod.rs b/src/presentation/http/mod.rs new file mode 100644 index 0000000..89f6afc --- /dev/null +++ b/src/presentation/http/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod handlers; +pub mod state; diff --git a/src/presentation/http/state.rs b/src/presentation/http/state.rs new file mode 100644 index 0000000..7e84400 --- /dev/null +++ b/src/presentation/http/state.rs @@ -0,0 +1,23 @@ +use crate::application::services::{ + AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService, + TenantService, UserService, +}; +use crate::domain::repositories::tenant_config_repo::TenantConfigRepo; +use redis::aio::ConnectionManager; +use std::sync::Arc; + +#[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, + pub tenant_config_repo: Arc, +} + diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs new file mode 100644 index 0000000..3883215 --- /dev/null +++ b/src/presentation/mod.rs @@ -0,0 +1 @@ +pub mod http; diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index 808627a..b4a191e 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -1,6 +1,6 @@ use crate::utils::keys::get_keys; use common_telemetry::AppError; -use jsonwebtoken::{Algorithm, Header, Validation, decode, encode}; +use jsonwebtoken::{Algorithm, Header, encode}; use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; @@ -74,14 +74,3 @@ pub fn sign_with_ttl( 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 { - let keys = get_keys(); - let mut validation = Validation::new(Algorithm::RS256); - validation.set_issuer(&["iam-service"]); - - let token_data = decode::(token, &keys.decoding_key, &validation) - .map_err(|e| AppError::AuthError(e.to_string()))?; - - Ok(token_data.claims) -} diff --git a/src/utils/keys.rs b/src/utils/keys.rs index ab8e95b..683ecb9 100644 --- a/src/utils/keys.rs +++ b/src/utils/keys.rs @@ -8,7 +8,6 @@ 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, @@ -49,15 +48,12 @@ pub fn get_keys() -> &'static KeyPair { 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, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9546c8b..3ccf88d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,5 +3,5 @@ pub mod jwt; pub mod keys; pub mod password; -pub use jwt::{sign, sign_with_ttl, verify}; +pub use jwt::{sign, sign_with_ttl}; pub use password::{hash_password, verify_password}; diff --git a/tests/app_lifecycle_smoke.rs b/tests/app_lifecycle_smoke.rs index c5efcb3..8d43e2b 100644 --- a/tests/app_lifecycle_smoke.rs +++ b/tests/app_lifecycle_smoke.rs @@ -1,5 +1,5 @@ use iam_service::models::{CreateAppRequest, ListAppsQuery, RequestAppStatusChangeRequest, UpdateAppRequest}; -use iam_service::services::AppService; +use iam_service::application::services::AppService; use sqlx::PgPool; use uuid::Uuid; @@ -90,4 +90,3 @@ async fn app_lifecycle_create_update_disable_approve_delete() Ok(()) } - diff --git a/tests/code2token_modes.rs b/tests/code2token_modes.rs new file mode 100644 index 0000000..e714b61 --- /dev/null +++ b/tests/code2token_modes.rs @@ -0,0 +1,180 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use iam_service::presentation::http::api; +use iam_service::presentation::http::state::AppState; +use iam_service::infrastructure::repositories::tenant_config_repo::TenantConfigRepoPg; +use iam_service::models::CreateTenantRequest; +use iam_service::models::CreateUserRequest; +use iam_service::application::services::{ + AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService, + TenantService, UserService, +}; +use redis::aio::ConnectionManager; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +#[tokio::test] +async fn code2token_modes_requirements() -> Result<(), Box> { + let database_url = match std::env::var("DATABASE_URL") { + Ok(v) if !v.trim().is_empty() => v, + _ => return Ok(()), + }; + let redis_url = match std::env::var("REDIS_URL") { + Ok(v) if !v.trim().is_empty() => v, + _ => return Ok(()), + }; + let auth_code_jwt_secret = match std::env::var("AUTH_CODE_JWT_SECRET") { + Ok(v) if !v.trim().is_empty() => v, + _ => return Ok(()), + }; + let internal_psk = match std::env::var("INTERNAL_EXCHANGE_PSK") { + Ok(v) if !v.trim().is_empty() => v, + _ => return Ok(()), + }; + + let pool = PgPool::connect(&database_url).await?; + + let redis = redis::Client::open(redis_url)?; + let redis: ConnectionManager = ConnectionManager::new(redis).await?; + + let auth_service = AuthService::new(pool.clone(), "test".to_string()); + let client_service = ClientService::new(pool.clone(), 30); + 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 tenant_config_repo = std::sync::Arc::new(TenantConfigRepoPg::new(pool.clone())); + + let state = AppState { + auth_service: auth_service.clone(), + client_service: client_service.clone(), + user_service, + role_service, + tenant_service: tenant_service.clone(), + authorization_service, + app_service, + permission_service, + redis, + auth_code_jwt_secret: auth_code_jwt_secret.clone(), + tenant_config_repo, + }; + + let tenant = tenant_service + .create_tenant(CreateTenantRequest { + name: format!("t-{}", Uuid::new_v4()), + config: None, + }) + .await?; + + let email = format!("u{}@example.com", Uuid::new_v4()); + let password = "P@ssw0rd123!".to_string(); + let _user = auth_service + .register( + tenant.id, + CreateUserRequest { + email: email.clone(), + password: password.clone(), + }, + ) + .await?; + + let client_id = format!("cms{}", Uuid::new_v4().to_string().replace('-', "")); + let client_id = &client_id[..std::cmp::min(24, client_id.len())]; + let redirect_uri = "http://localhost:5031/auth/callback".to_string(); + let client_secret = client_service + .create_client( + client_id.to_string(), + Some("CMS".to_string()), + Some(vec![redirect_uri.clone()]), + ) + .await?; + + let app = api::build_app(state); + + let login_code_req = serde_json::json!({ + "clientId": client_id, + "redirectUri": redirect_uri, + "email": email, + "password": password + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/login-code") + .header("Content-Type", "application/json") + .header("X-Tenant-ID", tenant.id.to_string()) + .body(Body::from(login_code_req.to_string()))?, + ) + .await?; + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await?; + let v: serde_json::Value = serde_json::from_slice(&body)?; + let redirect_to = v["data"]["redirectTo"].as_str().unwrap_or_default(); + let url = url::Url::parse(redirect_to)?; + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .unwrap_or_default(); + assert!(!code.is_empty()); + + let code2token_req = serde_json::json!({ + "code": code, + "clientId": client_id, + "clientSecret": "wrong" + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/code2token") + .header("Content-Type", "application/json") + .header("X-Tenant-ID", tenant.id.to_string()) + .body(Body::from(code2token_req.to_string()))?, + ) + .await?; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let code2token_req_missing_tenant = serde_json::json!({ + "code": url.query_pairs().find(|(k, _)| k == "code").unwrap().1, + "clientId": client_id, + "clientSecret": client_secret + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/code2token") + .header("Content-Type", "application/json") + .body(Body::from(code2token_req_missing_tenant.to_string()))?, + ) + .await?; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let code2token_req_internal = serde_json::json!({ + "code": url.query_pairs().find(|(k, _)| k == "code").unwrap().1, + "clientId": client_id, + "clientSecret": client_secret + }); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/internal/auth/code2token") + .header("Content-Type", "application/json") + .header("X-Internal-Token", internal_psk) + .body(Body::from(code2token_req_internal.to_string()))?, + ) + .await?; + assert_eq!(resp.status(), StatusCode::OK); + + Ok(()) +} diff --git a/tests/enabled_apps_smoke.rs b/tests/enabled_apps_smoke.rs index ac8daf9..2e0a4dc 100644 --- a/tests/enabled_apps_smoke.rs +++ b/tests/enabled_apps_smoke.rs @@ -1,4 +1,4 @@ -use iam_service::services::TenantService; +use iam_service::application::services::TenantService; use sqlx::PgPool; use uuid::Uuid; diff --git a/tests/jwks_e2e.rs b/tests/jwks_e2e.rs index 78d66a2..25778b6 100644 --- a/tests/jwks_e2e.rs +++ b/tests/jwks_e2e.rs @@ -5,7 +5,7 @@ use uuid::Uuid; 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), + get(iam_service::presentation::http::handlers::jwks::jwks_handler), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tests/password_reset_smoke.rs b/tests/password_reset_smoke.rs index 780b5c0..fe5862b 100644 --- a/tests/password_reset_smoke.rs +++ b/tests/password_reset_smoke.rs @@ -1,11 +1,10 @@ +use iam_service::application::services::{AuthService, TenantService, UserService}; use iam_service::models::{CreateUserRequest, LoginRequest}; -use iam_service::services::{AuthService, TenantService, UserService}; use sqlx::PgPool; use uuid::Uuid; #[tokio::test] -async fn password_reset_self_and_admin_flow() --> Result<(), Box> { +async fn password_reset_self_and_admin_flow() -> Result<(), Box> { let database_url = match std::env::var("DATABASE_URL") { Ok(v) if !v.trim().is_empty() => v, _ => return Ok(()), @@ -63,11 +62,12 @@ async fn password_reset_self_and_admin_flow() }, ) .await?; - let active_tokens: i64 = - sqlx::query_scalar("SELECT COUNT(1) FROM refresh_tokens WHERE user_id = $1 AND is_revoked = FALSE") - .bind(user.id) - .fetch_one(&pool) - .await?; + let active_tokens: i64 = sqlx::query_scalar( + "SELECT COUNT(1) FROM refresh_tokens WHERE user_id = $1 AND is_revoked = FALSE", + ) + .bind(user.id) + .fetch_one(&pool) + .await?; assert!(active_tokens >= 1); user_service @@ -79,11 +79,12 @@ async fn password_reset_self_and_admin_flow() ) .await?; - let revoked_tokens: i64 = - sqlx::query_scalar("SELECT COUNT(1) FROM refresh_tokens WHERE user_id = $1 AND is_revoked = TRUE") - .bind(user.id) - .fetch_one(&pool) - .await?; + let revoked_tokens: i64 = sqlx::query_scalar( + "SELECT COUNT(1) FROM refresh_tokens WHERE user_id = $1 AND is_revoked = TRUE", + ) + .bind(user.id) + .fetch_one(&pool) + .await?; assert!(revoked_tokens >= 1); let old_login = auth_service @@ -136,4 +137,3 @@ async fn password_reset_self_and_admin_flow() let _ = login1; Ok(()) } - diff --git a/tests/refresh_token_smoke.rs b/tests/refresh_token_smoke.rs index 0b15f7e..a2cfc39 100644 --- a/tests/refresh_token_smoke.rs +++ b/tests/refresh_token_smoke.rs @@ -1,6 +1,6 @@ use hmac::{Hmac, Mac}; +use iam_service::application::services::{AuthService, TenantService}; use iam_service::models::{CreateTenantRequest, CreateUserRequest, LoginRequest}; -use iam_service::services::{AuthService, TenantService}; use sha2::Sha256; use sqlx::PgPool; use uuid::Uuid; @@ -12,8 +12,7 @@ fn fingerprint(pepper: &str, token: &str) -> String { } #[tokio::test] -async fn refresh_token_rotate_and_expire_cases() --> Result<(), Box> { +async fn refresh_token_rotate_and_expire_cases() -> Result<(), Box> { let database_url = match std::env::var("DATABASE_URL") { Ok(v) if !v.trim().is_empty() => v, _ => return Ok(()), @@ -104,4 +103,3 @@ async fn refresh_token_rotate_and_expire_cases() Ok(()) } - diff --git a/tests/role_permission_smoke.rs b/tests/role_permission_smoke.rs index acd82de..e6f9875 100644 --- a/tests/role_permission_smoke.rs +++ b/tests/role_permission_smoke.rs @@ -1,11 +1,12 @@ +use iam_service::application::services::{ + AuthService, AuthorizationService, RoleService, TenantService, +}; use iam_service::models::{CreateRoleRequest, CreateTenantRequest, CreateUserRequest}; -use iam_service::services::{AuthService, AuthorizationService, RoleService, TenantService}; use sqlx::PgPool; use uuid::Uuid; #[tokio::test] -async fn role_permission_grant_and_wildcard_match() --> Result<(), Box> { +async fn role_permission_grant_and_wildcard_match() -> Result<(), Box> { let database_url = match std::env::var("DATABASE_URL") { Ok(v) if !v.trim().is_empty() => v, _ => return Ok(()), @@ -71,12 +72,7 @@ async fn role_permission_grant_and_wildcard_match() .await?; role_service - .grant_permissions_to_role( - tenant.id, - role.id, - vec!["cms:*:*".to_string()], - admin.id, - ) + .grant_permissions_to_role(tenant.id, role.id, vec!["cms:*:*".to_string()], admin.id) .await?; role_service @@ -89,4 +85,3 @@ async fn role_permission_grant_and_wildcard_match() Ok(()) } - diff --git a/tests/user_roles_smoke.rs b/tests/user_roles_smoke.rs index 16e395a..f2e7bb5 100644 --- a/tests/user_roles_smoke.rs +++ b/tests/user_roles_smoke.rs @@ -1,5 +1,5 @@ use iam_service::models::CreateRoleRequest; -use iam_service::services::{RoleService, TenantService}; +use iam_service::application::services::{RoleService, TenantService}; use sqlx::PgPool; use uuid::Uuid;