feat: add platform auth jwt adapter
This commit is contained in:
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-logging = { path = "../shared-logging" }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use serde_json::{Value, json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{http_error::ApiErrorPayload, request_context::RequestContext};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use axum::{body::Body, extract::Extension, http::Request, middleware, routing::get, Router};
|
||||
use axum::{Router, body::Body, extract::Extension, http::Request, middleware, routing::get};
|
||||
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tracing::{info_span, Level};
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
auth::{inspect_auth_claims, require_bearer_auth},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -19,6 +20,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
health_check(Extension(request_context)).await
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/_internal/auth/claims",
|
||||
get(inspect_auth_claims).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -53,7 +61,11 @@ mod tests {
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{config::AppConfig, state::AppState};
|
||||
@@ -62,7 +74,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_legacy_compatible_payload_and_headers() {
|
||||
let app = build_router(AppState::new(AppConfig::default()));
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -117,7 +129,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_standard_envelope_when_requested() {
|
||||
let app = build_router(AppState::new(AppConfig::default()));
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -152,4 +164,79 @@ mod tests {
|
||||
Value::String("req-health-envelope".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_auth_claims_rejects_missing_bearer_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/_internal/auth/claims")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_auth_claims_returns_verified_claims() {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config.clone()).expect("state should build");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "usr_auth_debug".to_string(),
|
||||
session_id: "sess_auth_debug".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 7,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/_internal/auth/claims")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["claims"]["sub"],
|
||||
Value::String("usr_auth_debug".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["claims"]["sid"],
|
||||
Value::String("sess_auth_debug".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["claims"]["ver"],
|
||||
Value::Number(serde_json::Number::from(7))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
119
server-rs/crates/api-server/src/auth.rs
Normal file
119
server-rs/crates/api-server/src/auth.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Request, State},
|
||||
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use platform_auth::{AccessTokenClaims, verify_access_token};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// 统一把已校验的 claims 写入 request extensions,避免后续 handler 再次重复解析 Bearer token。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedAccessToken {
|
||||
claims: AccessTokenClaims,
|
||||
}
|
||||
|
||||
impl AuthenticatedAccessToken {
|
||||
pub fn new(claims: AccessTokenClaims) -> Self {
|
||||
Self { claims }
|
||||
}
|
||||
|
||||
pub fn claims(&self) -> &AccessTokenClaims {
|
||||
&self.claims
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_bearer_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let request_id = request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"Bearer JWT 校验失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedAccessToken::new(claims));
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
pub async fn inspect_auth_claims(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Json<Value> {
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"claims": authenticated.claims(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
let authorization = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
let token = authorization
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| authorization.strip_prefix("bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
Ok(token.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_bearer_token;
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_token_accepts_standard_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer token-value"),
|
||||
);
|
||||
|
||||
let token = extract_bearer_token(&headers).expect("bearer token should be extracted");
|
||||
|
||||
assert_eq!(token, "token-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_token_rejects_missing_scheme() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic abc"));
|
||||
|
||||
let error = extract_bearer_token(&headers).expect_err("basic auth should be rejected");
|
||||
|
||||
assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ pub struct AppConfig {
|
||||
pub bind_host: String,
|
||||
pub bind_port: u16,
|
||||
pub log_filter: String,
|
||||
pub jwt_issuer: String,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_access_token_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -14,6 +17,9 @@ impl Default for AppConfig {
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 3000,
|
||||
log_filter: "info,tower_http=info".to_string(),
|
||||
jwt_issuer: "https://auth.genarrative.local".to_string(),
|
||||
jwt_secret: "genarrative-dev-secret".to_string(),
|
||||
jwt_access_token_ttl_seconds: 2 * 60 * 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +46,25 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(jwt_issuer) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"])
|
||||
{
|
||||
config.jwt_issuer = jwt_issuer;
|
||||
}
|
||||
|
||||
if let Some(jwt_secret) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_JWT_SECRET", "JWT_SECRET"])
|
||||
{
|
||||
config.jwt_secret = jwt_secret;
|
||||
}
|
||||
|
||||
if let Some(ttl_seconds) = read_first_duration_seconds_env(&[
|
||||
"GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS",
|
||||
"JWT_EXPIRES_IN",
|
||||
]) {
|
||||
config.jwt_access_token_ttl_seconds = ttl_seconds;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
@@ -50,3 +75,49 @@ impl AppConfig {
|
||||
.unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000)))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key).ok().and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_duration_seconds_env(keys: &[&str]) -> Option<u64> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_duration_seconds(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(seconds) = raw.parse::<u64>() {
|
||||
return Some(seconds);
|
||||
}
|
||||
|
||||
let (number, unit) = raw.split_at(raw.len().checked_sub(1)?);
|
||||
let unit = unit.to_ascii_lowercase();
|
||||
let number = number.trim().parse::<u64>().ok()?;
|
||||
|
||||
let multiplier = match unit.as_str() {
|
||||
"s" => 1,
|
||||
"m" => 60,
|
||||
"h" => 60 * 60,
|
||||
"d" => 24 * 60 * 60,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
number.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use tracing::{error, warn};
|
||||
|
||||
use crate::{
|
||||
http_error::AppError,
|
||||
request_context::{resolve_request_id, RequestContext},
|
||||
request_context::{RequestContext, resolve_request_id},
|
||||
};
|
||||
|
||||
pub async fn normalize_error_response(request: Request, next: Next) -> Response {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{extract::Extension, Json};
|
||||
use serde_json::{json, Value};
|
||||
use axum::{Json, extract::Extension};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod api_response;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
@@ -23,7 +24,8 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listener = TcpListener::bind(bind_address).await?;
|
||||
|
||||
let state = AppState::new(config);
|
||||
let state = AppState::new(config)
|
||||
.map_err(|error| std::io::Error::other(format!("初始化鉴权配置失败:{error}")))?;
|
||||
let router = build_router(state);
|
||||
|
||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{header::HeaderName, HeaderValue, Request as HttpRequest},
|
||||
http::{HeaderValue, Request as HttpRequest, header::HeaderName},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{header::HeaderName, HeaderValue},
|
||||
http::{HeaderValue, header::HeaderName},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::API_VERSION,
|
||||
request_context::{resolve_request_id, RequestContext, X_REQUEST_ID_HEADER},
|
||||
request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id},
|
||||
};
|
||||
|
||||
pub const API_VERSION_HEADER: &str = "x-api-version";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use platform_auth::{JwtConfig, JwtError};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
|
||||
@@ -6,10 +8,24 @@ pub struct AppState {
|
||||
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
auth_jwt_config: JwtConfig,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
Self { config }
|
||||
pub fn new(config: AppConfig) -> Result<Self, JwtError> {
|
||||
let auth_jwt_config = JwtConfig::new(
|
||||
config.jwt_issuer.clone(),
|
||||
config.jwt_secret.clone(),
|
||||
config.jwt_access_token_ttl_seconds,
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_jwt_config,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn auth_jwt_config(&self) -> &JwtConfig {
|
||||
&self.auth_jwt_config
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user