perf(struct): ddd

This commit is contained in:
2026-02-03 17:31:08 +08:00
parent 202b5eaad5
commit 4a071bd7c8
64 changed files with 1214 additions and 1189 deletions

2
src/application/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod services;
pub mod use_cases;

View File

@@ -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()
})?;

View File

@@ -16,15 +16,10 @@ pub struct AuthService {
}
impl AuthService {
/// 创建认证服务实例。
///
/// 说明:
/// - Access Token 使用 RS256 密钥对进行签发与校验不使用对称密钥HS256
/// - 但仍需要一个服务端 Secret 作为 Refresh Token 指纹HMACpepper因此保留 `_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<User, AppError> {
pub async fn register(&self, tenant_id: Uuid, req: CreateUserRequest) -> Result<User, AppError> {
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<LoginResponse, AppError> {
pub async fn login(&self, tenant_id: Uuid, req: LoginRequest) -> Result<LoginResponse, AppError> {
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 {
})
}
}

View File

@@ -13,6 +13,8 @@ pub struct ClientService {
prev_ttl_days: u32,
}
pub type ClientListItem = (String, Option<String>, Vec<String>, 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, Option<String>, Vec<String>, String, String)>, AppError> {
pub async fn list_clients(&self) -> Result<Vec<ClientListItem>, AppError> {
let rows = sqlx::query_as::<
_,
(
@@ -363,3 +376,4 @@ impl ClientService {
.collect())
}
}

View File

@@ -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;

View File

@@ -14,7 +14,10 @@ impl PermissionService {
}
#[instrument(skip(self))]
pub async fn list_permissions(&self, query: ListPermissionsQuery) -> Result<Vec<Permission>, AppError> {
pub async fn list_permissions(
&self,
query: ListPermissionsQuery,
) -> Result<Vec<Permission>, 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 {

View File

@@ -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))]

View File

@@ -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<Tenant, AppError> {
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<Tenant, AppError> {
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<Tenant, AppError> {
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<Tenant, AppError> {
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<String>, i32, chrono::DateTime<chrono::Utc>)>(
@@ -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<String>, 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<chrono::Utc>) = sqlx::query_as(
@@ -397,3 +366,4 @@ fn normalize_apps(enabled_apps: Vec<String>) -> Vec<String> {
out.sort();
out
}

View File

@@ -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)

View File

@@ -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<ExchangeCodeResult, DomainError>;
}
#[derive(serde::Deserialize)]
struct AuthCodeClaims {
sub: String,
tenant_id: String,
client_id: Option<String>,
#[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<String>,
}
fn redis_key(jti: &str) -> String {
format!("iam:auth_code:{}", jti)
}
#[async_trait]
impl Execute for ExchangeCodeUseCase {
async fn execute(&self, req: Code2TokenRequest) -> Result<ExchangeCodeResult, DomainError> {
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::<AuthCodeClaims>(
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<String> = 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,
})
}
}

View File

@@ -0,0 +1,2 @@
pub mod exchange_code;

2
src/constants.rs Normal file
View File

@@ -0,0 +1,2 @@
pub const CANONICAL_BASE: &str = "/api/v1";

View File

@@ -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,

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

13
src/domain/mod.rs Normal file
View File

@@ -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,
}

View File

@@ -0,0 +1,2 @@
pub mod tenant_config_repo;

View File

@@ -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<bool, DomainError>;
}

View File

@@ -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,
}

View File

@@ -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 <access_token>(访问令牌)"),
("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<AppState>`:应用状态,包含 `PermissionService`/`AuthorizationService`。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
/// - `Query(query): Query<ListPermissionsQuery>`:查询参数(分页/搜索/筛选/排序)。
/// - `page: Option<u32>`:页码(默认 1
/// - `page_size: Option<u32>`:每页条数(默认 20范围 1..=200
/// - `search: Option<String>`:按 `code` 或 `description` 模糊搜索。
/// - `app_code/resource/action`:精确筛选(用于权限目录分组)。
///
/// ## Returns
/// - `Ok(AppResponse<Vec<Permission>>)`:成功返回 `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 <access_token>
/// // X-Tenant-ID: <tenant_uuid> // 可选,但若提供必须与 token 中 tenant_id 一致
/// ```
pub async fn list_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Query(query): Query<ListPermissionsQuery>,
) -> Result<AppResponse<Vec<Permission>>, 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))
}

View File

