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

@@ -170,7 +170,8 @@
- [ ] 实现 refresh token 轮换 - [ ] 实现 refresh token 轮换
- [ ] 实现会话吊销 - [ ] 实现会话吊销
- [ ] 实现全端登出 - [ ] 实现全端登出
- [ ] 实现 `me` 查询 - [x] 实现 `me` 查询
交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
### 手机验证码登录 ### 手机验证码登录
@@ -214,7 +215,8 @@
- [ ] 兼容 `/api/auth/login-options` - [ ] 兼容 `/api/auth/login-options`
- [x] 兼容 `/api/auth/entry` - [x] 兼容 `/api/auth/entry`
交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) 交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [ ] 兼容 `/api/auth/me` - [x] 兼容 `/api/auth/me`
交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [ ] 兼容 `/api/auth/logout` - [ ] 兼容 `/api/auth/logout`
- [ ] 兼容 `/api/auth/logout-all` - [ ] 兼容 `/api/auth/logout-all`
- [ ] 兼容 `/api/auth/refresh` - [ ] 兼容 `/api/auth/refresh`

View File

@@ -0,0 +1,121 @@
# `/api/auth/me` 查询落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``实现 me 查询` 任务的首版落地,冻结:
1. `GET /api/auth/me` 的请求与响应 contract。
2. 当前阶段 Bearer JWT 与用户快照读取的衔接方式。
3. `availableLoginMethods` 的返回口径。
4. JWT 有效但本地用户不存在时的错误处理语义。
## 2. 当前基线
当前 Node `/api/auth/me` 具备以下最小语义:
1. 必须先通过 Bearer JWT 校验。
2.`sub = user_id` 读取当前用户。
3. 返回 `user + availableLoginMethods`
4. `availableLoginMethods` 只返回当前对外开启的补充登录方式,不包含 `password`
Rust 首版需要保留这条最小 contract但当前阶段允许继续使用进程内仓储承接用户真相。
## 3. 当前阶段范围
本阶段只落以下内容:
1. `module-auth` 增加按 `user_id` 查询当前用户能力。
2. `api-server` 暴露 `GET /api/auth/me`
3. 返回与当前前端兼容的 `user + availableLoginMethods`
本阶段不包含:
1. `refresh token` 轮换。
2. 会话列表、审计、风控等扩展信息。
3. `SpacetimeDB` 真正的身份表读取。
## 4. contract
### 4.1 请求
1. 方法:`GET`
2. 路径:`/api/auth/me`
3. 鉴权:`Authorization: Bearer <token>`
### 4.2 成功响应
```json
{
"user": {
"id": "user_00000001",
"username": "guest_001",
"displayName": "guest_001",
"phoneNumberMasked": null,
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false
},
"availableLoginMethods": []
}
```
说明:
1. 当前阶段 `user` 字段固定返回当前登录用户快照,不返回 `null`
2. `availableLoginMethods` 只按当前对外配置返回:
- `SMS_AUTH_ENABLED=true` 时包含 `phone`
- `WECHAT_AUTH_ENABLED=true` 时包含 `wechat`
3. `password` 不进入 `availableLoginMethods`,保持和 Node 现状一致。
## 5. 错误语义
### 5.1 缺少或非法 Bearer token
1. 返回 `401 UNAUTHORIZED`
### 5.2 JWT 有效但用户不存在
1. 返回 `401 UNAUTHORIZED`
2. 语义视为“当前登录态已失效,需要重新登录”
说明:
1. 当前阶段不把这种情况返回为 `404`
2. 这样可以与后续 `token_version`、会话吊销和用户禁用策略保持同一类恢复路径。
## 6. crate 边界
### 6.1 `module-auth`
负责:
1. 提供按 `user_id` 查询当前用户快照的能力。
2. 继续复用密码登录阶段已经建立的同一份进程内用户真相。
### 6.2 `api-server`
负责:
1. 复用现有 Bearer JWT 中间件拿到 `sub`
2. 调用 `module-auth` 查询用户。
3. 组装 `AuthMeResponse`
## 7. 测试策略
至少覆盖:
1. 已登录用户可通过 `/api/auth/me` 取回当前用户。
2. 当短信/微信开关开启时,`availableLoginMethods` 返回对应值。
3. JWT 有效但用户不存在时返回 `401`
## 8. 后续衔接
这条任务完成后,下一步顺序固定为:
1. refresh token 轮换
2. 会话吊销
3. 手机验证码登录
微信登录继续按“暂缓执行”处理。

View File

@@ -4,6 +4,7 @@
## 文档列表 ## 文档列表
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md)`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract以及用户不存在时的 `401` 语义。
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。 - [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。 - [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。 - [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。

View File

@@ -30,6 +30,7 @@
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化 8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
9. 接入 `POST /api/auth/entry` 首版密码登录链路 9. 接入 `POST /api/auth/entry` 首版密码登录链路
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口 10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
11. 接入 `GET /api/auth/me` 当前用户查询链路
后续与本 crate 直接相关的任务包括: 后续与本 crate 直接相关的任务包括:
@@ -40,6 +41,7 @@
5. [x] 接入 `/healthz` 5. [x] 接入 `/healthz`
6. [x] 接入 `/api/auth/entry` 6. [x] 接入 `/api/auth/entry`
7. [x] 接入 `/api/assets/direct-upload-tickets` 7. [x] 接入 `/api/assets/direct-upload-tickets`
8. [x] 接入 `/api/auth/me`
当前 tracing 约定: 当前 tracing 约定:
@@ -99,3 +101,4 @@
3. 外部副作用通过 `platform-auth``platform-oss``platform-llm` 与各模块 crate 的应用层完成。 3. 外部副作用通过 `platform-auth``platform-oss``platform-llm` 与各模块 crate 的应用层完成。
4. 不把领域规则直接堆在 handler 中。 4. 不把领域规则直接堆在 handler 中。
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。 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, attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth, require_bearer_auth,
}, },
auth_me::auth_me,
error_middleware::normalize_error_response, error_middleware::normalize_error_response,
health::health_check, health::health_check,
password_entry::password_entry, password_entry::password_entry,
@@ -46,6 +47,13 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token, attach_refresh_session_token,
)), )),
) )
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/assets/direct-upload-tickets", "/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket), post(create_direct_upload_ticket),
@@ -506,4 +514,106 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST); 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_secure: bool,
pub refresh_cookie_same_site: String, pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32, pub refresh_session_ttl_days: u32,
pub sms_auth_enabled: bool,
pub wechat_auth_enabled: bool,
pub oss_bucket: Option<String>, pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>, pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>, pub oss_access_key_id: Option<String>,
@@ -38,6 +40,8 @@ impl Default for AppConfig {
refresh_cookie_secure: false, refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(), refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30, refresh_session_ttl_days: 30,
sms_auth_enabled: false,
wechat_auth_enabled: false,
oss_bucket: None, oss_bucket: None,
oss_endpoint: None, oss_endpoint: None,
oss_access_key_id: None, oss_access_key_id: None,
@@ -115,6 +119,14 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days; 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_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]); 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"]); 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 app;
mod assets; mod assets;
mod auth; mod auth;
mod auth_me;
mod config; mod config;
mod error_middleware; mod error_middleware;
mod health; mod health;

View File

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

View File

@@ -49,6 +49,11 @@ pub struct PasswordEntryResult {
pub created: bool, pub created: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError { pub enum PasswordEntryError {
InvalidUsername, 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 { impl Default for InMemoryPasswordUserStore {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -198,6 +214,22 @@ impl InMemoryPasswordUserStore {
Ok(user) 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)] #[derive(Debug, PartialEq, Eq)]