feat: add current session logout flow
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
||||
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
|
||||
13. 接入 `POST /api/auth/logout` 当前设备退出链路
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -44,6 +45,7 @@
|
||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||
8. [x] 接入 `/api/auth/me`
|
||||
9. [x] 接入 `/api/auth/refresh`
|
||||
10. [x] 接入 `/api/auth/logout`
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -105,3 +107,4 @@
|
||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
||||
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。
|
||||
8. 当前 `/api/auth/logout` 复用 `module-auth` 的当前会话吊销与用户版本递增能力,`api-server` 负责 Bearer JWT、refresh cookie 读取与清理 cookie 回写。
|
||||
|
||||
@@ -10,7 +10,7 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
assets::create_direct_upload_ticket,
|
||||
assets::{create_direct_upload_ticket, get_asset_read_url},
|
||||
auth::{
|
||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||
require_bearer_auth,
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
auth_me::auth_me,
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
logout::logout,
|
||||
password_entry::password_entry,
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -62,10 +63,23 @@ pub fn build_router(state: AppState) -> Router {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
post(logout)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_refresh_session_token,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route("/api/auth/entry", post(password_entry))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
@@ -226,13 +240,21 @@ mod tests {
|
||||
async fn internal_auth_claims_returns_verified_claims() {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config.clone()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "guest_auth_debug".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "usr_auth_debug".to_string(),
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_auth_debug".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 7,
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
@@ -268,7 +290,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
payload["claims"]["sub"],
|
||||
Value::String("usr_auth_debug".to_string())
|
||||
Value::String("user_00000001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["claims"]["sid"],
|
||||
@@ -276,7 +298,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
payload["claims"]["ver"],
|
||||
Value::Number(serde_json::Number::from(7))
|
||||
Value::Number(serde_json::Number::from(1))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -732,4 +754,149 @@ mod tests {
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_clears_cookie_and_invalidates_current_access_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
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 login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("login payload should be json");
|
||||
let access_token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("token should exist")
|
||||
.to_string();
|
||||
|
||||
let logout_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("logout request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout request should succeed");
|
||||
|
||||
assert_eq!(logout_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
logout_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let logout_body = logout_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("logout body should collect")
|
||||
.to_bytes();
|
||||
let logout_payload: Value =
|
||||
serde_json::from_slice(&logout_body).expect("logout payload should be json");
|
||||
assert_eq!(logout_payload["ok"], Value::Bool(true));
|
||||
|
||||
let me_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("me request should succeed");
|
||||
|
||||
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_no_cookie",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("login payload should be json");
|
||||
let access_token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("token should exist")
|
||||
.to_string();
|
||||
|
||||
let logout_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("logout request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout request should succeed");
|
||||
|
||||
assert_eq!(logout_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
logout_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,36 @@ pub async fn require_bearer_auth(
|
||||
);
|
||||
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("当前登录态已失效,请重新登录"));
|
||||
}
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
|
||||
@@ -3,7 +3,7 @@ use axum::http::{
|
||||
header::SET_COOKIE,
|
||||
};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, RefreshSessionError,
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
@@ -116,6 +116,16 @@ pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
63
server-rs/crates/api-server/src/logout.rs
Normal file
63
server-rs/crates/api-server/src/logout.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::LogoutCurrentSessionInput;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, RefreshSessionToken},
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header, map_logout_error,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let refresh_token_hash = maybe_refresh_token.and_then(|token| {
|
||||
let token = token.0.token().trim().to_string();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(hash_refresh_session_token(&token))
|
||||
});
|
||||
|
||||
state
|
||||
.auth_user_service()
|
||||
.logout_current_session(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
refresh_token_hash,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_logout_error)?;
|
||||
|
||||
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), LogoutResponse { ok: true }),
|
||||
))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod logout;
|
||||
mod password_entry;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ pub struct AppState {
|
||||
oss_client: Option<OssClient>,
|
||||
password_entry_service: PasswordEntryService,
|
||||
refresh_session_service: RefreshSessionService,
|
||||
auth_user_service: AuthUserService,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -49,6 +50,7 @@ impl AppState {
|
||||
let oss_client = build_oss_client(&config)?;
|
||||
let auth_store = InMemoryAuthStore::default();
|
||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||
let auth_user_service = AuthUserService::new(auth_store.clone());
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||
|
||||
@@ -59,6 +61,7 @@ impl AppState {
|
||||
oss_client,
|
||||
password_entry_service,
|
||||
refresh_session_service,
|
||||
auth_user_service,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,6 +84,10 @@ impl AppState {
|
||||
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
||||
&self.refresh_session_service
|
||||
}
|
||||
|
||||
pub fn auth_user_service(&self) -> &AuthUserService {
|
||||
&self.auth_user_service
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
|
||||
Reference in New Issue
Block a user