@@ -0,0 +1,2 @@
pub mod repositories;

View File

@@ -0,0 +1,2 @@
pub mod tenant_config_repo;

View File

@@ -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<bool, DomainError> {
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<String>, Option<chrono::DateTime<chrono::Utc>>)> =
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)
}
}

View File

@@ -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;

View File

@@ -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));

View File

@@ -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<Uuid> {
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<Uuid> {
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<Response, AppError> {
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)
}

View File

@@ -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,
};

View File

@@ -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<IpNet>,
}

View File

@@ -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)
}

View File

@@ -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::<u32>().ok());
let page_size = params
.get("page_size")
.and_then(|v| v.parse::<u32>().ok());
let page_size = params.get("page_size").and_then(|v| v.parse::<u32>().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!({})))
}

View File

@@ -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<AppState>,
// 3. 获取 Body
Json(payload): Json<CreateUserRequest>,
) -> Result<AppResponse<UserResponse>, 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<LoginRequest>,
) -> Result<AppResponse<LoginResponse>, 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",

View File

@@ -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 <access_token>`(必填)
/// - 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<AppState>,

View File

@@ -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",

View File

@@ -91,3 +91,4 @@ mod tests {
assert!(jwks.keys.len() >= 2);
}
}

View File

@@ -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;

View File

@@ -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 <access_token>(访问令牌)"),
("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<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Query(query): Query<ListPermissionsQuery>,
) -> Result<AppResponse<Vec<Permission>>, 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))
}

View File

@@ -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,
}))
}

View File

@@ -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<AppState>`:应用状态,包含 `RoleService`/`AuthorizationService` 等依赖。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`
/// 从 `Authorization: Bearer <access_token>` 解析得到的认证上下文,包含调用方用户 ID 与租户 ID。
/// - `Json(payload): Json<CreateRoleRequest>`:创建角色的请求体(角色名与描述)。
///
/// ## Returns
/// - `Ok(AppResponse<RoleResponse>)`:创建成功返回 `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 <access_token>
/// // X-Tenant-ID: <tenant_uuid> // 可选,但若提供必须与 token 中 tenant_id 一致
/// // Body:
/// // { "name": "ContentAdmin", "description": "CMS content admins" }
/// ```
pub async fn create_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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<AppState)`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
///
/// ## Returns
/// - `Ok(AppResponse<Vec<RoleResponse>>)`:成功返回 `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<AppState>,
@@ -175,24 +128,6 @@ pub async fn list_roles_handler(
)
)]
#[instrument(skip(state))]
/// 查询角色详情Role
///
/// 仅允许读取当前租户内的角色,避免跨租户信息泄露。
///
/// ## Parameters
/// - `TenantId(tenant_id): TenantId``Uuid`):当前租户 ID。
/// - `State(state): State<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
///
/// ## Returns
/// - `Ok(AppResponse<RoleResponse>)`:成功返回 `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<AppState>,
@@ -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<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
/// - `Json(payload): Json<UpdateRoleRequest>`:可选更新字段(`name`/`description`)。
///
/// ## Returns
/// - `Ok(AppResponse<RoleResponse>)`:成功返回 `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<AppState>,
@@ -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<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
///
/// ## Returns
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `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<AppState>,
@@ -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<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
/// - `Json(payload): Json<RolePermissionsRequest>`:待授予的权限码数组(`Vec<String>`)。
///
/// ## Returns
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `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<AppState>,
@@ -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<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
/// - `Json(payload): Json<RolePermissionsRequest>`:待回收的权限码数组(`Vec<String>`)。
///
/// ## Returns
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `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<AppState>,
@@ -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<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文(`user_id` 为操作者)。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
/// - `Json(payload): Json<RoleUsersRequest>`:待授予用户 ID 列表(`Vec<Uuid>`)。
///
/// ## Returns
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `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": ["<user_uuid_1>", "<user_uuid_2>"] }
/// ```
pub async fn grant_role_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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<AppState>`:应用状态。
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文(`user_id` 为操作者)。
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
/// - `Json(payload): Json<RoleUsersRequest>`:待回收用户 ID 列表(`Vec<Uuid>`)。
///
/// ## Returns
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `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<AppState>,

View File

