Files
Genarrative/server-rs/crates/api-server/src/auth_session.rs
T
suzmii 1c6c499a11 登录返回前同步认证投影
新增 create_auth_session_and_sync,统一密码、手机号和微信登录会话的投影同步时机。

避免登录接口签发的新 access token 在下一次请求被当前或其它节点的 SpacetimeDB 校验拒绝。

补充登录投影同步回归测试。
2026-09-02 14:55:52 +08:00

255 lines
9.2 KiB
Rust

use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
use module_auth::{
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionClientInfo,
RefreshSessionError,
};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AccessTokenDeviceInfo, AuthProvider, BindingStatus,
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
};
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
#[cfg(not(test))]
use crate::tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path;
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
pub access_token: String,
pub refresh_token: String,
}
/// 创建登录会话并立即同步认证投影。
///
/// `create_auth_session` 只更新 api-server 进程内的认证工作集。登录响应返回前必须
/// 完成一次投影同步,否则客户端拿到的 access token 会在下一次请求经过
/// `validate_auth_session` 时被其它节点或当前节点的 SpacetimeDB 校验拒绝。
pub async fn create_auth_session_and_sync(
state: &AppState,
user: &AuthUser,
session_client: &SessionClientContext,
session_provider: AuthLoginMethod,
) -> Result<SignedAuthSession, AppError> {
let signed_session = create_auth_session(state, user, session_client, session_provider)?;
state
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证状态失败:{error}"))
})?;
Ok(signed_session)
}
#[cfg(not(test))]
pub async fn record_daily_login_tracking_event_after_auth_success(
state: &AppState,
request_context: &RequestContext,
user_id: &str,
login_method: AuthLoginMethod,
) {
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发;每日登录也走统一埋点 helper/procedure。
record_daily_login_tracking_event_via_unified_path(
state,
request_context,
user_id,
login_method,
)
.await;
}
#[cfg(test)]
pub async fn record_daily_login_tracking_event_after_auth_success(
_state: &AppState,
_request_context: &RequestContext,
_user_id: &str,
_login_method: AuthLoginMethod,
) {
// 单元测试默认不启动 SpacetimeDB;这里仅验证登录链路调用点能通过编译并保持非阻断语义。
}
pub fn create_auth_session(
state: &AppState,
user: &AuthUser,
session_client: &SessionClientContext,
session_provider: AuthLoginMethod,
) -> Result<SignedAuthSession, AppError> {
let refresh_token = create_refresh_session_token();
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
let session = state
.refresh_session_service()
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash,
issued_by_provider: session_provider.clone(),
client_info: session_client.to_refresh_session_client_info(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_refresh_session_error)?;
let access_token = sign_access_token_for_user(
state,
user,
&session.session.session_id,
Some(&session_provider),
Some(&session.session.client_info),
)?;
Ok(SignedAuthSession {
access_token,
refresh_token,
})
}
pub fn sign_access_token_for_user(
state: &AppState,
user: &AuthUser,
session_id: &str,
session_provider_override: Option<&AuthLoginMethod>,
client_info: Option<&RefreshSessionClientInfo>,
) -> Result<String, AppError> {
let access_claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
provider: map_auth_provider(session_provider_override.unwrap_or(&user.login_method)),
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: user.phone_number_masked.is_some(),
binding_status: map_binding_status(&user.binding_status),
display_name: Some(user.display_name.clone()),
},
client_info.map(map_access_token_device_info),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})
}
pub fn build_refresh_session_cookie_header(
state: &AppState,
refresh_token: &str,
) -> Result<HeaderValue, AppError> {
let refresh_cookie =
build_refresh_session_set_cookie(refresh_token, state.refresh_cookie_config());
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})
}
pub fn build_clear_refresh_session_cookie_header(
state: &AppState,
) -> Result<HeaderValue, AppError> {
let refresh_cookie = build_refresh_session_clear_cookie(state.refresh_cookie_config());
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})
}
pub fn attach_set_cookie_header(headers: &mut HeaderMap, set_cookie: HeaderValue) {
headers.insert(SET_COOKIE, set_cookie);
}
pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError {
match error {
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
RefreshSessionError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}
pub fn map_logout_error(error: LogoutError) -> AppError {
match error {
LogoutError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"),
LogoutError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}
fn map_auth_provider(login_method: &AuthLoginMethod) -> AuthProvider {
match login_method {
AuthLoginMethod::Password => AuthProvider::Password,
AuthLoginMethod::Phone => AuthProvider::Phone,
AuthLoginMethod::Wechat => AuthProvider::Wechat,
}
}
fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> BindingStatus {
match binding_status {
module_auth::AuthBindingStatus::Active => BindingStatus::Active,
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
}
}
fn map_access_token_device_info(client_info: &RefreshSessionClientInfo) -> AccessTokenDeviceInfo {
AccessTokenDeviceInfo {
client_type: client_info.client_type.clone(),
client_runtime: client_info.client_runtime.clone(),
client_platform: client_info.client_platform.clone(),
}
}
#[cfg(test)]
mod tests {
use axum::http::HeaderMap;
use super::*;
use crate::{config::AppConfig, session_client::resolve_session_client_context};
#[tokio::test]
async fn create_auth_session_and_sync_persists_the_new_session_projection() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let user = state
.seed_test_phone_user_with_password("13800138099", "secret123")
.await;
let session_client = resolve_session_client_context(&HeaderMap::new());
assert!(
!state.test_auth_projection_is_synced(),
"the seeded user is a local change until the login path syncs it"
);
let signed_session =
create_auth_session_and_sync(&state, &user, &session_client, AuthLoginMethod::Password)
.await
.expect("auth session should be created and synchronized");
assert!(state.test_auth_projection_is_synced());
let claims = platform_auth::verify_access_token(
&signed_session.access_token,
state.auth_jwt_config(),
)
.expect("access token should be valid");
assert_eq!(claims.user_id(), user.id);
assert!(
state
.refresh_session_service()
.is_session_active_for_user(
claims.user_id(),
claims.session_id(),
time::OffsetDateTime::now_utc(),
)
.expect("session state should be readable")
);
}
}