diff --git a/.env.example b/.env.example index 1f06973c..879be395 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax" AUTH_REFRESH_COOKIE_SECURE="false" # Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。 GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" +# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。 +GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false" # 手机号验证码登录配置(阿里云 PNVS)。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。 diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index 6f45b1f1..fdfb33ea 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,6 +1,8 @@ # 密码登录入口历史落地设计 > 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 +> +> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。 日期:`2026-04-21` @@ -166,6 +168,13 @@ 2. 不创建账号。 3. 不写 `password_hash`。 +开发期例外: + +1. 当 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 时,未知手机号会创建手机号账号。 +2. 新账号立即写入本次密码的 `password_hash`,并将 `password_login_enabled` 置为 `true`。 +3. 成功响应沿用密码登录响应体,`created` 只保留在领域结果中,不额外暴露到当前 HTTP contract。 +4. 手机号格式和密码长度校验仍完全沿用正式密码入口规则。 + ### 8.2 未设置密码 当账号存在但 `password_login_enabled = false` 时: @@ -233,6 +242,8 @@ 4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。 5. 登录成功时返回 access token。 6. 登录成功时写回 refresh cookie。 +7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。 +8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`。 ## 13. 完成定义 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8cc51fb1..3d3fc35a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1382,6 +1382,36 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() { + let config = AppConfig { + dev_password_entry_auto_register_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let first_response = + password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await; + let first_status = first_response.status(); + let first_body = first_response + .into_body() + .collect() + .await + .expect("first response body should collect") + .to_bytes(); + let first_payload: Value = + serde_json::from_slice(&first_body).expect("first response body should be valid json"); + let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await; + + assert_eq!(first_status, StatusCode::OK); + assert!(first_payload["token"].as_str().is_some()); + assert_eq!( + first_payload["user"]["loginMethod"], + Value::String("password".to_string()) + ); + assert_eq!(second_response.status(), StatusCode::OK); + } + #[tokio::test] async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e2c497d5..deb69712 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -29,6 +29,7 @@ pub struct AppConfig { pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, pub auth_store_path: PathBuf, + pub dev_password_entry_auto_register_enabled: bool, pub sms_auth_enabled: bool, pub sms_auth_provider: String, pub sms_endpoint: String, @@ -118,6 +119,7 @@ impl Default for AppConfig { refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), + dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), @@ -273,6 +275,11 @@ impl AppConfig { if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { config.auth_store_path = PathBuf::from(auth_store_path); } + if let Some(enabled) = + read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) + { + config.dev_password_entry_auto_register_enabled = enabled; + } if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 743adcf3..52bbd3a3 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -26,14 +26,19 @@ pub async fn password_entry( headers: HeaderMap, Json(payload): Json, ) -> Result { - let result = state - .password_entry_service() - .execute(PasswordEntryInput { - phone_number: payload.phone, - password: payload.password, - }) - .await - .map_err(map_password_entry_error)?; + let input = PasswordEntryInput { + phone_number: payload.phone, + password: payload.password, + }; + let result = if state.config.dev_password_entry_auto_register_enabled { + state + .password_entry_service() + .execute_with_dev_registration(input) + .await + } else { + state.password_entry_service().execute(input).await + } + .map_err(map_password_entry_error)?; let session_client = resolve_session_client_context(&headers); let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; state diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 1952d725..57005c54 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -486,6 +486,38 @@ impl PasswordEntryService { verify_stored_password_user(existing_user, &input.password).await } + pub async fn execute_with_dev_registration( + &self, + input: PasswordEntryInput, + ) -> Result { + validate_password(&input.password)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) + .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + if let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + { + return verify_stored_password_user(existing_user, &input.password).await; + } + + let password_hash = hash_password(&input.password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self.store.create_dev_password_phone_user( + normalized_phone.clone(), + normalized_phone.masked_national_number, + password_hash, + )?; + + Ok(PasswordEntryResult { + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..user + }, + created: true, + }) + } + pub fn get_user_by_id( &self, user_id: &str, @@ -1336,6 +1368,53 @@ impl InMemoryAuthStore { Ok(user) } + fn create_dev_password_phone_user( + &self, + phone_number: PhoneNumberSnapshot, + display_name: String, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if state.phone_to_user_id.contains_key(&phone_number.e164) { + return Err(PasswordEntryError::InvalidCredentials); + } + + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + token_version: 1, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: true, + phone_number: Some(phone_number.e164), + }, + ); + self.persist_password_state(&state)?; + + Ok(user) + } + fn create_pending_wechat_user( &self, profile: WechatIdentityProfile, @@ -2474,6 +2553,39 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidCredentials); } + #[tokio::test] + async fn password_entry_dev_registration_creates_unknown_phone_user() { + let service = build_password_service(build_store()); + + let created = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user"); + let reused = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("same password should reuse created user"); + let wrong_password = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret999".to_string(), + }) + .await + .expect_err("existing user still requires the right password"); + + assert!(created.created); + assert_eq!(created.user.login_method, AuthLoginMethod::Password); + assert!(!reused.created); + assert_eq!(created.user.id, reused.user.id); + assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); + } + #[tokio::test] async fn phone_user_can_set_password_then_login() { let store = build_store();