@@ -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<String>,
redirect_uri: Option<String>,
}
fn redis_key(jti: &str) -> String {
format!("iam:auth_code:{}", jti)
}
/// Exchange one-time authorization code to access/refresh token.
/// 授权码换取 token一次性 code5 分钟有效,单次使用)。
#[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<AppState>,
Json(payload): Json<Code2TokenRequest>,
) -> Result<AppResponse<Code2TokenResponse>, 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<String>, Header, description = "Optional tenant UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn internal_code2token_handler(
headers: axum::http::HeaderMap,
State(state): State<AppState>,
Json(payload): Json<Code2TokenRequest>,
) -> Result<AppResponse<Code2TokenResponse>, 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::<AuthCodeClaims>(
payload.code.trim(),
&DecodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
&validation,
)
.map_err(|e| AppError::AuthError(e.to_string()))?;
let claims = token_data.claims;
if let Some(cid) = &claims.client_id {
if cid != payload.client_id.trim() {
return Err(AppError::AuthError("Invalid code".into()));
}
}
let jti = claims.jti.trim();
if jti.is_empty() {
return Err(AppError::AuthError("Invalid code".into()));
}
let script = Script::new(
r#"
local v = redis.call('GET', KEYS[1])
if v then
redis.call('DEL', KEYS[1])
end
return v
"#,
);
let key = redis_key(jti);
let mut conn = state.redis.clone();
let val: Option<String> = script
.key(key)
.invoke_async(&mut conn)
.await
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let Some(val) = val else {
return Err(AppError::AuthError("Invalid or used code".into()));
let 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",

View File

@@ -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<AppState>,
@@ -65,13 +50,12 @@ pub async fn create_tenant_handler(
) -> Result<AppResponse<TenantResponse>, 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 中租户不一致,返回 403tenant:mismatch
/// - 需要具备 `tenant:read` 权限。
///
/// 输入:
/// - Header `Authorization: Bearer <access_token>`(必填)
/// - Header `X-Tenant-ID`(可选;若提供需与 Token 一致)
///
/// 输出:
/// - `200`:租户信息
///
/// 异常:
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
pub async fn get_tenant_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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 <access_token>`(必填)
/// - Body `UpdateTenantRequest``name` / `config` 为可选字段,未提供则保持不变
///
/// 输出:
/// - `200`:更新后的租户信息
///
/// 异常:
/// - `400`:请求参数错误
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
pub async fn update_tenant_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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 <access_token>`(必填)
/// - Body `UpdateTenantStatusRequest.status`(必填)
///
/// 输出:
/// - `200`:更新后的租户信息
///
/// 异常:
/// - `400`:请求参数错误
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
pub async fn update_tenant_status_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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<AppState>,

View File

@@ -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 <access_token>`(必填)
/// - Query `page` / `page_size`(可选)
///
/// 输出:
/// - `200`:用户列表
///
/// 异常:
/// - `400`:分页参数非法
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
pub async fn list_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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 <access_token>`(必填)
///
/// 输出:
/// - `200`:用户信息
///
/// 异常:
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
/// - `404`:用户不存在
pub async fn get_user_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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 <access_token>`(必填)
/// - Body `UpdateUserRequest`(必填)
///
/// 输出:
/// - `200`:更新后的用户信息
///
/// 异常:
/// - `400`:请求参数错误
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
/// - `404`:用户不存在
pub async fn update_user_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -278,24 +220,6 @@ pub async fn update_user_handler(
)
)]
#[instrument(skip(state))]
/// Delete user.
/// 删除指定用户。
///
/// 业务规则:
/// - 仅允许删除当前租户用户;租户不一致返回 403。
/// - 需要具备 `user:write` 权限。
///
/// 输入:
/// - Path `id`:用户 UUID
/// - Header `Authorization: Bearer <access_token>`(必填)
///
/// 输出:
/// - `200`:删除成功(空响应)
///
/// 异常:
/// - `401`:未认证
/// - `403`:租户不匹配或无权限
/// - `404`:用户不存在
pub async fn delete_user_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
@@ -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<AppState>,
@@ -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<AppState>,
@@ -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",

View File

@@ -0,0 +1,3 @@
pub mod api;
pub mod handlers;
pub mod state;

View File

@@ -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<dyn TenantConfigRepo>,
}

1
src/presentation/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod http;

View File

@@ -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<Claims, AppError> {
let keys = get_keys();
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&["iam-service"]);
let token_data = decode::<Claims>(token, &keys.decoding_key, &validation)
.map_err(|e| AppError::AuthError(e.to_string()))?;
Ok(token_data.claims)
}

View File

@@ -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,

View File

@@ -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};