US-002: 用户模型与认证模块

- src/models/user.rs: User 结构体 + UserRole 枚举 (admin/blocked)
- src/models/session.rs: Session 结构体
- src/auth/password.rs: argon2 密码哈希和验证
- src/auth/session.rs: UUID v4 token 生成、30天过期、过期检查

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-25 16:17:27 +08:00
parent 031a7aa0f2
commit a767f81d21
7 changed files with 93 additions and 8 deletions

View File

@ -20,7 +20,7 @@
"src/db/pool.rs",
"migrations/001_init.sql"
],
"passes": false
"passes": true
},
{
"id": "US-002",
@ -48,7 +48,7 @@
"title": "认证 API (Server Functions)",
"description": "实现 register, login, logout, get_current_user 四个 Dioxus server function",
"acceptanceCriteria": [
"register(): 输入验证(用户名3-50字符/邮箱格式/密码8位)首个用户role=admin后续返回'Registration is closed'",
"register(): 输入验证(用户名3-50字符/邮箱格式/密码>=8位)首个用户role=admin后续返回'Registration is closed'",
"login(): 验证密码创建30天过期session设置HttpOnly+SameSite=Lax cookie",
"logout(): 删除session行清除cookie",
"get_current_user(): 从cookie读取token返回Option<User>",
@ -105,12 +105,12 @@
"description": "端到端验证所有功能",
"acceptanceCriteria": [
"启动PostgreSQL运行migration",
"注册首个用户 role=admin",
"再次注册 收到'Registration is closed'",
"登录 设置cookie跳转/admin",
"关闭浏览器重开/admin 无需重新登录",
"登出 cookie清除/admin重定向到/login",
"错误密码 显示'Invalid credentials'",
"注册首个用户 -> role=admin",
"再次注册 -> 收到'Registration is closed'",
"登录 -> 设置cookie跳转/admin",
"关闭浏览器重开/admin -> 无需重新登录",
"登出 -> cookie清除/admin重定向到/login",
"错误密码 -> 显示'Invalid credentials'",
"主题切换正常"
],
"filesExpected": [],

2
src/auth/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod password;
pub mod session;

21
src/auth/password.rs Normal file
View File

@ -0,0 +1,21 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?;
Ok(password_hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
let parsed_hash = PasswordHash::new(hash)?;
let argon2 = Argon2::default();
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true),
Err(argon2::password_hash::Error::Password) => Ok(false),
Err(e) => Err(e),
}
}

14
src/auth/session.rs Normal file
View File

@ -0,0 +1,14 @@
use chrono::{DateTime, Duration, Utc};
use uuid::Uuid;
pub fn generate_token() -> String {
Uuid::new_v4().to_string()
}
pub fn default_expiry() -> DateTime<Utc> {
Utc::now() + Duration::days(30)
}
pub fn is_expired(expires_at: DateTime<Utc>) -> bool {
Utc::now() > expires_at
}

2
src/models/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod session;
pub mod user;

11
src/models/session.rs Normal file
View File

@ -0,0 +1,11 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: i32,
pub user_id: i32,
pub token: String,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}

35
src/models/user.rs Normal file
View File

@ -0,0 +1,35 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UserRole {
Admin,
Blocked,
}
impl UserRole {
pub fn as_str(&self) -> &'static str {
match self {
UserRole::Admin => "admin",
UserRole::Blocked => "blocked",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"admin" => Some(UserRole::Admin),
"blocked" => Some(UserRole::Blocked),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub password_hash: String,
pub role: UserRole,
pub created_at: DateTime<Utc>,
}