feat: add auth me endpoint

This commit is contained in:
2026-04-21 14:57:17 +08:00
parent c23088539e
commit 70dbefda2b
10 changed files with 360 additions and 2 deletions

View File

@@ -30,6 +30,7 @@
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
9. 接入 `POST /api/auth/entry` 首版密码登录链路
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
11. 接入 `GET /api/auth/me` 当前用户查询链路
后续与本 crate 直接相关的任务包括:
@@ -40,6 +41,7 @@
5. [x] 接入 `/healthz`
6. [x] 接入 `/api/auth/entry`
7. [x] 接入 `/api/assets/direct-upload-tickets`
8. [x] 接入 `/api/auth/me`
当前 tracing 约定:
@@ -99,3 +101,4 @@
3. 外部副作用通过 `platform-auth``platform-oss``platform-llm` 与各模块 crate 的应用层完成。
4. 不把领域规则直接堆在 handler 中。
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。

View File

@@ -15,6 +15,7 @@ use crate::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
},
auth_me::auth_me,
error_middleware::normalize_error_response,
health::health_check,
password_entry::password_entry,
@@ -46,6 +47,13 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token,
)),
)
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
@@ -506,4 +514,106 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn auth_me_returns_current_user_and_available_login_methods() {
let config = AppConfig {
sms_auth_enabled: true,
wechat_auth_enabled: true,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_me_query".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("guest_001".to_string()),
},
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 response = app
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["user"]["id"],
Value::String("user_00000001".to_string())
);
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "wechat"])
);
}
#[tokio::test]
async fn auth_me_returns_unauthorized_when_user_missing() {
let config = AppConfig::default();
let state = AppState::new(config).expect("state should build");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_missing".to_string(),
session_id: "sess_missing".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("ghost".to_string()),
},
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 response = app
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}

View File

@@ -0,0 +1,75 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use serde::Serialize;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMeResponse {
pub user: AuthMeUserPayload,
pub available_login_methods: Vec<&'static str>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMeUserPayload {
pub id: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: &'static str,
pub binding_status: &'static str,
pub wechat_bound: bool,
}
pub async fn auth_me(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = authenticated.claims().user_id().to_string();
let user = state
.password_entry_service()
.get_user_by_id(&user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录")
})?;
Ok(json_success_body(
Some(&request_context),
AuthMeResponse {
user: AuthMeUserPayload {
id: user.user.id,
username: user.user.username,
display_name: user.user.display_name,
phone_number_masked: user.user.phone_number_masked,
login_method: user.user.login_method.as_str(),
binding_status: user.user.binding_status.as_str(),
wechat_bound: user.user.wechat_bound,
},
available_login_methods: build_available_login_methods(&state),
},
))
}
fn build_available_login_methods(state: &AppState) -> Vec<&'static str> {
let mut methods = Vec::new();
if state.config.sms_auth_enabled {
methods.push("phone");
}
if state.config.wechat_auth_enabled {
methods.push("wechat");
}
methods
}

View File

@@ -14,6 +14,8 @@ pub struct AppConfig {
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
pub sms_auth_enabled: bool,
pub wechat_auth_enabled: bool,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
@@ -38,6 +40,8 @@ impl Default for AppConfig {
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
sms_auth_enabled: false,
wechat_auth_enabled: false,
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
@@ -115,6 +119,14 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
config.sms_auth_enabled = sms_auth_enabled;
}
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
config.wechat_auth_enabled = wechat_auth_enabled;
}
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);

View File

@@ -2,6 +2,7 @@ mod api_response;
mod app;
mod assets;
mod auth;
mod auth_me;
mod config;
mod error_middleware;
mod health;

View File

@@ -49,3 +49,4 @@
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。

View File

@@ -49,6 +49,11 @@ pub struct PasswordEntryResult {
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidUsername,
@@ -138,6 +143,17 @@ impl PasswordEntryService {
}
}
impl PasswordEntryService {
pub fn get_user_by_id(
&self,
user_id: &str,
) -> Result<Option<AuthMeResult>, PasswordEntryError> {
self.store
.find_by_user_id(user_id)
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
}
}
impl Default for InMemoryPasswordUserStore {
fn default() -> Self {
Self {
@@ -198,6 +214,22 @@ impl InMemoryPasswordUserStore {
Ok(user)
}
fn find_by_user_id(
&self,
user_id: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
.cloned())
}
}
#[derive(Debug, PartialEq, Eq)]