fix(auth): tighten refresh session revocation
This commit is contained in:
@@ -776,7 +776,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_ai_tasks".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::auth_sessions,
|
||||
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||
@@ -331,6 +331,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/sessions/{session_id}/revoke",
|
||||
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/me",
|
||||
axum::routing::patch(update_profile_identity).route_layer(
|
||||
@@ -1921,10 +1928,12 @@ mod tests {
|
||||
user: &module_auth::AuthUser,
|
||||
session_id: &str,
|
||||
) -> String {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let active_session_id = state.seed_test_refresh_session_for_user(user, session_id);
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
session_id: active_session_id,
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
@@ -1933,13 +1942,22 @@ mod tests {
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
now,
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn read_access_token(response_body: &[u8]) -> String {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(response_body).expect("login payload should be json");
|
||||
payload["token"]
|
||||
.as_str()
|
||||
.expect("access token should exist")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn password_login_request(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
@@ -1963,6 +1981,37 @@ mod tests {
|
||||
.expect("password login request should succeed")
|
||||
}
|
||||
|
||||
async fn password_login_request_with_client(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
password: &str,
|
||||
client_instance_id: &str,
|
||||
forwarded_for: &str,
|
||||
) -> axum::response::Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", client_instance_id)
|
||||
.header("x-forwarded-for", forwarded_for)
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": phone_number,
|
||||
"password": password
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("password login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("password login request should succeed")
|
||||
}
|
||||
|
||||
fn build_internal_creative_agent_app() -> Router {
|
||||
let mut config = AppConfig::default();
|
||||
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
|
||||
@@ -2536,10 +2585,11 @@ mod tests {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config.clone()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
|
||||
let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: seed_user.id.clone(),
|
||||
session_id: "sess_auth_debug".to_string(),
|
||||
session_id: session_id.clone(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: seed_user.token_version,
|
||||
@@ -2577,10 +2627,7 @@ mod tests {
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
|
||||
assert_eq!(
|
||||
payload["claims"]["sid"],
|
||||
Value::String("sess_auth_debug".to_string())
|
||||
);
|
||||
assert_eq!(payload["claims"]["sid"], Value::String(session_id));
|
||||
assert_eq!(
|
||||
payload["claims"]["ver"],
|
||||
Value::Number(serde_json::Number::from(seed_user.token_version))
|
||||
@@ -4238,12 +4285,17 @@ mod tests {
|
||||
session["clientType"] == Value::String("web_browser".to_string())
|
||||
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
||||
&& session["clientPlatform"] == Value::String("windows".to_string())
|
||||
&& session["sessionCount"] == Value::Number(1.into())
|
||||
&& session["sessionIds"]
|
||||
.as_array()
|
||||
.is_some_and(|ids| ids.len() == 1)
|
||||
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
|
||||
&& session["isCurrent"] == Value::Bool(true)
|
||||
}));
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("mini_program".to_string())
|
||||
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
|
||||
&& session["sessionCount"] == Value::Number(1.into())
|
||||
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
||||
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
||||
@@ -4251,6 +4303,108 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
let login_body = serde_json::json!({
|
||||
"phone": "13800138028",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", "same-device")
|
||||
.header("x-forwarded-for", "203.0.113.10")
|
||||
.body(Body::from(login_body.clone()))
|
||||
.expect("first login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
let first_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first cookie should exist")
|
||||
.to_string();
|
||||
let first_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let access_token = read_access_token(&first_body);
|
||||
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", "same-device")
|
||||
.header("x-forwarded-for", "203.0.113.10")
|
||||
.body(Body::from(login_body))
|
||||
.expect("second login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
|
||||
let sessions_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
|
||||
assert_eq!(sessions_response.status(), StatusCode::OK);
|
||||
let sessions_body = sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let sessions_payload: Value =
|
||||
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||
let sessions = sessions_payload["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions should be array");
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into()));
|
||||
assert_eq!(sessions[0]["isCurrent"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
sessions[0]["ipMasked"],
|
||||
Value::String("203.0.*.*".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sessions[0]["sessionIds"]
|
||||
.as_array()
|
||||
.expect("session ids should exist")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_phone() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -4362,9 +4516,23 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn password_change_allows_login_with_new_password_only() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_password_change");
|
||||
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||
let refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let token = read_access_token(&login_body);
|
||||
|
||||
let change_response = app
|
||||
.clone()
|
||||
@@ -4386,6 +4554,40 @@ mod tests {
|
||||
.await
|
||||
.expect("change password request should succeed");
|
||||
assert_eq!(change_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
change_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let old_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("me request should succeed");
|
||||
assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let old_refresh_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("refresh request should succeed");
|
||||
assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let old_password_response =
|
||||
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||
@@ -4429,23 +4631,16 @@ mod tests {
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: seed_user.id.clone(),
|
||||
session_id: "sess_me_query".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: seed_user.token_version,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some(seed_user.display_name.clone()),
|
||||
},
|
||||
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 login_response =
|
||||
password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await;
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let token = read_access_token(&login_body);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -4606,6 +4801,141 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let first_login_response = password_login_request_with_client(
|
||||
app.clone(),
|
||||
"13800138030",
|
||||
TEST_PASSWORD,
|
||||
"revoke-current-device",
|
||||
"203.0.113.30",
|
||||
)
|
||||
.await;
|
||||
let first_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first cookie should exist")
|
||||
.to_string();
|
||||
let first_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let first_access_token = read_access_token(&first_body);
|
||||
|
||||
let second_login_response = password_login_request_with_client(
|
||||
app.clone(),
|
||||
"13800138030",
|
||||
TEST_PASSWORD,
|
||||
"revoke-remote-device",
|
||||
"203.0.113.31",
|
||||
)
|
||||
.await;
|
||||
let second_cookie = second_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("second cookie should exist")
|
||||
.to_string();
|
||||
let second_body = second_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("second login body should collect")
|
||||
.to_bytes();
|
||||
let second_access_token = read_access_token(&second_body);
|
||||
|
||||
let remote_sessions_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.header("cookie", first_cookie.clone())
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
assert_eq!(remote_sessions_response.status(), StatusCode::OK);
|
||||
let remote_sessions_body = remote_sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let remote_sessions_payload: Value =
|
||||
serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json");
|
||||
let remote_session_id = remote_sessions_payload["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions should be array")
|
||||
.iter()
|
||||
.find(|session| session["isCurrent"] == Value::Bool(false))
|
||||
.and_then(|session| session["sessionId"].as_str())
|
||||
.expect("remote session id should exist")
|
||||
.to_string();
|
||||
|
||||
let revoke_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/auth/sessions/{remote_session_id}/revoke"))
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("revoke request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("revoke request should succeed");
|
||||
assert_eq!(revoke_response.status(), StatusCode::OK);
|
||||
|
||||
let current_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("current me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("current me request should succeed");
|
||||
assert_eq!(current_me_response.status(), StatusCode::OK);
|
||||
|
||||
let remote_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {second_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("remote me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("remote me request should succeed");
|
||||
assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let remote_refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", second_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("remote refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("remote refresh request should succeed");
|
||||
assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_clears_cookie_and_invalidates_current_access_token() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -4688,6 +5018,12 @@ mod tests {
|
||||
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
|
||||
let refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -4702,6 +5038,7 @@ mod tests {
|
||||
.to_string();
|
||||
|
||||
let logout_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
@@ -4721,6 +5058,19 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("refresh request should succeed");
|
||||
assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -117,6 +117,34 @@ pub async fn require_bearer_auth(
|
||||
.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()));
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
|
||||
use shared_contracts::auth::{
|
||||
AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -37,41 +42,189 @@ pub async fn auth_sessions(
|
||||
.refresh_session_service()
|
||||
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
|
||||
.map_err(map_refresh_session_list_error)?;
|
||||
let current_session_id = authenticated.claims().session_id().to_string();
|
||||
let session_groups = group_sessions_by_device_and_ip(sessions.sessions);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthSessionsResponse {
|
||||
sessions: sessions
|
||||
.sessions
|
||||
sessions: session_groups
|
||||
.into_iter()
|
||||
.map(|session| {
|
||||
let is_current = current_refresh_token_hash
|
||||
.as_ref()
|
||||
.is_some_and(|hash| session.refresh_token_hash == *hash);
|
||||
let client_label = session.client_info.device_display_name.clone();
|
||||
|
||||
AuthSessionSummaryPayload {
|
||||
session_id: session.session_id,
|
||||
client_type: session.client_info.client_type,
|
||||
client_runtime: session.client_info.client_runtime,
|
||||
client_platform: session.client_info.client_platform,
|
||||
client_label,
|
||||
device_display_name: session.client_info.device_display_name,
|
||||
mini_program_app_id: session.client_info.mini_program_app_id,
|
||||
mini_program_env: session.client_info.mini_program_env,
|
||||
user_agent: session.client_info.user_agent,
|
||||
ip_masked: mask_ip(session.client_info.ip.as_deref()),
|
||||
is_current,
|
||||
created_at: session.created_at,
|
||||
last_seen_at: session.last_seen_at,
|
||||
expires_at: session.expires_at,
|
||||
}
|
||||
.map(|group| {
|
||||
build_session_summary(
|
||||
group,
|
||||
current_refresh_token_hash.as_deref(),
|
||||
¤t_session_id,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn revoke_auth_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let session_id = session_id.trim().to_string();
|
||||
if session_id.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少会话 ID"));
|
||||
}
|
||||
if session_id == authenticated.claims().session_id() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::CONFLICT).with_message("当前设备请使用退出登录")
|
||||
);
|
||||
}
|
||||
|
||||
let revoke_result = state
|
||||
.refresh_session_service()
|
||||
.revoke_session_by_user_and_session(
|
||||
RevokeRefreshSessionByUserInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
session_id,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_refresh_session_revoke_error)?;
|
||||
if !revoke_result.revoked {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("会话不存在或已失效")
|
||||
);
|
||||
}
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RevokeAuthSessionResponse { ok: true },
|
||||
))
|
||||
}
|
||||
|
||||
fn group_sessions_by_device_and_ip(
|
||||
sessions: Vec<RefreshSessionRecord>,
|
||||
) -> Vec<Vec<RefreshSessionRecord>> {
|
||||
let mut grouped = HashMap::<String, Vec<RefreshSessionRecord>>::new();
|
||||
for session in sessions {
|
||||
grouped
|
||||
.entry(build_session_group_key(&session))
|
||||
.or_default()
|
||||
.push(session);
|
||||
}
|
||||
|
||||
let mut groups = grouped.into_values().collect::<Vec<_>>();
|
||||
for group in &mut groups {
|
||||
group.sort_by(|left, right| {
|
||||
right
|
||||
.last_seen_at
|
||||
.cmp(&left.last_seen_at)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
}
|
||||
groups.sort_by(|left, right| {
|
||||
group_latest_last_seen(right)
|
||||
.cmp(group_latest_last_seen(left))
|
||||
.then_with(|| group_earliest_created(left).cmp(group_earliest_created(right)))
|
||||
});
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
fn build_session_group_key(session: &RefreshSessionRecord) -> String {
|
||||
let client_info = &session.client_info;
|
||||
let device_key = client_info.device_fingerprint.as_deref().unwrap_or("");
|
||||
if !device_key.is_empty() {
|
||||
return format!("{}|{}", device_key, client_info.ip.as_deref().unwrap_or(""));
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}|{}",
|
||||
client_info.client_type,
|
||||
client_info.client_runtime,
|
||||
client_info.client_platform,
|
||||
client_info.device_display_name,
|
||||
client_info.user_agent.as_deref().unwrap_or(""),
|
||||
client_info.ip.as_deref().unwrap_or("")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_session_summary(
|
||||
group: Vec<RefreshSessionRecord>,
|
||||
current_refresh_token_hash: Option<&str>,
|
||||
current_session_id: &str,
|
||||
) -> AuthSessionSummaryPayload {
|
||||
let is_current = group.iter().any(|session| {
|
||||
session.session_id == current_session_id
|
||||
|| current_refresh_token_hash.is_some_and(|hash| session.refresh_token_hash == hash)
|
||||
});
|
||||
let representative = group
|
||||
.iter()
|
||||
.find(|session| is_current && session.session_id == current_session_id)
|
||||
.or_else(|| {
|
||||
group.iter().find(|session| {
|
||||
is_current
|
||||
&& current_refresh_token_hash
|
||||
.is_some_and(|hash| session.refresh_token_hash == hash)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| group.first().expect("session group should not be empty"));
|
||||
let client_label = representative.client_info.device_display_name.clone();
|
||||
let session_ids = group
|
||||
.iter()
|
||||
.map(|session| session.session_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX);
|
||||
|
||||
AuthSessionSummaryPayload {
|
||||
session_id: representative.session_id.clone(),
|
||||
session_ids,
|
||||
session_count,
|
||||
client_type: representative.client_info.client_type.clone(),
|
||||
client_runtime: representative.client_info.client_runtime.clone(),
|
||||
client_platform: representative.client_info.client_platform.clone(),
|
||||
client_label,
|
||||
device_display_name: representative.client_info.device_display_name.clone(),
|
||||
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
|
||||
mini_program_env: representative.client_info.mini_program_env.clone(),
|
||||
user_agent: representative.client_info.user_agent.clone(),
|
||||
ip_masked: mask_ip(representative.client_info.ip.as_deref()),
|
||||
is_current,
|
||||
created_at: group_earliest_created(&group).to_string(),
|
||||
last_seen_at: group_latest_last_seen(&group).to_string(),
|
||||
expires_at: group_latest_expires_at(&group).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str {
|
||||
group
|
||||
.iter()
|
||||
.map(|session| session.last_seen_at.as_str())
|
||||
.max()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str {
|
||||
group
|
||||
.iter()
|
||||
.map(|session| session.created_at.as_str())
|
||||
.min()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str {
|
||||
group
|
||||
.iter()
|
||||
.map(|session| session.expires_at.as_str())
|
||||
.max()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
module_auth::RefreshSessionError::UserNotFound => {
|
||||
@@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
module_auth::RefreshSessionError::MissingToken
|
||||
| module_auth::RefreshSessionError::SessionNotFound => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
|
||||
}
|
||||
module_auth::RefreshSessionError::SessionExpired
|
||||
| module_auth::RefreshSessionError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
|
||||
}
|
||||
module_auth::RefreshSessionError::Store(message) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,14 +375,15 @@ mod tests {
|
||||
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id,
|
||||
session_id: "sess_creation_doc_input".to_string(),
|
||||
user_id: user.id.clone(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some(user.display_name),
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
|
||||
@@ -333,7 +333,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_llm_proxy".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -40,6 +40,7 @@ pub async fn logout(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
refresh_token_hash,
|
||||
session_id: Some(authenticated.claims().session_id().to_string()),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,8 @@ use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth_payload::map_auth_user_payload,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
|
||||
build_refresh_session_cookie_header, create_auth_session,
|
||||
record_daily_login_tracking_event_after_auth_success,
|
||||
},
|
||||
http_error::AppError,
|
||||
@@ -30,14 +31,17 @@ pub async fn change_password(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<PasswordChangeRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.change_password(ChangePasswordInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
current_password: payload.current_password,
|
||||
new_password: payload.new_password,
|
||||
})
|
||||
.change_password_and_revoke_all_sessions(
|
||||
ChangePasswordInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
current_password: payload.current_password,
|
||||
new_password: payload.new_password,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_password_management_error)?;
|
||||
state
|
||||
@@ -48,11 +52,20 @@ pub async fn change_password(
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PasswordChangeResponse {
|
||||
user: map_auth_user_payload(result.user),
|
||||
},
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_clear_refresh_session_cookie_header(&state)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
PasswordChangeResponse {
|
||||
user: map_auth_user_payload(result.user),
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +374,10 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_browse_history".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_browse_history",
|
||||
),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -174,7 +174,10 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_inventory".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_inventory",
|
||||
),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -1568,7 +1568,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_profile".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -575,7 +575,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_save".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -350,7 +350,10 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_settings".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_settings",
|
||||
),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -600,6 +600,54 @@ impl AppState {
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
pub(crate) fn seed_test_refresh_session_for_user(
|
||||
&self,
|
||||
user: &module_auth::AuthUser,
|
||||
seed: &str,
|
||||
) -> String {
|
||||
let session = self
|
||||
.refresh_session_service()
|
||||
.create_session(
|
||||
module_auth::CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: platform_auth::hash_refresh_session_token(&format!(
|
||||
"test-refresh-token-{seed}"
|
||||
)),
|
||||
issued_by_provider: module_auth::AuthLoginMethod::Password,
|
||||
client_info: module_auth::RefreshSessionClientInfo {
|
||||
client_type: "web_browser".to_string(),
|
||||
client_runtime: "test".to_string(),
|
||||
client_platform: "test".to_string(),
|
||||
client_instance_id: Some(seed.to_string()),
|
||||
device_fingerprint: Some(format!("test-device-{seed}")),
|
||||
device_display_name: "Test Browser".to_string(),
|
||||
mini_program_app_id: None,
|
||||
mini_program_env: None,
|
||||
user_agent: Some("GenarrativeApiServerTest/1.0".to_string()),
|
||||
ip: Some("127.0.0.1".to_string()),
|
||||
},
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("test refresh session should create");
|
||||
|
||||
session.session.session_id
|
||||
}
|
||||
|
||||
pub(crate) fn seed_test_refresh_session_for_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
seed: &str,
|
||||
) -> String {
|
||||
let user = self
|
||||
.auth_user_service()
|
||||
.get_user_by_id(user_id)
|
||||
.expect("test user lookup should succeed")
|
||||
.expect("test user should exist");
|
||||
|
||||
self.seed_test_refresh_session_for_user(&user, seed)
|
||||
}
|
||||
|
||||
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
|
||||
*self
|
||||
.test_creation_entry_config
|
||||
|
||||
@@ -959,7 +959,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_battles".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_story_battles"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -1132,7 +1132,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_sessions".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_story_sessions"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -121,6 +121,9 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrack
|
||||
("GET", "/api/auth/sessions") => {
|
||||
Some(route_spec("auth_sessions_view", "auth", User, "anonymous"))
|
||||
}
|
||||
("POST", "/api/auth/sessions/{id}/revoke") => {
|
||||
Some(route_spec("auth_revoke_session", "auth", User, "anonymous"))
|
||||
}
|
||||
("POST", "/api/auth/refresh") => {
|
||||
Some(route_spec("auth_refresh_success", "auth", Site, "site"))
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ pub struct ListActiveRefreshSessionsResult {
|
||||
pub sessions: Vec<RefreshSessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RevokeRefreshSessionResult {
|
||||
pub session_id: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionResult {
|
||||
pub user: AuthUser,
|
||||
|
||||
@@ -87,10 +87,17 @@ pub struct RotateRefreshSessionInput {
|
||||
pub next_refresh_token_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RevokeRefreshSessionByUserInput {
|
||||
pub user_id: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -230,6 +230,22 @@ impl PasswordEntryService {
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
input: ChangePasswordInput,
|
||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||
self.change_password_internal(input, None).await
|
||||
}
|
||||
|
||||
pub async fn change_password_and_revoke_all_sessions(
|
||||
&self,
|
||||
input: ChangePasswordInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||
self.change_password_internal(input, Some(now)).await
|
||||
}
|
||||
|
||||
async fn change_password_internal(
|
||||
&self,
|
||||
input: ChangePasswordInput,
|
||||
revoke_all_sessions_at: Option<OffsetDateTime>,
|
||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||
validate_password(&input.new_password)?;
|
||||
let stored_user = self
|
||||
@@ -257,7 +273,7 @@ impl PasswordEntryService {
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
let user = self
|
||||
.store
|
||||
.set_user_password_hash(&input.user_id, password_hash)?
|
||||
.set_user_password_hash(&input.user_id, password_hash, revoke_all_sessions_at)?
|
||||
.ok_or(PasswordEntryError::UserNotFound)?;
|
||||
|
||||
Ok(ChangePasswordResult { user })
|
||||
@@ -375,6 +391,39 @@ impl RefreshSessionService {
|
||||
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
|
||||
Ok(ListActiveRefreshSessionsResult { sessions })
|
||||
}
|
||||
|
||||
pub fn revoke_session_by_user_and_session(
|
||||
&self,
|
||||
input: RevokeRefreshSessionByUserInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<RevokeRefreshSessionResult, RefreshSessionError> {
|
||||
self.store
|
||||
.find_by_user_id(&input.user_id)
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let Some(session_id) = normalize_required_string(&input.session_id) else {
|
||||
return Err(RefreshSessionError::SessionNotFound);
|
||||
};
|
||||
let revoked =
|
||||
self.store
|
||||
.revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?;
|
||||
|
||||
Ok(RevokeRefreshSessionResult {
|
||||
session_id,
|
||||
revoked,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_session_active_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, RefreshSessionError> {
|
||||
self.store
|
||||
.is_session_active_for_user(user_id, session_id.trim(), now)
|
||||
}
|
||||
}
|
||||
|
||||
impl PhoneAuthService {
|
||||
@@ -779,7 +828,7 @@ impl AuthUserService {
|
||||
input: LogoutCurrentSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<LogoutCurrentSessionResult, LogoutError> {
|
||||
if let Some(refresh_token_hash) = input
|
||||
let revoked_by_hash = if let Some(refresh_token_hash) = input
|
||||
.refresh_token_hash
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
@@ -788,6 +837,21 @@ impl AuthUserService {
|
||||
self.store
|
||||
.revoke_session_by_refresh_token_hash(refresh_token_hash, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !revoked_by_hash
|
||||
&& let Some(session_id) = input
|
||||
.session_id
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
self.store
|
||||
.revoke_session_by_user_and_session_id(&input.user_id, session_id, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
}
|
||||
|
||||
let user = self
|
||||
@@ -1685,6 +1749,36 @@ impl InMemoryAuthStore {
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn is_session_active_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, RefreshSessionError> {
|
||||
if session_id.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(stored) = state.sessions_by_id.get(session_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if stored.session.user_id != user_id || stored.session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let expires_at = OffsetDateTime::parse(
|
||||
&stored.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?;
|
||||
|
||||
Ok(expires_at > now)
|
||||
}
|
||||
|
||||
fn rotate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -1774,6 +1868,37 @@ impl InMemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revoke_session_by_user_and_session_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(stored) = state.sessions_by_id.get_mut(session_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if stored.session.user_id != user_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if stored.session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?;
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso;
|
||||
self.persist_refresh_state(&state)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn revoke_all_sessions_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -1832,11 +1957,21 @@ impl InMemoryAuthStore {
|
||||
&self,
|
||||
user_id: &str,
|
||||
password_hash: String,
|
||||
revoke_all_sessions_at: Option<OffsetDateTime>,
|
||||
) -> Result<Option<AuthUser>, PasswordEntryError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
let revoke_all_sessions_at = match revoke_all_sessions_at {
|
||||
Some(now) => Some(
|
||||
now.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
for stored_user in state.users_by_username.values_mut() {
|
||||
if stored_user.user.id != user_id {
|
||||
@@ -1847,6 +1982,18 @@ impl InMemoryAuthStore {
|
||||
stored_user.password_login_enabled = true;
|
||||
stored_user.user.token_version += 1;
|
||||
let next_user = stored_user.user.clone();
|
||||
if let Some(now_iso) = revoke_all_sessions_at.as_ref() {
|
||||
for stored_session in state.sessions_by_id.values_mut() {
|
||||
if stored_session.session.user_id != user_id
|
||||
|| stored_session.session.revoked_at.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stored_session.session.revoked_at = Some(now_iso.clone());
|
||||
stored_session.session.updated_at = now_iso.clone();
|
||||
}
|
||||
}
|
||||
self.persist_password_state(&state)?;
|
||||
return Ok(Some(next_user));
|
||||
}
|
||||
@@ -2177,6 +2324,118 @@ mod tests {
|
||||
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_and_revoke_all_sessions_revokes_every_refresh_session() {
|
||||
let store = build_store();
|
||||
let user = create_phone_login_user(store.clone(), "13800138030").await;
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
let first_password_user = password_service
|
||||
.change_password(ChangePasswordInput {
|
||||
user_id: user.id.clone(),
|
||||
current_password: None,
|
||||
new_password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first password should set")
|
||||
.user;
|
||||
let first_token_hash = hash_refresh_session_token("change-password-token-01");
|
||||
let second_token_hash = hash_refresh_session_token("change-password-token-02");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("first session should create");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: second_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "safari".to_string(),
|
||||
device_display_name: "iOS / Safari".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now + Duration::seconds(1),
|
||||
)
|
||||
.expect("second session should create");
|
||||
|
||||
let changed_user = password_service
|
||||
.change_password_and_revoke_all_sessions(
|
||||
ChangePasswordInput {
|
||||
user_id: user.id.clone(),
|
||||
current_password: Some("secret123".to_string()),
|
||||
new_password: "secret456".to_string(),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.await
|
||||
.expect("password change should revoke all sessions")
|
||||
.user;
|
||||
|
||||
assert_eq!(
|
||||
changed_user.token_version,
|
||||
first_password_user.token_version + 1
|
||||
);
|
||||
assert!(
|
||||
refresh_service
|
||||
.list_active_sessions_by_user(&user.id, now + Duration::minutes(2))
|
||||
.expect("active sessions should list")
|
||||
.sessions
|
||||
.is_empty()
|
||||
);
|
||||
for (token_hash, next_hash) in [
|
||||
(
|
||||
first_token_hash,
|
||||
hash_refresh_session_token("change-password-token-01-next"),
|
||||
),
|
||||
(
|
||||
second_token_hash,
|
||||
hash_refresh_session_token("change-password-token-02-next"),
|
||||
),
|
||||
] {
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: token_hash,
|
||||
next_refresh_token_hash: next_hash,
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("revoked session should not rotate");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
password_service
|
||||
.execute(PasswordEntryInput {
|
||||
phone_number: "13800138030".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("old password should fail"),
|
||||
PasswordEntryError::InvalidCredentials
|
||||
);
|
||||
let login = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
phone_number: "13800138030".to_string(),
|
||||
password: "secret456".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("new password should login");
|
||||
assert_eq!(login.user.id, user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_wrong_password_after_set() {
|
||||
let store = build_store();
|
||||
@@ -2524,6 +2783,7 @@ mod tests {
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: Some(refresh_token_hash.clone()),
|
||||
session_id: None,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -2543,6 +2803,148 @@ mod tests {
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() {
|
||||
let store = build_store();
|
||||
let user = create_phone_login_user(store.clone(), "13800138028").await;
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let first_token_hash = hash_refresh_session_token("revoke-target-token");
|
||||
let second_token_hash = hash_refresh_session_token("revoke-current-token");
|
||||
|
||||
let target = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("target session should create");
|
||||
let current = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: second_token_hash,
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "firefox".to_string(),
|
||||
device_display_name: "Windows / Firefox".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now + Duration::seconds(1),
|
||||
)
|
||||
.expect("current session should create");
|
||||
|
||||
let revoke = refresh_service
|
||||
.revoke_session_by_user_and_session(
|
||||
RevokeRefreshSessionByUserInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: target.session.session_id.clone(),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.expect("target session should revoke");
|
||||
|
||||
assert!(revoke.revoked);
|
||||
assert_eq!(revoke.session_id, target.session.session_id);
|
||||
assert!(
|
||||
!refresh_service
|
||||
.is_session_active_for_user(
|
||||
&user.id,
|
||||
&target.session.session_id,
|
||||
now + Duration::minutes(2)
|
||||
)
|
||||
.expect("target active check should succeed")
|
||||
);
|
||||
assert!(
|
||||
refresh_service
|
||||
.is_session_active_for_user(
|
||||
&user.id,
|
||||
¤t.session.session_id,
|
||||
now + Duration::minutes(2)
|
||||
)
|
||||
.expect("current active check should succeed")
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.find_by_user_id(&user.id)
|
||||
.expect("user lookup should succeed")
|
||||
.expect("user should exist")
|
||||
.user
|
||||
.token_version,
|
||||
user.token_version
|
||||
);
|
||||
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: first_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("revoked target should not rotate");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() {
|
||||
let store = build_store();
|
||||
let user = create_phone_login_user(store.clone(), "13800138029").await;
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user_service = build_user_service(store);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let refresh_token_hash = hash_refresh_session_token("logout-sid-token");
|
||||
let session = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("session should create");
|
||||
|
||||
let result = user_service
|
||||
.logout_current_session(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: None,
|
||||
session_id: Some(session.session.session_id.clone()),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.expect("logout should succeed");
|
||||
|
||||
assert_eq!(result.user.token_version, user.token_version + 1);
|
||||
assert!(
|
||||
!refresh_service
|
||||
.is_session_active_for_user(
|
||||
&user.id,
|
||||
&session.session.session_id,
|
||||
now + Duration::minutes(2)
|
||||
)
|
||||
.expect("session active check should succeed")
|
||||
);
|
||||
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("sid-revoked session should fail");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
|
||||
let store = build_store();
|
||||
|
||||
@@ -114,6 +114,8 @@ pub struct AuthSessionsResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub session_ids: Vec<String>,
|
||||
pub session_count: u32,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
@@ -144,6 +146,11 @@ pub struct LogoutAllResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RevokeAuthSessionResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeRequest {
|
||||
|
||||
Reference in New Issue
Block a user