C1: action_inbox_service.rs 中 patient_id/user_id 的 format! 拼接改为
参数化查询 ($2/$3/$4/$5 绑定),消除 SQL 注入风险
C2: fhir/handler.rs 所有患者相关端点强制执行 allowed_patient_ids 范围
过滤,search 端点用 is_in 过滤,get 端点用 enforce_patient_scope 校验
H5: oauth/handler.rs 5 个管理端点添加 require_permission 校验
M3: oauth/handler.rs 和 middleware.rs 移除 "dev-secret-key" fallback,
缺少环境变量时启动失败(token)/返回 500(middleware)
172 lines
5.4 KiB
Rust
172 lines
5.4 KiB
Rust
use axum::{
|
||
extract::Request,
|
||
http::StatusCode,
|
||
middleware::Next,
|
||
response::{IntoResponse, Response},
|
||
Json,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
use uuid::Uuid;
|
||
|
||
/// Client Credentials JWT Claims
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ClientCredentialsClaims {
|
||
/// API 客户端 ID(api_clients 表主键)
|
||
pub sub: Uuid,
|
||
/// 租户 ID
|
||
pub tid: Uuid,
|
||
/// 允许的 FHIR scope 列表
|
||
pub scopes: Vec<String>,
|
||
/// 允许访问的患者 ID 列表(None = 该租户下全部患者)
|
||
pub allowed_patient_ids: Option<Vec<String>>,
|
||
/// 速率限制(每分钟请求数)
|
||
pub rate_limit_per_minute: i32,
|
||
/// 过期时间
|
||
pub exp: i64,
|
||
/// 签发时间
|
||
pub iat: i64,
|
||
/// 令牌类型标识
|
||
pub token_type: String,
|
||
}
|
||
|
||
/// FHIR 请求上下文 — 中间件注入到请求扩展中
|
||
#[derive(Debug, Clone)]
|
||
pub struct FhirAuthContext {
|
||
pub client_id: Uuid,
|
||
pub tenant_id: Uuid,
|
||
pub scopes: Vec<String>,
|
||
pub allowed_patient_ids: Option<Vec<String>>,
|
||
}
|
||
|
||
/// FHIR OAuth 认证中间件
|
||
pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response {
|
||
let auth_header = request
|
||
.headers()
|
||
.get("Authorization")
|
||
.and_then(|v| v.to_str().ok());
|
||
|
||
let token = match auth_header {
|
||
Some(header) if header.starts_with("Bearer ") => &header[7..],
|
||
_ => {
|
||
return (
|
||
StatusCode::UNAUTHORIZED,
|
||
Json(serde_json::json!({
|
||
"resourceType": "OperationOutcome",
|
||
"issue": [{
|
||
"severity": "error",
|
||
"code": "login",
|
||
"diagnostics": "Missing or invalid Authorization header"
|
||
}]
|
||
})),
|
||
)
|
||
.into_response();
|
||
}
|
||
};
|
||
|
||
let jwt_secret = match std::env::var("ERP__AUTH__JWT_SECRET") {
|
||
Ok(secret) => secret,
|
||
Err(_) => {
|
||
tracing::error!("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法验证 OAuth token");
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({
|
||
"resourceType": "OperationOutcome",
|
||
"issue": [{
|
||
"severity": "error",
|
||
"code": "exception",
|
||
"diagnostics": "Server configuration error"
|
||
}]
|
||
})),
|
||
)
|
||
.into_response();
|
||
}
|
||
};
|
||
|
||
let claims = match jsonwebtoken::decode::<ClientCredentialsClaims>(
|
||
token,
|
||
&jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()),
|
||
&jsonwebtoken::Validation::default(),
|
||
) {
|
||
Ok(data) => data.claims,
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, "FHIR OAuth token 验证失败");
|
||
return (
|
||
StatusCode::UNAUTHORIZED,
|
||
Json(serde_json::json!({
|
||
"resourceType": "OperationOutcome",
|
||
"issue": [{
|
||
"severity": "error",
|
||
"code": "login",
|
||
"diagnostics": "Invalid or expired token"
|
||
}]
|
||
})),
|
||
)
|
||
.into_response();
|
||
}
|
||
};
|
||
|
||
if claims.token_type != "client_credentials" {
|
||
return (
|
||
StatusCode::UNAUTHORIZED,
|
||
Json(serde_json::json!({
|
||
"resourceType": "OperationOutcome",
|
||
"issue": [{
|
||
"severity": "error",
|
||
"code": "login",
|
||
"diagnostics": "Token is not a client_credentials token"
|
||
}]
|
||
})),
|
||
)
|
||
.into_response();
|
||
}
|
||
|
||
let fhir_ctx = FhirAuthContext {
|
||
client_id: claims.sub,
|
||
tenant_id: claims.tid,
|
||
scopes: claims.scopes.clone(),
|
||
allowed_patient_ids: claims.allowed_patient_ids,
|
||
};
|
||
|
||
// 同时注入 TenantContext,兼容 require_permission() 和 ctx.tenant_id
|
||
// 将 FHIR scope 映射为内部 permission code
|
||
let permissions: Vec<String> = claims
|
||
.scopes
|
||
.iter()
|
||
.flat_map(|s| scope_to_permissions(s))
|
||
.collect();
|
||
|
||
let tenant_ctx = erp_core::types::TenantContext {
|
||
tenant_id: claims.tid,
|
||
user_id: claims.sub,
|
||
roles: vec!["api_client".to_string()],
|
||
permissions,
|
||
department_ids: vec![],
|
||
permission_data_scopes: HashMap::new(),
|
||
};
|
||
|
||
let mut request = request;
|
||
request.extensions_mut().insert(tenant_ctx);
|
||
request.extensions_mut().insert(fhir_ctx);
|
||
|
||
next.run(request).await
|
||
}
|
||
|
||
/// FHIR scope → 内部 permission code 映射
|
||
fn scope_to_permissions(scope: &str) -> Vec<String> {
|
||
match scope {
|
||
"Patient.read" => vec!["health.patient.list".to_string()],
|
||
"Observation.read" => vec![
|
||
"health.device-readings.list".to_string(),
|
||
"health.health-data.list".to_string(),
|
||
],
|
||
"Device.read" => vec!["health.devices.list".to_string()],
|
||
"Practitioner.read" => vec!["health.doctor.list".to_string()],
|
||
"Appointment.read" => vec!["health.appointment.list".to_string()],
|
||
"DiagnosticReport.read" => vec!["health.health-data.list".to_string()],
|
||
"Encounter.read" => vec!["health.consultation.list".to_string()],
|
||
"Task.read" => vec!["health.follow-up.list".to_string()],
|
||
_ => vec![],
|
||
}
|
||
}
|