feat: add password entry auth flow

This commit is contained in:
2026-04-21 14:50:42 +08:00
parent 5675c40119
commit c23088539e
18 changed files with 1146 additions and 25 deletions

View File

@@ -0,0 +1,11 @@
[package]
name = "module-auth"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
platform-auth = { path = "../platform-auth" }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View File

@@ -1,28 +1,34 @@
# module-auth 独立模块 crate 占位说明
# module-auth 鉴权模块 crate 说明
日期:`2026-04-20`
日期:`2026-04-21`
## 1. crate 职责
`module-auth` 是鉴权与会话模块 crate后续负责
`module-auth` 是鉴权与会话模块 crate当前与后续负责:
1. 用户身份、会话、风控、审计相关领域模型
2. 手机验证码、微信登录、密码登录的模块内用例编排
3.`crates/api-server` 的鉴权接口装配对接
4.`crates/spacetime-module` 的身份表、会话表聚合对接
1. 用户身份、会话、风控、审计相关领域模型
2. 手机验证码、微信登录、密码登录的模块内用例编排
3.`crates/api-server` 的鉴权接口装配对接
4.`crates/spacetime-module` 的身份表、会话表聚合对接
## 2. 当前阶段说明
当前阶段已冻结前七张鉴权基础表设计,剩余重点收口在 JWT claims、refresh cookie 与旧接口兼容细节。
当前阶段已冻结前七张鉴权基础表设计,并已完成:
后续与本 crate 直接相关的任务包括:
1. JWT claims 设计与 `platform-auth` 落地。
2. refresh cookie 读取适配。
3. `module-auth` 真实 crate 与首版密码登录用例落地。
4. 微信登录链路暂缓执行,不进入当前连续实现顺序。
1. 设计 `user_account``auth_identity``refresh_session`
2. 设计 `auth_audit_log``auth_risk_block`
3. 设计 `sms_auth_event``wechat_auth_state`
4. 落地 JWT claims、refresh cookie 与旧接口兼容
当前连续实现优先顺序固定为:
当前已冻结文档:
1. 密码登录
2. `me` 查询
3. refresh token 轮换
4. 会话吊销
5. 手机验证码登录
## 3. 当前已冻结文档
1. [../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
@@ -32,9 +38,14 @@
6. [../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
7. [../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
8. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
9. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
10. [../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
## 3. 边界约束
## 4. 边界约束
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
2. 短信、微信、JWT、Cookie 等平台适配优先通过 `crates/platform-auth` 承接。
2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。

View File

@@ -0,0 +1,353 @@
use std::{
collections::HashMap,
error::Error,
fmt,
sync::{Arc, Mutex},
};
use platform_auth::{hash_password, verify_password};
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
const PASSWORD_MIN_LENGTH: usize = 6;
const PASSWORD_MAX_LENGTH: usize = 128;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthUser {
pub id: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidUsername,
InvalidPasswordLength,
InvalidCredentials,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug)]
pub struct InMemoryPasswordUserStore {
inner: Arc<Mutex<InMemoryPasswordUserStoreState>>,
}
#[derive(Debug)]
struct InMemoryPasswordUserStoreState {
next_id: u64,
users_by_username: HashMap<String, StoredPasswordUser>,
}
#[derive(Clone, Debug)]
struct StoredPasswordUser {
user: AuthUser,
password_hash: String,
}
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryPasswordUserStore,
}
impl PasswordEntryService {
pub fn new(store: InMemoryPasswordUserStore) -> Self {
Self { store }
}
pub async fn execute(
&self,
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
let username = normalize_username(&input.username)?;
validate_password(&input.password)?;
if let Some(existing_user) = self.store.find_by_username(&username)? {
let is_valid = verify_password(&existing_user.password_hash, &input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
return Ok(PasswordEntryResult {
user: existing_user.user,
created: false,
});
}
let password_hash = hash_password(&input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
match self
.store
.create_user(username.clone(), password_hash.clone())
{
Ok(user) => Ok(PasswordEntryResult {
user,
created: true,
}),
Err(CreateUserError::AlreadyExists) => {
let existing_user = self.store.find_by_username(&username)?.ok_or_else(|| {
PasswordEntryError::Store("唯一键冲突后未能重新读取账号".to_string())
})?;
let is_valid = verify_password(&existing_user.password_hash, &input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
Ok(PasswordEntryResult {
user: existing_user.user,
created: false,
})
}
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
}
}
}
impl Default for InMemoryPasswordUserStore {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(InMemoryPasswordUserStoreState {
next_id: 1,
users_by_username: HashMap::new(),
})),
}
}
}
impl InMemoryPasswordUserStore {
fn find_by_username(
&self,
username: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state.users_by_username.get(username).cloned())
}
fn create_user(
&self,
username: String,
password_hash: String,
) -> Result<AuthUser, CreateUserError> {
let mut state = self
.inner
.lock()
.map_err(|_| CreateUserError::Store("用户仓储锁已中毒".to_string()))?;
if state.users_by_username.contains_key(&username) {
return Err(CreateUserError::AlreadyExists);
}
let user_id = format!("user_{:08}", state.next_id);
state.next_id += 1;
let user = AuthUser {
id: user_id,
username: username.clone(),
display_name: username.clone(),
phone_number_masked: None,
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
};
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
},
);
Ok(user)
}
}
#[derive(Debug, PartialEq, Eq)]
enum CreateUserError {
AlreadyExists,
Store(String),
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PasswordEntryError {}
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
let username = raw_username.trim().to_string();
let valid_length =
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
let valid_chars = username
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_');
if !valid_length || !valid_chars {
return Err(PasswordEntryError::InvalidUsername);
}
Ok(username)
}
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn build_service() -> PasswordEntryService {
PasswordEntryService::new(InMemoryPasswordUserStore::default())
}
#[tokio::test]
async fn first_password_entry_creates_user() {
let service = build_service();
let result = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("first login should succeed");
assert!(result.created);
assert_eq!(result.user.id, "user_00000001");
assert_eq!(result.user.username, "guest_001");
assert_eq!(result.user.display_name, "guest_001");
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
assert_eq!(result.user.binding_status, AuthBindingStatus::Active);
}
#[tokio::test]
async fn repeated_password_entry_reuses_same_user() {
let service = build_service();
let first = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("first login should succeed");
let second = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("second login should succeed");
assert!(first.created);
assert!(!second.created);
assert_eq!(second.user.id, first.user.id);
}
#[tokio::test]
async fn repeated_password_entry_rejects_wrong_password() {
let service = build_service();
service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("first login should succeed");
let error = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret999".to_string(),
})
.await
.expect_err("wrong password should fail");
assert_eq!(error, PasswordEntryError::InvalidCredentials);
}
#[tokio::test]
async fn invalid_username_returns_bad_request_error() {
let service = build_service();
let error = service
.execute(PasswordEntryInput {
username: "坏用户名".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("invalid username should fail");
assert_eq!(error, PasswordEntryError::InvalidUsername);
}
}