e0e3348f79
切断运行中从 SpacetimeDB 整包快照回灌内存的路径 清空旧 auth_store_snapshot 并仅保留一次性迁移 修复手机号字段只在 AuthUser 中时重复创建账号 同步认证排障与后端数据契约文档
501 lines
16 KiB
Rust
501 lines
16 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Request, State},
|
|
http::{
|
|
HeaderMap, StatusCode,
|
|
header::{AUTHORIZATION, COOKIE},
|
|
},
|
|
middleware::Next,
|
|
response::Response,
|
|
};
|
|
use platform_auth::{
|
|
AccessTokenClaims, AuthProvider, BindingStatus, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY,
|
|
RuntimeGuestTokenClaims, RuntimeGuestTokenClaimsInput, read_refresh_session_token,
|
|
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use shared_contracts::auth::RuntimeGuestTokenResponse;
|
|
use shared_kernel::{format_rfc3339, new_uuid_simple_string};
|
|
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,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum RuntimePrincipal {
|
|
User(AuthenticatedAccessToken),
|
|
Guest(RuntimeGuestTokenClaims),
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum RuntimePrincipalKind {
|
|
User,
|
|
Guest,
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
impl RuntimePrincipal {
|
|
pub fn subject(&self) -> &str {
|
|
match self {
|
|
Self::User(authenticated) => authenticated.claims().user_id(),
|
|
Self::Guest(claims) => claims.subject(),
|
|
}
|
|
}
|
|
|
|
pub fn kind(&self) -> RuntimePrincipalKind {
|
|
match self {
|
|
Self::User(_) => RuntimePrincipalKind::User,
|
|
Self::Guest(_) => RuntimePrincipalKind::Guest,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RuntimePrincipalKind {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::User => "user",
|
|
Self::Guest => "guest",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn issue_runtime_guest_token(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let issued_at = OffsetDateTime::now_utc();
|
|
let claims = RuntimeGuestTokenClaims::from_input(
|
|
RuntimeGuestTokenClaimsInput {
|
|
subject: format!("guest-runtime-{}", new_uuid_simple_string()),
|
|
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
|
|
},
|
|
state.auth_jwt_config(),
|
|
issued_at,
|
|
)
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
|
})?;
|
|
let token = sign_runtime_guest_token(&claims, state.auth_jwt_config()).map_err(|error| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
|
})?;
|
|
let expires_at = OffsetDateTime::from_unix_timestamp(claims.expires_at_unix() as i64)
|
|
.ok()
|
|
.and_then(|value| format_rfc3339(value).ok())
|
|
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
RuntimeGuestTokenResponse {
|
|
token,
|
|
expires_at,
|
|
subject: claims.subject().to_string(),
|
|
scope: claims.scope().to_string(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn require_bearer_auth(
|
|
State(state): State<AppState>,
|
|
mut request: Request,
|
|
next: Next,
|
|
) -> Result<Response, AppError> {
|
|
let path = request.uri().path().to_string();
|
|
let headers = request.headers().clone();
|
|
let request_id = request_id_from_request(&request);
|
|
let Some(authenticated) = authenticate_request(&state, path, headers, request_id).await? else {
|
|
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
|
};
|
|
request.extensions_mut().insert(authenticated.clone());
|
|
|
|
let mut response = next.run(request).await;
|
|
response.extensions_mut().insert(authenticated);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
pub async fn require_runtime_principal_auth(
|
|
State(state): State<AppState>,
|
|
mut request: Request,
|
|
next: Next,
|
|
) -> Result<Response, AppError> {
|
|
let path = request.uri().path().to_string();
|
|
let headers = request.headers().clone();
|
|
let request_id = request_id_from_request(&request);
|
|
let Some(principal) = authenticate_runtime_principal(&state, path, headers, request_id).await?
|
|
else {
|
|
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
|
};
|
|
request.extensions_mut().insert(principal.clone());
|
|
|
|
let mut response = next.run(request).await;
|
|
response.extensions_mut().insert(principal);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
async fn authenticate_runtime_principal(
|
|
state: &AppState,
|
|
path: String,
|
|
headers: HeaderMap,
|
|
request_id: String,
|
|
) -> Result<Option<RuntimePrincipal>, AppError> {
|
|
if !headers.contains_key(AUTHORIZATION) {
|
|
return Ok(None);
|
|
}
|
|
|
|
match authenticate_request(state, path, headers.clone(), request_id.clone()).await {
|
|
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
|
|
Ok(None) => Ok(None),
|
|
Err(_) => {
|
|
let bearer_token = extract_bearer_token(&headers)?;
|
|
let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config())
|
|
.map_err(|error| {
|
|
warn!(
|
|
%request_id,
|
|
error = %error,
|
|
"runtime guest JWT 校验失败"
|
|
);
|
|
AppError::from_status(StatusCode::UNAUTHORIZED)
|
|
})?;
|
|
if claims.scope() != RUNTIME_GUEST_SCOPE_PUBLIC_PLAY {
|
|
warn!(
|
|
%request_id,
|
|
scope = %claims.scope(),
|
|
"runtime guest JWT scope 非法"
|
|
);
|
|
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
|
}
|
|
Ok(Some(RuntimePrincipal::Guest(claims)))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn authenticate_request(
|
|
state: &AppState,
|
|
path: String,
|
|
headers: HeaderMap,
|
|
request_id: String,
|
|
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
|
|
if allows_internal_forwarded_auth(&path) {
|
|
if let Some(claims) = try_build_internal_forwarded_claims(state, &headers) {
|
|
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
|
}
|
|
}
|
|
|
|
if !headers.contains_key(AUTHORIZATION) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let bearer_token = extract_bearer_token(&headers)?;
|
|
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)
|
|
})?;
|
|
let Some(current_user) = current_user else {
|
|
warn!(
|
|
%request_id,
|
|
user_id = %claims.user_id(),
|
|
"Bearer JWT 对应用户不存在"
|
|
);
|
|
return Err(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("当前登录态已失效,请重新登录"));
|
|
}
|
|
|
|
Ok(Some(AuthenticatedAccessToken::new(claims)))
|
|
}
|
|
|
|
fn request_id_from_request(request: &Request) -> String {
|
|
request
|
|
.extensions()
|
|
.get::<RequestContext>()
|
|
.map(|context| context.request_id().to_string())
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|