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 { 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 { 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 { 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 { 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 { 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") ); } }