feat: add refresh cookie reader
This commit is contained in:
@@ -8,6 +8,9 @@ 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)]
|
||||
@@ -64,6 +67,24 @@ pub struct JwtConfig {
|
||||
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),
|
||||
@@ -72,6 +93,11 @@ pub enum JwtError {
|
||||
VerifyFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RefreshCookieError {
|
||||
InvalidConfig(&'static str),
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(
|
||||
issuer: String,
|
||||
@@ -111,6 +137,84 @@ impl JwtConfig {
|
||||
}
|
||||
}
|
||||
|
||||
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 = cookie_name.trim().to_string();
|
||||
let cookie_path = cookie_path.trim().to_string();
|
||||
|
||||
if cookie_name.is_empty() {
|
||||
return Err(RefreshCookieError::InvalidConfig(
|
||||
"refresh cookie 名称不能为空",
|
||||
));
|
||||
}
|
||||
|
||||
if cookie_path.is_empty() {
|
||||
return Err(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,
|
||||
@@ -233,6 +337,39 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
|
||||
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
|
||||
}
|
||||
|
||||
impl fmt::Display for JwtError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -244,6 +381,16 @@ impl fmt::Display for JwtError {
|
||||
|
||||
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 {}
|
||||
|
||||
fn normalize_required_field(
|
||||
value: String,
|
||||
error_message: &'static str,
|
||||
@@ -322,6 +469,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -374,4 +532,32 @@ mod tests {
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user