fix(auth): check handle

This commit is contained in:
2026-02-11 10:55:26 +08:00
parent e29926a62b
commit ba6e39d60a
9 changed files with 239 additions and 24 deletions

34
Cargo.lock generated
View File

@@ -90,6 +90,7 @@ name = "auth-kit"
version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
"base64",
"common-telemetry",
"dashmap",
@@ -183,6 +184,28 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backon"
version = "1.6.0"
@@ -390,6 +413,17 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"

View File

@@ -37,8 +37,8 @@
"code": 0,
"message": "Success",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwOTc2NzYsImlhdCI6MTc3MDA5Njc3NiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.Tt7fEQj8wtS5XzJ-GuwRF5yiOrtGaRr9P_V5RqNZagLXj0eRikf9U4oNkFS2uy8Pp75Ks4jL816DzeLXQsXZJfWEtvlTp0QmwyOhYIN1p-yyGS9Pitl0gb7wobjStGDyMSD-ctbHgEsU41qLrQd6ZQkpFl2IpaCSfCN0JZCpc7_3BeI6YLwAw_K4-TFF_1OTNRPm4sT3RnEZYOHXm6EcOUk-MsDBy1itADCdEEUdCSoslK6FGHIpbhkgA56Z7Qy5909BxXW34I21c2rZX-R_iB9q_eKzWd0GqBMIZi33ITbRy4F7_CtCQSwFNUt6-lvVvzXYLHsVQchcpOdYtj3h6w",
"refresh_token": "7483624e8942aee112e62b2b58ec902fb01731dd7b098b4af6001e350b2303f0",
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImRlZmF1bHQifQ.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzA3NzU0NDYsImlhdCI6MTc3MDc3NDU0NiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.yPCz0CgX4qc1AqFM_yi-IIthVRBzYykFAIdJQmB0O6EEWSMutG3WtW2L9lvvBEajmJuje-PIxj-UwpS6tInzUR0H7BKdqOwmao9CKJkaOjp6aSfOZUxQ90kaR1PnJtjmkI8r6rorvWtTyoLIMzwbHisXjMoYxZ5-JTrOmXdYs_MT50cn-tzb7eV_eM7g77J82-vxTVQq0olKA7rLpOFw2B4hHOoNjniL8Tas8zupHakVZzW7G1Q25tveqJtck9rDfEm2KpswKC_Kh4-kk1E5M1rESbpzM0h9VENTd2EPgxvOcIjPtczkMp89qlrovQqZteCCyDDSWnfGPWGsVrm7tQ",
"refresh_token": "56923a401cf9bbf08f988dfff05b28aabbff0814a02f7760b5fbf34f56df8ed8",
"token_type": "Bearer",
"expires_in": 900
}
@@ -46,7 +46,7 @@
```
`user1@example.com`
`user1secret`
`user5secret`
```json
{

View File

@@ -4,6 +4,8 @@ use sqlx::PgPool;
use tracing::instrument;
use uuid::Uuid;
use crate::models::{PermissionExpr, PermissionExprItem};
#[derive(Clone)]
pub struct AuthorizationService {
pool: PgPool,
@@ -88,6 +90,17 @@ impl AuthorizationService {
}
}
pub async fn check_permission_expr(
&self,
tenant_id: Uuid,
user_id: Uuid,
expr: &PermissionExpr,
) -> Result<bool, AppError> {
validate_expr_depth(expr, 32)?;
let permissions = self.list_permissions_for_user(tenant_id, user_id).await?;
Ok(evaluate_expr(&permissions, expr))
}
#[instrument(skip(self))]
pub async fn list_platform_permissions_for_user(
&self,
@@ -122,3 +135,45 @@ impl AuthorizationService {
}
}
}
fn validate_expr_depth(expr: &PermissionExpr, max_depth: usize) -> Result<(), AppError> {
fn walk(expr: &PermissionExpr, depth: usize, max_depth: usize) -> Result<(), AppError> {
if depth > max_depth {
return Err(AppError::BadRequest("permission expr too deep".into()));
}
let items: &[PermissionExprItem] = match expr {
PermissionExpr::Any(x) => &x.any,
PermissionExpr::All(x) => &x.all,
};
for item in items {
if let PermissionExprItem::Expr(e) = item {
walk(e, depth + 1, max_depth)?;
}
}
Ok(())
}
walk(expr, 1, max_depth)
}
fn evaluate_expr(user_permissions: &[String], expr: &PermissionExpr) -> bool {
let items: &[PermissionExprItem] = match expr {
PermissionExpr::Any(x) => &x.any,
PermissionExpr::All(x) => &x.all,
};
match expr {
PermissionExpr::Any(_) => items.iter().any(|item| match item {
PermissionExprItem::Permission(required) => user_permissions
.iter()
.any(|granted| matches_permission(granted.as_str(), required.as_str())),
PermissionExprItem::Expr(e) => evaluate_expr(user_permissions, e),
}),
PermissionExpr::All(_) => items.iter().all(|item| match item {
PermissionExprItem::Permission(required) => user_permissions
.iter()
.any(|granted| matches_permission(granted.as_str(), required.as_str())),
PermissionExprItem::Expr(e) => evaluate_expr(user_permissions, e),
}),
}
}

