feat: add dev password auto registration
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-04-28 14:46:18 +08:00
parent fb965a1207
commit 1d319ba916
6 changed files with 175 additions and 8 deletions

View File

@@ -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 和短信签名/模板。

View File

@@ -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. 完成定义

View File

@@ -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");

View File

@@ -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;

View File

@@ -26,14 +26,19 @@ pub async fn password_entry(
headers: HeaderMap,
Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> {
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

View File

@@ -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<PasswordEntryResult, PasswordEntryError> {
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<AuthUser, PasswordEntryError> {
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();