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 267b0f68..c33b796c 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -217,7 +217,8 @@ ### 当前接口兼容 -- [ ] 兼容 `/api/auth/login-options` +- [x] 兼容 `/api/auth/login-options` + 交付物:[../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/login_options.rs](../server-rs/crates/api-server/src/login_options.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [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) - [x] 兼容 `/api/auth/me` @@ -250,4 +251,4 @@ - [ ] 手机验证码主链可用 - [ ] 微信登录主链可用 说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。 -- [ ] 所有旧鉴权接口可通过 contract 回归 +- [ ] 所有旧鉴权接口可通过 contract 回归 \ No newline at end of file diff --git a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md new file mode 100644 index 00000000..712c3f4f --- /dev/null +++ b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md @@ -0,0 +1,102 @@ +# `/api/auth/login-options` 登录方式选项设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于冻结 Rust `api-server` 首版 `GET /api/auth/login-options` 的返回 contract、配置来源与当前阶段边界,确保前端在登录页读取“当前可用登录方式”时,不需要依赖硬编码开关。 + +## 2. 当前目标 + +当前阶段只解决一件事: + +1. 由 `Axum` 根据服务端配置,返回当前环境启用的登录方式列表。 + +本阶段明确不包含: + +1. 短信或微信登录链路本身是否已经完整落地 +2. 对前端返回更细粒度的 provider 配置 +3. 第三方登录按钮文案、图标或 UI 布局规则 + +## 3. 接口 contract + +### 3.1 请求 + +1. 方法:`GET` +2. 路径:`/api/auth/login-options` +3. 鉴权:不需要 +4. 请求体:空 + +### 3.2 成功响应 + +```json +{ + "availableLoginMethods": ["phone", "wechat"] +} +``` + +字段说明: + +1. `availableLoginMethods` 为字符串数组 +2. 当前阶段只允许出现: + - `phone` + - `wechat` + +### 3.3 返回顺序 + +返回顺序固定为: + +1. 先 `phone` +2. 再 `wechat` + +这样可以保证前端按钮顺序稳定,不因配置解析顺序变化而漂移。 + +## 4. 配置来源 + +`api-server` 只读取以下布尔配置: + +1. `SMS_AUTH_ENABLED` +2. `WECHAT_AUTH_ENABLED` + +映射规则固定为: + +1. `SMS_AUTH_ENABLED=true` 时返回 `phone` +2. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat` +3. 两者都关闭时返回空数组 + +## 5. crate 边界 + +### 5.1 `api-server` + +负责: + +1. 读取 `AppState.config` +2. 组装 `availableLoginMethods` +3. 返回项目兼容的响应 envelope + +### 5.2 `module-auth` + +本接口当前阶段不依赖 `module-auth`。 + +### 5.3 前端 + +负责: + +1. 根据 `availableLoginMethods` 决定是否展示手机号 / 微信入口 +2. 不再假设某种登录方式一定存在 + +## 6. 测试要求 + +至少覆盖: + +1. 默认配置下返回空数组 +2. 同时启用短信与微信时返回 `["phone", "wechat"]` + +## 7. 完成定义 + +满足以下条件时,本任务视为完成: + +1. Rust 已提供 `GET /api/auth/login-options` +2. 响应字段命名与前端约定一致 +3. 配置开关可稳定映射到返回数组 +4. 文档、任务清单与测试已同步更新 diff --git a/docs/technical/README.md b/docs/technical/README.md index 030c9211..9a12d56d 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md):`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。 - [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` 语义。 - [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md):`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。 - [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。 @@ -43,4 +44,4 @@ ## 使用建议 - 做实现选型时,优先看这一组。 -- 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 +- 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 \ No newline at end of file diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 9bec59bb..e07120c1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -19,6 +19,7 @@ use crate::{ auth_sessions::auth_sessions, error_middleware::normalize_error_response, health::health_check, + login_options::auth_login_options, logout::logout, logout_all::logout_all, password_entry::password_entry, @@ -51,6 +52,10 @@ pub fn build_router(state: AppState) -> Router { attach_refresh_session_token, )), ) + .route( + "/api/auth/login-options", + get(auth_login_options), + ) .route( "/api/auth/me", get(auth_me).route_layer(middleware::from_fn_with_state( @@ -438,6 +443,42 @@ mod tests { assert!(payload["token"].as_str().is_some()); } + #[tokio::test] + async fn auth_login_options_returns_enabled_methods_in_stable_order() { + let config = AppConfig { + sms_auth_enabled: true, + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/login-options") + .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["availableLoginMethods"], + serde_json::json!(["phone", "wechat"]) + ); + } + #[tokio::test] async fn auth_sessions_returns_multi_device_session_fields() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1238,4 +1279,4 @@ mod tests { .is_some_and(|value| value.contains("Max-Age=0")) ); } -} +} \ No newline at end of file diff --git a/server-rs/crates/api-server/src/login_options.rs b/server-rs/crates/api-server/src/login_options.rs new file mode 100644 index 00000000..8595124d --- /dev/null +++ b/server-rs/crates/api-server/src/login_options.rs @@ -0,0 +1,33 @@ +use axum::{ + Json, + extract::{Extension, State}, +}; +use serde::Serialize; + +use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthLoginOptionsResponse { + pub available_login_methods: Vec<&'static str>, +} + +pub async fn auth_login_options( + State(state): State, + Extension(request_context): Extension, +) -> Json { + let mut methods = Vec::new(); + if state.config.sms_auth_enabled { + methods.push("phone"); + } + if state.config.wechat_auth_enabled { + methods.push("wechat"); + } + + json_success_body( + Some(&request_context), + AuthLoginOptionsResponse { + available_login_methods: methods, + }, + ) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 445a5a9c..f204e3e0 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -9,6 +9,7 @@ mod config; mod error_middleware; mod health; mod http_error; +mod login_options; mod logout; mod logout_all; mod password_entry; @@ -44,4 +45,4 @@ async fn main() -> Result<(), std::io::Error> { info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听"); axum::serve(listener, router).await -} +} \ No newline at end of file