diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 62643350..25233171 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -170,7 +170,8 @@ - [ ] 实现 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` - [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) -- [ ] 兼容 `/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-all` - [ ] 兼容 `/api/auth/refresh` diff --git a/docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md b/docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md new file mode 100644 index 00000000..8bd42d58 --- /dev/null +++ b/docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md @@ -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 ` + +### 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. 手机验证码登录 + +微信登录继续按“暂缓执行”处理。 diff --git a/docs/technical/README.md b/docs/technical/README.md index e037396b..38e9c652 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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 的衔接方式。 - [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 中间件与内部验收路由边界。 diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 7a5f5300..1be32051 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -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` 用户快照查询,不直接绕过模块边界读取内部状态。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c81c59f2..03c80afc 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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); + } } diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs new file mode 100644 index 00000000..ad68a5c1 --- /dev/null +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -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, + pub login_method: &'static str, + pub binding_status: &'static str, + pub wechat_bound: bool, +} + +pub async fn auth_me( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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 +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 87be9d0d..12c90df4 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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, pub oss_endpoint: Option, pub oss_access_key_id: Option, @@ -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"]); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index adedcd28..b43c6bcb 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -2,6 +2,7 @@ mod api_response; mod app; mod assets; mod auth; +mod auth_me; mod config; mod error_middleware; mod health; diff --git a/server-rs/crates/module-auth/README.md b/server-rs/crates/module-auth/README.md index bbbbccbb..51cabdd9 100644 --- a/server-rs/crates/module-auth/README.md +++ b/server-rs/crates/module-auth/README.md @@ -49,3 +49,4 @@ 3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。 4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。 5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。 +6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。 diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 64231135..a9f77c0d 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -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, 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, 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)]