Files
Genarrative/server-rs/crates/platform-auth/src/lib.rs
2026-04-22 12:34:49 +08:00

673 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 sha2::{Digest, Sha256};
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
use time::{Duration, OffsetDateTime};
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
Password,
Phone,
Wechat,
}
// 绑定状态只保留当前 JWT 需要透传的最小快照,不把完整账号状态枚举直接泄漏到 token 中。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BindingStatus {
Active,
PendingBindPhone,
}
// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AccessTokenClaimsInput {
pub user_id: String,
pub session_id: String,
pub provider: AuthProvider,
pub roles: Vec<String>,
pub token_version: u64,
pub phone_verified: bool,
pub binding_status: BindingStatus,
pub display_name: Option<String>,
}
// 直接映射最终 JWT payload字段名与文档冻结口径保持一致。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessTokenClaims {
pub iss: String,
pub sub: String,
pub sid: String,
pub provider: AuthProvider,
pub roles: Vec<String>,
pub ver: u64,
pub phone_verified: bool,
pub binding_status: BindingStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub iat: u64,
pub exp: u64,
}
// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JwtConfig {
issuer: String,
secret: String,
access_token_ttl_seconds: u64,
}
// refresh cookie 的 SameSite 固定约束成枚举,避免各层直接使用大小写不一致的字符串。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshCookieSameSite {
Lax,
Strict,
None,
}
// refresh cookie 的平台配置统一收口到 platform-auth避免 api-server 直接散落 cookie 细节。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefreshCookieConfig {
cookie_name: String,
cookie_path: String,
cookie_secure: bool,
cookie_same_site: RefreshCookieSameSite,
refresh_session_ttl_days: u32,
}
#[derive(Debug, PartialEq, Eq)]
pub enum JwtError {
InvalidConfig(&'static str),
InvalidClaims(&'static str),
SignFailed(String),
VerifyFailed(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum RefreshCookieError {
InvalidConfig(&'static str),
}
#[derive(Debug, PartialEq, Eq)]
pub enum PasswordHashError {
HashFailed(String),
VerifyFailed(String),
}
impl JwtConfig {
pub fn new(
issuer: String,
secret: String,
access_token_ttl_seconds: u64,
) -> Result<Self, JwtError> {
let issuer = normalize_required_string(&issuer)
.ok_or(JwtError::InvalidConfig("JWT issuer 不能为空"))?;
let secret = normalize_required_string(&secret)
.ok_or(JwtError::InvalidConfig("JWT secret 不能为空"))?;
if access_token_ttl_seconds == 0 {
return Err(JwtError::InvalidConfig(
"JWT access token 过期时间必须大于 0",
));
}
Ok(Self {
issuer,
secret,
access_token_ttl_seconds,
})
}
pub fn issuer(&self) -> &str {
&self.issuer
}
pub fn access_token_ttl_seconds(&self) -> u64 {
self.access_token_ttl_seconds
}
}
impl RefreshCookieSameSite {
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"lax" => Some(Self::Lax),
"strict" => Some(Self::Strict),
"none" => Some(Self::None),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Lax => "Lax",
Self::Strict => "Strict",
Self::None => "None",
}
}
}
impl RefreshCookieConfig {
pub fn new(
cookie_name: String,
cookie_path: String,
cookie_secure: bool,
cookie_same_site: RefreshCookieSameSite,
refresh_session_ttl_days: u32,
) -> Result<Self, RefreshCookieError> {
let cookie_name = normalize_required_string(&cookie_name).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie 名称不能为空"),
)?;
let cookie_path = normalize_required_string(&cookie_path).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie path 不能为空"),
)?;
if refresh_session_ttl_days == 0 {
return Err(RefreshCookieError::InvalidConfig(
"refresh session TTL 天数必须大于 0",
));
}
Ok(Self {
cookie_name,
cookie_path,
cookie_secure,
cookie_same_site,
refresh_session_ttl_days,
})
}
pub fn cookie_name(&self) -> &str {
&self.cookie_name
}
pub fn cookie_path(&self) -> &str {
&self.cookie_path
}
pub fn cookie_secure(&self) -> bool {
self.cookie_secure
}
pub fn cookie_same_site(&self) -> &RefreshCookieSameSite {
&self.cookie_same_site
}
pub fn refresh_session_ttl_days(&self) -> u32 {
self.refresh_session_ttl_days
}
}
impl AccessTokenClaims {
pub fn from_input(
input: AccessTokenClaimsInput,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?;
let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?;
let roles = normalize_roles(input.roles)?;
let display_name = normalize_optional_field(input.display_name);
let issued_at_unix = issued_at.unix_timestamp();
if issued_at_unix < 0 {
return Err(JwtError::InvalidClaims("JWT iat 不能早于 Unix epoch"));
}
let expires_at = issued_at
.checked_add(Duration::seconds(
i64::try_from(config.access_token_ttl_seconds()).map_err(|_| {
JwtError::InvalidConfig("JWT access token 过期时间超出 i64 上限")
})?,
))
.ok_or(JwtError::InvalidConfig("JWT 过期时间计算溢出"))?;
let expires_at_unix = expires_at.unix_timestamp();
if expires_at_unix <= issued_at_unix {
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
}
let claims = Self {
iss: config.issuer().to_string(),
sub: user_id,
sid: session_id,
provider: input.provider,
roles,
ver: input.token_version,
phone_verified: input.phone_verified,
binding_status: input.binding_status,
display_name,
iat: issued_at_unix as u64,
exp: expires_at_unix as u64,
};
claims.validate_for_config(config)?;
Ok(claims)
}
pub fn user_id(&self) -> &str {
&self.sub
}
pub fn session_id(&self) -> &str {
&self.sid
}
pub fn token_version(&self) -> u64 {
self.ver
}
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
if self.iss.trim() != config.issuer() {
return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致"));
}
normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?;
normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?;
normalize_roles(self.roles.clone())?;
if self.exp <= self.iat {
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
}
Ok(())
}
}
pub fn sign_access_token(
claims: &AccessTokenClaims,
config: &JwtConfig,
) -> Result<String, JwtError> {
claims.validate_for_config(config)?;
let header = Header {
alg: ACCESS_TOKEN_ALGORITHM,
typ: Some("JWT".to_string()),
..Header::default()
};
encode(
&header,
claims,
&EncodingKey::from_secret(config.secret.as_bytes()),
)
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
}
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
let token = token.trim();
if token.is_empty() {
return Err(JwtError::VerifyFailed("JWT 不能为空".to_string()));
}
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
validation.required_spec_claims = HashSet::from([
"exp".to_string(),
"iat".to_string(),
"iss".to_string(),
"sub".to_string(),
]);
validation.set_issuer(&[config.issuer()]);
let decoded = decode::<AccessTokenClaims>(
token,
&DecodingKey::from_secret(config.secret.as_bytes()),
&validation,
)
.map_err(map_verify_error)?;
decoded.claims.validate_for_config(config)?;
Ok(decoded.claims)
}
pub fn read_refresh_session_token(
cookie_header: &str,
config: &RefreshCookieConfig,
) -> Option<String> {
let cookie_header = cookie_header.trim();
if cookie_header.is_empty() {
return None;
}
for entry in cookie_header.split(';') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (raw_name, raw_value) = entry.split_once('=')?;
if raw_name.trim() != config.cookie_name() {
continue;
}
let raw_value = raw_value.trim();
if raw_value.is_empty() {
return None;
}
return urlencoding::decode(raw_value)
.ok()
.map(|decoded| decoded.into_owned());
}
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 {
new_uuid_simple_string()
}
pub fn hash_refresh_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}
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("; ")
}
pub fn build_refresh_session_clear_cookie(config: &RefreshCookieConfig) -> String {
let mut parts = vec![
format!("{}=", config.cookie_name()),
format!("Path={}", config.cookie_path()),
"HttpOnly".to_string(),
format!("SameSite={}", config.cookie_same_site().as_str()),
"Max-Age=0".to_string(),
];
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 {
Self::InvalidConfig(message) | Self::InvalidClaims(message) => f.write_str(message),
Self::SignFailed(message) | Self::VerifyFailed(message) => f.write_str(message),
}
}
}
impl Error for JwtError {}
impl fmt::Display for RefreshCookieError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message) => f.write_str(message),
}
}
}
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,
) -> Result<String, JwtError> {
normalize_required_string(&value).ok_or(JwtError::InvalidClaims(error_message))
}
fn normalize_optional_field(value: Option<String>) -> Option<String> {
normalize_optional_string(value)
}
fn normalize_roles(roles: Vec<String>) -> Result<Vec<String>, JwtError> {
let roles = roles
.into_iter()
.map(|role| role.trim().to_string())
.filter(|role| !role.is_empty())
.collect::<Vec<_>>();
if roles.is_empty() {
return Err(JwtError::InvalidClaims("JWT roles 至少包含一个角色"));
}
Ok(roles)
}
fn map_verify_error(error: jsonwebtoken::errors::Error) -> JwtError {
let message = match error.kind() {
ErrorKind::ExpiredSignature => "JWT 已过期".to_string(),
ErrorKind::InvalidIssuer => "JWT 发行者不匹配".to_string(),
ErrorKind::InvalidSignature => "JWT 签名无效".to_string(),
ErrorKind::InvalidAlgorithm => "JWT 算法不匹配".to_string(),
ErrorKind::InvalidToken => "JWT 非法".to_string(),
ErrorKind::ImmatureSignature => "JWT 尚未生效".to_string(),
ErrorKind::MissingRequiredClaim(claim) => format!("JWT 缺少必填字段:{claim}"),
_ => format!("JWT 校验失败:{error}"),
};
JwtError::VerifyFailed(message)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_jwt_config() -> JwtConfig {
JwtConfig::new(
"https://auth.genarrative.local".to_string(),
"genarrative-dev-secret".to_string(),
DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
)
.expect("jwt config should be valid")
}
fn build_claims_input() -> AccessTokenClaimsInput {
AccessTokenClaimsInput {
user_id: "usr_123".to_string(),
session_id: "sess_456".to_string(),
provider: AuthProvider::Wechat,
roles: vec!["user".to_string()],
token_version: 3,
phone_verified: false,
binding_status: BindingStatus::PendingBindPhone,
display_name: Some("微信旅人".to_string()),
}
}
fn build_refresh_cookie_config() -> RefreshCookieConfig {
RefreshCookieConfig::new(
DEFAULT_REFRESH_COOKIE_NAME.to_string(),
DEFAULT_REFRESH_COOKIE_PATH.to_string(),
false,
RefreshCookieSameSite::Lax,
DEFAULT_REFRESH_SESSION_TTL_DAYS,
)
.expect("refresh cookie config should be valid")
}
#[test]
fn round_trip_sign_and_verify_access_token() {
let config = build_jwt_config();
let claims =
AccessTokenClaims::from_input(build_claims_input(), &config, OffsetDateTime::now_utc())
.expect("claims should build");
let token = sign_access_token(&claims, &config).expect("token should sign");
let verified = verify_access_token(&token, &config).expect("token should verify");
assert_eq!(verified, claims);
assert_eq!(verified.user_id(), "usr_123");
assert_eq!(verified.session_id(), "sess_456");
assert_eq!(verified.token_version(), 3);
}
#[test]
fn verify_rejects_invalid_issuer() {
let config = build_jwt_config();
let claims =
AccessTokenClaims::from_input(build_claims_input(), &config, OffsetDateTime::now_utc())
.expect("claims should build");
let token = sign_access_token(&claims, &config).expect("token should sign");
let other_config = JwtConfig::new(
"https://auth.other.local".to_string(),
"genarrative-dev-secret".to_string(),
DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
)
.expect("other config should be valid");
let error = verify_access_token(&token, &other_config).expect_err("issuer should mismatch");
assert_eq!(
error,
JwtError::VerifyFailed("JWT 发行者不匹配".to_string())
);
}
#[test]
fn build_claims_rejects_empty_roles() {
let error = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
roles: Vec::new(),
..build_claims_input()
},
&build_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect_err("empty roles should be rejected");
assert_eq!(error, JwtError::InvalidClaims("JWT roles 至少包含一个角色"));
}
#[test]
fn read_refresh_session_token_returns_matching_cookie() {
let token = read_refresh_session_token(
"theme=dark; genarrative_refresh_session=refresh-token-01; locale=zh-CN",
&build_refresh_cookie_config(),
);
assert_eq!(token.as_deref(), Some("refresh-token-01"));
}
#[test]
fn read_refresh_session_token_decodes_urlencoded_value() {
let token = read_refresh_session_token(
"genarrative_refresh_session=refresh%2Ftoken%3D01",
&build_refresh_cookie_config(),
);
assert_eq!(token.as_deref(), Some("refresh/token=01"));
}
#[test]
fn read_refresh_session_token_returns_none_when_missing() {
let token =
read_refresh_session_token("theme=dark; locale=zh-CN", &build_refresh_cookie_config());
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"));
}
#[test]
fn hash_refresh_session_token_matches_sha256_hex() {
let hash = hash_refresh_session_token("refresh-token-01");
assert_eq!(
hash,
"9fab76f9100ec6c151c8caa0c42ab10e10fbc7618f15e24cf3dffc93e19c4c4e"
);
}
#[test]
fn build_refresh_session_clear_cookie_respects_config() {
let cookie = build_refresh_session_clear_cookie(&build_refresh_cookie_config());
assert!(cookie.contains("genarrative_refresh_session="));
assert!(cookie.contains("Path=/api/auth"));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Lax"));
assert!(cookie.contains("Max-Age=0"));
}
}