386 lines
11 KiB
Rust
386 lines
11 KiB
Rust
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 struct AuthMeResult {
|
|
pub user: AuthUser,
|
|
}
|
|
|
|
#[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 PasswordEntryService {
|
|
pub fn get_user_by_id(
|
|
&self,
|
|
user_id: &str,
|
|
) -> Result<Option<AuthMeResult>, PasswordEntryError> {
|
|
self.store
|
|
.find_by_user_id(user_id)
|
|
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn find_by_user_id(
|
|
&self,
|
|
user_id: &str,
|
|
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
|
let state = self
|
|
.inner
|
|
.lock()
|
|
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
|
|
|
Ok(state
|
|
.users_by_username
|
|
.values()
|
|
.find(|stored_user| stored_user.user.id == user_id)
|
|
.cloned())
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|