feat(lib): add auth-kit

This commit is contained in:
2026-02-02 14:26:24 +08:00
parent e49b33a464
commit 27a6791591
19 changed files with 1154 additions and 185 deletions

View File

@@ -7,7 +7,7 @@ use crate::models::{
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery,
UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest,
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, AuthorizationCheckRequest, AuthorizationCheckResponse,
};
use serde_json::Value;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
@@ -140,6 +140,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
handlers::auth::login_handler,
handlers::auth::refresh_handler,
handlers::authorization::my_permissions_handler,
handlers::authorization::authorization_check_handler,
handlers::permission::list_permissions_handler,
handlers::platform::get_tenant_enabled_apps_handler,
handlers::platform::set_tenant_enabled_apps_handler,
@@ -211,6 +212,8 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
RequestAppStatusChangeRequest,
ApproveAppStatusChangeRequest,
AppStatusChangeRequest
,AuthorizationCheckRequest
,AuthorizationCheckResponse
)
),
tags(

View File

@@ -1,9 +1,10 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use axum::extract::State;
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse};
#[utoipa::path(
get,
@@ -58,3 +59,49 @@ pub async fn my_permissions_handler(
.await?;
Ok(AppResponse::ok(permissions))
}
#[utoipa::path(
post,
path = "/authorize/check",
tag = "Policy",
security(
("bearer_auth" = [])
),
request_body = AuthorizationCheckRequest,
responses(
(status = 200, description = "鉴权校验结果", body = AuthorizationCheckResponse),
(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_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(body): Json<AuthorizationCheckRequest>,
) -> Result<AppResponse<AuthorizationCheckResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
let allowed = match state
.authorization_service
.require_permission(tenant_id, user_id, body.permission.as_str())
.await
{
Ok(()) => true,
Err(AppError::PermissionDenied(_)) => false,
Err(e) => return Err(e),
};
Ok(AppResponse::ok(AuthorizationCheckResponse { allowed }))
}

93
src/handlers/jwks.rs Normal file
View File