View File

@@ -252,6 +252,13 @@ impl ClientService {
let requested_path = requested.path().to_string();
let requested_scheme = requested.scheme().to_string();
if arr.is_empty()
&& cfg!(debug_assertions)
&& (requested_host == "localhost" || requested_host == "127.0.0.1")
{
return Ok(normalized);
}
let allowed = arr.iter().filter_map(|x| x.as_str()).any(|raw_allowed| {
let allowed_norm = match self.normalize_redirect_uri(raw_allowed) {
Ok(v) => v,
@@ -265,7 +272,14 @@ impl ClientService {
let host = allowed_url.host_str().unwrap_or_default();
let port = allowed_url.port_or_known_default().unwrap_or(0);
if allowed_url.scheme() != requested_scheme
let scheme_ok = allowed_url.scheme() == requested_scheme
|| (cfg!(debug_assertions)
&& (requested_host == "localhost" || requested_host == "127.0.0.1")
&& host == requested_host
&& ((allowed_url.scheme() == "https" && requested_scheme == "http")
|| (allowed_url.scheme() == "http" && requested_scheme == "https")));
if !scheme_ok
|| host != requested_host
|| port != requested_port
|| allowed_url.path() != requested_path
@@ -376,4 +390,3 @@ impl ClientService {
.collect())
}
}

View File

@@ -361,7 +361,6 @@ pub struct RefreshTokenRequest {
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct LoginCodeRequest {
#[schema(default = "", example = "user@example.com")]
#[serde(default)]
@@ -371,17 +370,18 @@ pub struct LoginCodeRequest {
pub password: String,
#[schema(default = "", example = "cms")]
#[serde(default)]
#[serde(alias = "clientId")]
pub client_id: String,
#[schema(
default = "",
example = "https://cms-api.example.com/auth/callback?next=https%3A%2F%2Fcms.example.com%2F"
)]
#[serde(default)]
#[serde(alias = "redirectUri")]
pub redirect_uri: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct LoginCodeResponse {
#[schema(
default = "",
@@ -395,21 +395,21 @@ pub struct LoginCodeResponse {
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct Code2TokenRequest {
#[schema(default = "", example = "one_time_code_jwt")]
#[serde(default)]
pub code: String,
#[schema(default = "", example = "cms")]
#[serde(default)]
#[serde(alias = "clientId")]
pub client_id: String,
#[schema(default = "", example = "client_secret")]
#[serde(default)]
#[serde(alias = "clientSecret")]
pub client_secret: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct Code2TokenResponse {
#[schema(default = "", example = "access_token")]
#[serde(default)]
@@ -432,20 +432,20 @@ pub struct Code2TokenResponse {
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct CreateClientRequest {
#[schema(default = "", example = "cms")]
#[serde(default)]
#[serde(alias = "clientId")]
pub client_id: String,
#[serde(default)]
pub name: Option<String>,
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
#[serde(default)]
#[serde(alias = "redirectUris")]
pub redirect_uris: Option<Vec<String>>,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct CreateClientResponse {
#[schema(default = "", example = "cms")]
#[serde(default)]
@@ -456,7 +456,6 @@ pub struct CreateClientResponse {
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct RotateClientSecretResponse {
#[schema(default = "", example = "cms")]
#[serde(default)]
@@ -467,7 +466,6 @@ pub struct RotateClientSecretResponse {
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ClientSummary {
#[schema(default = "", example = "cms")]
#[serde(default)]
@@ -476,6 +474,7 @@ pub struct ClientSummary {
pub name: Option<String>,
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
#[serde(default)]
#[serde(alias = "redirectUris")]
pub redirect_uris: Vec<String>,
#[schema(default = "", example = "2026-02-02T12:00:00Z")]
#[serde(default)]
@@ -486,10 +485,10 @@ pub struct ClientSummary {
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientRedirectUrisRequest {
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
#[serde(default)]
#[serde(alias = "redirectUris")]
pub redirect_uris: Vec<String>,
}
@@ -552,3 +551,39 @@ pub struct AuthorizationCheckResponse {
#[serde(default)]
pub allowed: bool,
}
#[derive(Debug, Deserialize, Serialize, ToSchema, IntoParams)]
pub struct AuthorizationExprCheckRequest {
pub expr: PermissionExpr,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
pub struct AuthorizationExprCheckResponse {
pub allowed: bool,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct AnyExpr {
pub any: Vec<PermissionExprItem>,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct AllExpr {
pub all: Vec<PermissionExprItem>,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(untagged)]
pub enum PermissionExpr {
Any(AnyExpr),
All(AllExpr),
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(untagged)]
pub enum PermissionExprItem {
Permission(String),
Expr(Box<PermissionExpr>),
}

View File

@@ -26,6 +26,7 @@ pub fn routes() -> Vec<(&'static str, &'static str)> {
("POST", "/internal/auth/code2token"),
("GET", "/me/permissions"),
("POST", "/authorize/check"),
("POST", "/authorize/check-expr"),
("GET", "/users"),
("GET", "/users/{id}"),
("PATCH", "/users/{id}"),
@@ -144,6 +145,10 @@ pub fn build_app(state: AppState) -> Router {
"/authorize/check",
post(authorization::authorization_check_handler),
)
.route(
"/authorize/check-expr",
post(authorization::authorization_check_expr_handler),
)
.route("/users", get(user::list_users_handler))
.route(
"/users/me/password/reset",

View File

@@ -1,6 +1,9 @@
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse};
use crate::models::{
AuthorizationCheckRequest, AuthorizationCheckResponse, AuthorizationExprCheckRequest,
AuthorizationExprCheckResponse,
};
use crate::presentation::http::state::AppState;
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
@@ -89,3 +92,45 @@ pub async fn authorization_check_handler(
Ok(AppResponse::ok(AuthorizationCheckResponse { allowed }))
}
#[utoipa::path(
post,
path = "/authorize/check-expr",
tag = "Policy",
security(
("bearer_auth" = [])
),
request_body = AuthorizationExprCheckRequest,
responses(
(status = 200, description = "鉴权校验结果", body = AuthorizationExprCheckResponse),
(status = 400, description = "表达式不合法"),
(status = 401, description = "未认证"),
(status = 403, description = "租户不匹配")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state, body))]
pub async fn authorization_check_expr_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(body): Json<AuthorizationExprCheckRequest>,
) -> Result<AppResponse<AuthorizationExprCheckResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
let allowed = state
.authorization_service
.check_permission_expr(tenant_id, user_id, &body.expr)
.await?;
Ok(AppResponse::ok(AuthorizationExprCheckResponse { allowed }))
}

View File

@@ -0,0 +1,27 @@
use iam_service::models::{AuthorizationExprCheckRequest, PermissionExpr};
#[test]
fn deserialize_any_expr() {
let json = r#"{ "expr": { "any": ["cms:article:edit", "cms:article:create"] } }"#;
let parsed: AuthorizationExprCheckRequest = serde_json::from_str(json).unwrap();
match parsed.expr {
PermissionExpr::Any(x) => {
assert_eq!(x.any.len(), 2);
}
_ => panic!("expected any"),
}
}
#[test]
fn deserialize_nested_expr() {
let json =
r#"{ "expr": { "all": ["cms:article:edit", { "any": ["cms:article:create", "cms:article:publish"] }] } }"#;
let parsed: AuthorizationExprCheckRequest = serde_json::from_str(json).unwrap();
match parsed.expr {
PermissionExpr::All(x) => {
assert_eq!(x.all.len(), 2);
}
_ => panic!("expected all"),
}
}

View File

@@ -1,14 +1,15 @@
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 iam_service::constants::CANONICAL_BASE;
use iam_service::infrastructure::repositories::tenant_config_repo::TenantConfigRepoPg;
use iam_service::models::CreateTenantRequest;
use iam_service::models::CreateUserRequest;
use iam_service::presentation::http::api;
use iam_service::presentation::http::state::AppState;
use redis::aio::ConnectionManager;
use sqlx::PgPool;
use tower::ServiceExt;
@@ -106,7 +107,7 @@ async fn code2token_modes_requirements() -> Result<(), Box<dyn std::error::Error
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login-code")
.uri(format!("{}/auth/login-code", CANONICAL_BASE))
.header("Content-Type", "application/json")
.header("X-Tenant-ID", tenant.id.to_string())
.body(Body::from(login_code_req.to_string()))?,
@@ -134,7 +135,7 @@ async fn code2token_modes_requirements() -> Result<(), Box<dyn std::error::Error
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/code2token")
.uri(format!("{}/auth/code2token", CANONICAL_BASE))
.header("Content-Type", "application/json")
.header("X-Tenant-ID", tenant.id.to_string())
.body(Body::from(code2token_req.to_string()))?,
@@ -152,7 +153,7 @@ async fn code2token_modes_requirements() -> Result<(), Box<dyn std::error::Error
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/code2token")
.uri(format!("{}/auth/code2token", CANONICAL_BASE))
.header("Content-Type", "application/json")
.body(Body::from(code2token_req_missing_tenant.to_string()))?,
)
@@ -168,7 +169,7 @@ async fn code2token_modes_requirements() -> Result<(), Box<dyn std::error::Error
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/internal/auth/code2token")
.uri(format!("{}/internal/auth/code2token", CANONICAL_BASE))
.header("Content-Type", "application/json")
.header("X-Internal-Token", internal_psk)
.body(Body::from(code2token_req_internal.to_string()))?,