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

@@ -1,10 +1,13 @@
use std::{collections::HashSet, error::Error, fmt};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
@@ -98,6 +101,12 @@ pub enum RefreshCookieError {
InvalidConfig(&'static str),
}
#[derive(Debug, PartialEq, Eq)]
pub enum PasswordHashError {
HashFailed(String),
VerifyFailed(String),
}
impl JwtConfig {
pub fn new(
issuer: String,
@@ -370,6 +379,53 @@ pub fn read_refresh_session_token(
None
}
pub async fn hash_password(password: &str) -> Result<String, PasswordHashError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|error| PasswordHashError::HashFailed(format!("密码哈希失败:{error}")))
}
pub async fn verify_password(
password_hash: &str,
password: &str,
) -> Result<bool, PasswordHashError> {
let parsed_hash = PasswordHash::new(password_hash)
.map_err(|error| PasswordHashError::VerifyFailed(format!("密码哈希格式非法:{error}")))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
pub fn create_refresh_session_token() -> String {
Uuid::new_v4().simple().to_string()
}
pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfig) -> String {
let mut parts = vec![
format!(
"{}={}",
config.cookie_name(),
urlencoding::encode(token).into_owned()
),
format!("Path={}", config.cookie_path()),
"HttpOnly".to_string(),
format!("SameSite={}", config.cookie_same_site().as_str()),
format!(
"Max-Age={}",
u64::from(config.refresh_session_ttl_days()) * 24 * 60 * 60
),
];
if config.cookie_secure() {
parts.push("Secure".to_string());
}
parts.join("; ")
}
impl fmt::Display for JwtError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -391,6 +447,16 @@ impl fmt::Display for RefreshCookieError {
impl Error for RefreshCookieError {}
impl fmt::Display for PasswordHashError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HashFailed(message) | Self::VerifyFailed(message) => f.write_str(message),
}
}
}
impl Error for PasswordHashError {}
fn normalize_required_field(
value: String,
error_message: &'static str,
@@ -560,4 +626,29 @@ mod tests {
assert!(token.is_none());
}
#[tokio::test]
async fn hash_and_verify_password_round_trip() {
let password_hash = hash_password("secret123")
.await
.expect("password hash should build");
let is_valid = verify_password(&password_hash, "secret123")
.await
.expect("password hash should verify");
assert!(is_valid);
}
#[test]
fn build_refresh_session_cookie_respects_config() {
let cookie =
build_refresh_session_set_cookie("refresh/token=01", &build_refresh_cookie_config());
assert!(cookie.contains("genarrative_refresh_session=refresh%2Ftoken%3D01"));
assert!(cookie.contains("Path=/api/auth"));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Lax"));
assert!(cookie.contains("Max-Age=2592000"));
}
}