feat: add password entry auth flow
This commit is contained in:
11
server-rs/crates/module-auth/Cargo.toml
Normal file
11
server-rs/crates/module-auth/Cargo.toml
Normal 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"] }
|
||||
@@ -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` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||
|
||||
353
server-rs/crates/module-auth/src/lib.rs
Normal file
353
server-rs/crates/module-auth/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user