@@ -0,0 +1,93 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Serialize)]
struct Jwk {
kty: &'static str,
kid: String,
#[serde(rename = "use")]
use_field: &'static str,
alg: &'static str,
n: String,
e: String,
}
#[derive(Deserialize)]
struct ExtraJwkKey {
kid: String,
public_key_pem: String,
}
fn build_jwks(extra_json: Option<&str>) -> Result<Jwks, &'static str> {
let keys = crate::utils::keys::get_keys();
let mut jwk_keys = vec![Jwk {
kty: "RSA",
kid: keys.kid.clone(),
use_field: "sig",
alg: "RS256",
n: keys.public_n.clone(),
e: keys.public_e.clone(),
}];
if let Some(extra_json) = extra_json {
let extra: Vec<ExtraJwkKey> =
serde_json::from_str(extra_json).map_err(|_| "Invalid JWT_JWKS_EXTRA_KEYS_JSON")?;
for k in extra {
let (n, e) = crate::utils::keys::jwk_components_from_public_pem(&k.public_key_pem)
.map_err(|_| "Invalid public_key_pem in JWT_JWKS_EXTRA_KEYS_JSON")?;
jwk_keys.push(Jwk {
kty: "RSA",
kid: k.kid,
use_field: "sig",
alg: "RS256",
n,
e,
});
}
}
Ok(Jwks { keys: jwk_keys })
}
pub async fn jwks_handler() -> Response {
let extra_json = std::env::var("JWT_JWKS_EXTRA_KEYS_JSON").ok();
match build_jwks(extra_json.as_deref()) {
Ok(jwks) => (StatusCode::OK, Json(jwks)).into_response(),
Err(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::build_jwks;
#[test]
fn build_jwks_rejects_invalid_extra_json() {
assert!(matches!(
build_jwks(Some("not-json")),
Err("Invalid JWT_JWKS_EXTRA_KEYS_JSON")
));
}
#[test]
fn build_jwks_accepts_extra_key() {
let active_public_pem = crate::utils::keys::get_keys().public_pem.clone();
let extra_json = serde_json::json!([{
"kid": "extra-kid",
"public_key_pem": active_public_pem
}])
.to_string();
let jwks = build_jwks(Some(&extra_json)).unwrap();
assert!(jwks.keys.iter().any(|k| k.kid == "extra-kid"));
assert!(jwks.keys.len() >= 2);
}
}

View File

@@ -1,6 +1,7 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod jwks;
pub mod permission;
pub mod platform;
pub mod role;
@@ -18,7 +19,8 @@ pub use app::{
request_app_status_change_handler, update_app_handler,
};
pub use auth::{login_handler, refresh_handler, register_handler};
pub use authorization::my_permissions_handler;
pub use authorization::{authorization_check_handler, my_permissions_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::{

View File

@@ -11,14 +11,16 @@ use axum::{
Router,
http::StatusCode,
middleware::from_fn,
middleware::from_fn_with_state,
routing::{get, post},
};
use config::AppConfig;
use handlers::{
AppState, approve_app_status_change_handler, create_app_handler, create_role_handler,
create_tenant_handler, delete_app_handler, delete_role_handler, delete_tenant_handler,
delete_user_handler, get_app_handler, get_role_handler, get_tenant_enabled_apps_handler,
get_tenant_handler, get_user_handler, grant_role_permissions_handler, grant_role_users_handler,
AppState, approve_app_status_change_handler, authorization_check_handler, create_app_handler,
create_role_handler, create_tenant_handler, delete_app_handler, delete_role_handler,
delete_tenant_handler, delete_user_handler, get_app_handler, get_role_handler,
get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
grant_role_permissions_handler, grant_role_users_handler, jwks_handler,
list_app_status_change_requests_handler, list_apps_handler, list_permissions_handler,
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
my_permissions_handler, refresh_handler, register_handler, reject_app_status_change_handler,
@@ -35,6 +37,7 @@ 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;
@@ -94,8 +97,33 @@ async fn main() {
permission_service,
};
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/refresh".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(),
],
skip_path_prefixes: vec!["/scalar".to_string()],
};
// 5. 构建路由
let api = Router::new()
.route("/.well-known/jwks.json", get(jwks_handler))
.route("/tenants/register", post(create_tenant_handler))
.route(
"/tenants/me",
@@ -123,6 +151,7 @@ async fn main() {
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.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))
@@ -157,8 +186,14 @@ async fn main() {
)
.route("/roles/{id}/users/grant", post(grant_role_users_handler))
.route("/roles/{id}/users/revoke", post(revoke_role_users_handler))
.layer(from_fn(middleware::resolve_tenant))
.layer(from_fn(middleware::auth::authenticate))
.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,
));
@@ -194,7 +229,10 @@ async fn main() {
"/platform/app-status-change-requests/{request_id}/reject",
post(reject_app_status_change_handler),
)
.layer(from_fn(middleware::auth::authenticate))
.layer(from_fn_with_state(
auth_cfg,
middleware::auth::authenticate_with_config,
))
.layer(from_fn(
common_telemetry::axum_middleware::trace_http_request,
));

View File

@@ -1,68 +1,3 @@
use axum::{
extract::{FromRequestParts, Request},
http::request::Parts,
middleware::Next,
response::Response,
pub use auth_kit::middleware::auth::{
AuthContext, AuthMiddlewareConfig, authenticate_with_config,
};
use common_telemetry::AppError;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct AuthContext {
pub tenant_id: Uuid,
pub user_id: Uuid,
pub roles: Vec<String>,
pub permissions: Vec<String>,
}
pub async fn authenticate(mut req: Request, next: Next) -> Result<Response, AppError> {
let path = req.uri().path();
if path.starts_with("/scalar")
|| path == "/tenants/register"
|| path == "/auth/register"
|| path == "/auth/login"
|| path == "/auth/refresh"
{
return Ok(next.run(req).await);
}
let token = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::MissingAuthHeader)?;
let claims = crate::utils::verify(token)?;
let tenant_id = Uuid::parse_str(&claims.tenant_id)
.map_err(|_| AppError::AuthError("Invalid tenant_id claim".into()))?;
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::AuthError("Invalid sub claim".into()))?;
tracing::Span::current().record("tenant_id", tracing::field::display(tenant_id));
tracing::Span::current().record("user_id", tracing::field::display(user_id));
req.extensions_mut().insert(AuthContext {
tenant_id,
user_id,
roles: claims.roles,
permissions: claims.permissions,
});
Ok(next.run(req).await)
}
impl<S> FromRequestParts<S> for AuthContext
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthContext>()
.cloned()
.ok_or(AppError::MissingAuthHeader)
}
}

View File

