Files
Genarrative/server-rs/crates/api-server/src/auth.rs

357 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
Json,
extract::{Extension, Request, State},
http::{
HeaderMap, StatusCode,
header::{AUTHORIZATION, COOKIE},
},
middleware::Next,
response::Response,
};
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tracing::warn;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
const INTERNAL_AUTH_USER_ID_HEADER: &str = "x-genarrative-authenticated-user-id";
const INTERNAL_API_SECRET_HEADER: &str = "x-genarrative-internal-api-secret";
// 统一把已校验的 claims 写入 request extensions避免后续 handler 再次重复解析 Bearer token。
#[derive(Clone, Debug)]
pub struct AuthenticatedAccessToken {
claims: AccessTokenClaims,
}
#[derive(Clone, Debug)]
pub struct RefreshSessionToken {
token: String,
}
impl AuthenticatedAccessToken {
pub fn new(claims: AccessTokenClaims) -> Self {
Self { claims }
}
pub fn claims(&self) -> &AccessTokenClaims {
&self.claims
}
}
impl RefreshSessionToken {
pub fn new(token: String) -> Self {
Self { token }
}
pub fn token(&self) -> &str {
&self.token
}
}
pub async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
if allows_internal_forwarded_auth(request.uri().path())
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
return Ok(response);
}
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)
})?;
let current_user = state
.auth_user_service()
.get_user_by_id(claims.user_id())
.map_err(|error| {
warn!(
%request_id,
error = %error,
"Bearer JWT 用户快照读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?
.ok_or_else(|| {
warn!(
%request_id,
user_id = %claims.user_id(),
"Bearer JWT 对应用户不存在"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
if current_user.token_version != claims.token_version() {
warn!(
%request_id,
user_id = %claims.user_id(),
token_version = claims.token_version(),
current_token_version = current_user.token_version,
"Bearer JWT 版本已失效"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"));
}
let session_is_active = state
.refresh_session_service()
.is_session_active_for_user(
claims.user_id(),
claims.session_id(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
error = %error,
"Bearer JWT refresh session 状态读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
if !session_is_active {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
"Bearer JWT 对应 refresh session 已失效"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"));
}
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
Ok(response)
}
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(),
}),
)
}
pub async fn attach_refresh_session_token(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
if let Some(token) = request
.headers()
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|cookie_header| {
read_refresh_session_token(cookie_header, state.refresh_cookie_config())
})
{
request
.extensions_mut()
.insert(RefreshSessionToken::new(token));
}
next.run(request).await
}
pub async fn inspect_refresh_session_cookie(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
request: Request,
) -> Json<Value> {
let maybe_token = request.extensions().get::<RefreshSessionToken>();
json_success_body(
Some(&request_context),
json!({
"cookieName": state.refresh_cookie_config().cookie_name(),
"present": maybe_token.is_some(),
"tokenLength": maybe_token.map(|token| token.token().len()),
}),
)
}
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())
}
fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/")
|| path.starts_with("/api/runtime/chat/")
|| path.starts_with("/api/runtime/creative-agent/")
|| path.starts_with("/api/runtime/puzzle/")
}
fn try_build_internal_forwarded_claims(
state: &AppState,
headers: &HeaderMap,
) -> Option<AccessTokenClaims> {
let expected_secret = state.config.internal_api_secret.as_ref()?.trim();
if expected_secret.is_empty() {
return None;
}
let provided_secret = headers
.get(INTERNAL_API_SECRET_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())?;
if provided_secret != expected_secret {
return None;
}
let user_id = headers
.get(INTERNAL_AUTH_USER_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
// 这里的 claims 只服务于经 Node 已鉴权后的本地内部转发链路,避免在开发态复制整套账号仓储。
AccessTokenClaims::from_input(
platform_auth::AccessTokenClaimsInput {
user_id: user_id.clone(),
session_id: format!("internal-forwarded-{user_id}"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 0,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: None,
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.ok()
}
#[cfg(test)]
mod tests {
use super::{
INTERNAL_API_SECRET_HEADER, INTERNAL_AUTH_USER_ID_HEADER, RefreshSessionToken,
allows_internal_forwarded_auth, extract_bearer_token, try_build_internal_forwarded_claims,
};
use crate::{config::AppConfig, state::AppState};
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);
}
#[test]
fn refresh_session_token_retains_original_value() {
let token = RefreshSessionToken::new("refresh-token-01".to_string());
assert_eq!(token.token(), "refresh-token-01");
}
#[test]
fn internal_forwarded_auth_allows_node_proxy_runtime_paths() {
assert!(allows_internal_forwarded_auth(
"/api/runtime/big-fish/sessions"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/chat/npc/turn/stream"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/creative-agent/sessions"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
}
#[test]
fn internal_forwarded_claims_require_matching_secret() {
let mut config = AppConfig::default();
config.internal_api_secret = Some("bridge-secret".to_string());
let state = AppState::new(config).expect("state should build");
let mut headers = HeaderMap::new();
headers.insert(
INTERNAL_AUTH_USER_ID_HEADER,
HeaderValue::from_static("user_forwarded_01"),
);
headers.insert(
INTERNAL_API_SECRET_HEADER,
HeaderValue::from_static("bridge-secret"),
);
let claims =
try_build_internal_forwarded_claims(&state, &headers).expect("claims should resolve");
assert_eq!(claims.user_id(), "user_forwarded_01");
assert_eq!(claims.token_version(), 0);
}
}