@@ -1,89 +1,4 @@
pub mod auth;
pub mod rate_limit;
use axum::extract::FromRequestParts;
use axum::{extract::Request, middleware::Next, response::Response};
use common_telemetry::AppError;
use http::request::Parts;
use uuid::Uuid;
// --- 1. 租户 ID 提取器 ---
#[derive(Clone, Debug)] // 这是一个类型安全的 Wrapper用于在 Handler 中注入
pub struct TenantId(pub Uuid);
pub async fn resolve_tenant(mut req: Request, next: Next) -> Result<Response, AppError> {
let path = req.uri().path();
if path.starts_with("/scalar") || path == "/tenants/register" || path == "/auth/refresh" {
return Ok(next.run(req).await);
}
if let Some(auth_tenant_id) = req
.extensions()
.get::<auth::AuthContext>()
.map(|ctx| ctx.tenant_id)
{
if let Some(header_value) = req
.headers()
.get("X-Tenant-ID")
.and_then(|val| val.to_str().ok())
{
let header_tenant_id = Uuid::parse_str(header_value)
.map_err(|_| AppError::BadRequest("Invalid X-Tenant-ID format".into()))?;
if header_tenant_id != auth_tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
}
tracing::Span::current().record("tenant_id", tracing::field::display(auth_tenant_id));
req.extensions_mut().insert(TenantId(auth_tenant_id));
return Ok(next.run(req).await);
}
// 尝试从 Header 获取 X-Tenant-ID
let tenant_id_str = req
.headers()
.get("X-Tenant-ID")
.and_then(|val| val.to_str().ok());
match tenant_id_str {
Some(id_str) => {
if let Ok(uuid) = Uuid::parse_str(id_str) {
tracing::Span::current().record("tenant_id", tracing::field::display(uuid));
// 验证成功,注入到 Extension 中
req.extensions_mut().insert(TenantId(uuid));
Ok(next.run(req).await)
} else {
Err(AppError::BadRequest("Invalid X-Tenant-ID format".into()))
}
}
None => {
// 如果是公开接口(如登录注册),可能不需要 TenantID视业务而定
// 这里假设严格模式,必须带 TenantID
Err(AppError::BadRequest("Missing X-Tenant-ID header".into()))
}
}
}
// 实现 FromRequestParts 让 Handler 可以直接写 `tid: TenantId`
impl<S> FromRequestParts<S> for TenantId
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(tid) = parts.extensions.get::<TenantId>().cloned() {
return Ok(tid);
}
let tenant_id_str = parts
.headers
.get("X-Tenant-ID")
.and_then(|val| val.to_str().ok());
match tenant_id_str {
Some(id_str) => uuid::Uuid::parse_str(id_str)
.map(TenantId)
.map_err(|_| AppError::BadRequest("Invalid X-Tenant-ID format".into())),
None => Err(AppError::BadRequest("Missing X-Tenant-ID header".into())),
}
}
}
pub use auth_kit::middleware::tenant::{TenantId, TenantMiddlewareConfig, resolve_tenant_with_config};

View File

@@ -407,3 +407,15 @@ pub struct RoleUsersRequest {
#[serde(default)]
pub user_ids: Vec<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct AuthorizationCheckRequest {
#[serde(default)]
pub permission: String,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
pub struct AuthorizationCheckResponse {
#[serde(default)]
pub allowed: bool,
}

View File

@@ -50,8 +50,9 @@ pub fn sign(
};
let keys = get_keys();
encode(&Header::new(Algorithm::RS256), &claims, &keys.encoding_key)
.map_err(|e| AppError::AuthError(e.to_string()))
let mut header = Header::new(Algorithm::RS256);
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> {

View File

@@ -1,40 +1,76 @@
use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rsa::pkcs1::DecodeRsaPublicKey;
use rsa::pkcs8::{DecodePublicKey, EncodePrivateKey, EncodePublicKey};
use rsa::rand_core::OsRng;
use rsa::traits::PublicKeyParts;
use rsa::{RsaPrivateKey, RsaPublicKey, pkcs1::LineEnding};
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,
pub public_pem: String,
}
static KEYS: OnceLock<KeyPair> = OnceLock::new();
pub fn get_keys() -> &'static KeyPair {
KEYS.get_or_init(|| {
// In a real production app, you would load these from files or ENV variables
// defined in your AppConfig.
// For now, we generate a fresh key pair on startup.
let kid = std::env::var("JWT_KEY_ID").unwrap_or_else(|_| "default".to_string());
let private_pem = std::env::var("JWT_PRIVATE_KEY_PEM").ok();
let public_pem = std::env::var("JWT_PUBLIC_KEY_PEM").ok();
let bits = 2048;
let private_key = RsaPrivateKey::new(&mut OsRng, bits).expect("failed to generate a key");
let public_key = RsaPublicKey::from(&private_key);
let private_pem = private_key
.to_pkcs1_pem(LineEnding::LF)
.expect("failed to encode private key");
let public_pem = public_key
.to_pkcs1_pem(LineEnding::LF)
.expect("failed to encode public key");
let (private_pem, public_pem, public_key) = match (private_pem, public_pem) {
(Some(priv_pem), Some(pub_pem)) => {
let public_key = RsaPublicKey::from_pkcs1_pem(&pub_pem)
.or_else(|_| RsaPublicKey::from_public_key_pem(&pub_pem))
.expect("invalid JWT_PUBLIC_KEY_PEM");
(priv_pem, pub_pem, public_key)
}
_ => {
let bits = 2048;
let private_key =
RsaPrivateKey::new(&mut OsRng, bits).expect("failed to generate a key");
let public_key = RsaPublicKey::from(&private_key);
let private_pem = private_key
.to_pkcs8_pem(LineEnding::LF)
.expect("failed to encode private key")
.to_string();
let public_pem = public_key
.to_public_key_pem(LineEnding::LF)
.expect("failed to encode public key")
.to_string();
(private_pem, public_pem, public_key)
}
};
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,
public_pem,
}
})
}
pub fn jwk_components_from_public_pem(public_pem: &str) -> Result<(String, String), String> {
let public_key = RsaPublicKey::from_pkcs1_pem(public_pem)
.or_else(|_| RsaPublicKey::from_public_key_pem(public_pem))
.map_err(|_| "Invalid public key pem".to_string())?;
let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
Ok((n, e))
}