feat: add refresh cookie reader
This commit is contained in:
@@ -3,7 +3,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
auth::{inspect_auth_claims, require_bearer_auth},
|
||||
auth::{
|
||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||
require_bearer_auth,
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -27,6 +30,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/_internal/auth/refresh-cookie",
|
||||
get(inspect_refresh_session_cookie).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -239,4 +249,72 @@ mod tests {
|
||||
Value::Number(serde_json::Number::from(7))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_refresh_cookie_reports_missing_cookie() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/_internal/auth/refresh-cookie")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["present"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["cookieName"],
|
||||
Value::String("genarrative_refresh_session".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_refresh_cookie_reports_present_cookie() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/_internal/auth/refresh-cookie")
|
||||
.header(
|
||||
"cookie",
|
||||
"theme=dark; genarrative_refresh_session=token12345",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["present"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
payload["tokenLength"],
|
||||
Value::Number(serde_json::Number::from(10))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Request, State},
|
||||
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
|
||||
http::{
|
||||
HeaderMap, StatusCode,
|
||||
header::{AUTHORIZATION, COOKIE},
|
||||
},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use platform_auth::{AccessTokenClaims, verify_access_token};
|
||||
use platform_auth::{AccessTokenClaims, read_refresh_session_token, verify_access_token};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::warn;
|
||||
|
||||
@@ -20,6 +23,11 @@ pub struct AuthenticatedAccessToken {
|
||||
claims: AccessTokenClaims,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefreshSessionToken {
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl AuthenticatedAccessToken {
|
||||
pub fn new(claims: AccessTokenClaims) -> Self {
|
||||
Self { claims }
|
||||
@@ -30,6 +38,16 @@ impl AuthenticatedAccessToken {
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshSessionToken {
|
||||
pub fn new(token: String) -> Self {
|
||||
Self { token }
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_bearer_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
@@ -69,6 +87,44 @@ pub async fn inspect_auth_claims(
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn attach_refresh_session_token(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if let Some(token) = request
|
||||
.headers()
|
||||
.get(COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|cookie_header| {
|
||||
read_refresh_session_token(cookie_header, state.refresh_cookie_config())
|
||||
})
|
||||
{
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(RefreshSessionToken::new(token));
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub async fn inspect_refresh_session_cookie(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
request: Request,
|
||||
) -> Json<Value> {
|
||||
let maybe_token = request.extensions().get::<RefreshSessionToken>();
|
||||
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"cookieName": state.refresh_cookie_config().cookie_name(),
|
||||
"present": maybe_token.is_some(),
|
||||
"tokenLength": maybe_token.map(|token| token.token().len()),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
let authorization = headers
|
||||
.get(AUTHORIZATION)
|
||||
@@ -88,7 +144,7 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_bearer_token;
|
||||
use super::{RefreshSessionToken, extract_bearer_token};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
|
||||
response::IntoResponse,
|
||||
@@ -116,4 +172,11 @@ mod tests {
|
||||
|
||||
assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_session_token_retains_original_value() {
|
||||
let token = RefreshSessionToken::new("refresh-token-01".to_string());
|
||||
|
||||
assert_eq!(token.token(), "refresh-token-01");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ pub struct AppConfig {
|
||||
pub jwt_issuer: String,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_access_token_ttl_seconds: u64,
|
||||
pub refresh_cookie_name: String,
|
||||
pub refresh_cookie_path: String,
|
||||
pub refresh_cookie_secure: bool,
|
||||
pub refresh_cookie_same_site: String,
|
||||
pub refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -20,6 +25,11 @@ impl Default for AppConfig {
|
||||
jwt_issuer: "https://auth.genarrative.local".to_string(),
|
||||
jwt_secret: "genarrative-dev-secret".to_string(),
|
||||
jwt_access_token_ttl_seconds: 2 * 60 * 60,
|
||||
refresh_cookie_name: "genarrative_refresh_session".to_string(),
|
||||
refresh_cookie_path: "/api/auth".to_string(),
|
||||
refresh_cookie_secure: false,
|
||||
refresh_cookie_same_site: "Lax".to_string(),
|
||||
refresh_session_ttl_days: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +75,30 @@ impl AppConfig {
|
||||
config.jwt_access_token_ttl_seconds = ttl_seconds;
|
||||
}
|
||||
|
||||
if let Some(refresh_cookie_name) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_NAME"]) {
|
||||
config.refresh_cookie_name = refresh_cookie_name;
|
||||
}
|
||||
|
||||
if let Some(refresh_cookie_path) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_PATH"]) {
|
||||
config.refresh_cookie_path = refresh_cookie_path;
|
||||
}
|
||||
|
||||
if let Some(refresh_cookie_same_site) =
|
||||
read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_SAME_SITE"])
|
||||
{
|
||||
config.refresh_cookie_same_site = refresh_cookie_same_site;
|
||||
}
|
||||
|
||||
if let Some(refresh_cookie_secure) = read_first_bool_env(&["AUTH_REFRESH_COOKIE_SECURE"]) {
|
||||
config.refresh_cookie_secure = refresh_cookie_secure;
|
||||
}
|
||||
|
||||
if let Some(refresh_session_ttl_days) =
|
||||
read_first_positive_u32_env(&["AUTH_REFRESH_SESSION_TTL_DAYS"])
|
||||
{
|
||||
config.refresh_session_ttl_days = refresh_session_ttl_days;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
@@ -97,6 +131,19 @@ fn read_first_duration_seconds_env(keys: &[&str]) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_bool_env(keys: &[&str]) -> Option<bool> {
|
||||
keys.iter()
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value)))
|
||||
}
|
||||
|
||||
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_positive_u32(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
@@ -121,3 +168,20 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
||||
|
||||
number.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Option<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
let value = raw.trim().parse::<u32>().ok()?;
|
||||
if value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use platform_auth::{JwtConfig, JwtError};
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
@@ -9,23 +13,69 @@ pub struct AppState {
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
auth_jwt_config: JwtConfig,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppStateInitError {
|
||||
Jwt(JwtError),
|
||||
RefreshCookie(RefreshCookieError),
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Result<Self, JwtError> {
|
||||
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
let auth_jwt_config = JwtConfig::new(
|
||||
config.jwt_issuer.clone(),
|
||||
config.jwt_secret.clone(),
|
||||
config.jwt_access_token_ttl_seconds,
|
||||
)?;
|
||||
let refresh_cookie_same_site =
|
||||
RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or(
|
||||
RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"),
|
||||
)?;
|
||||
let refresh_cookie_config = RefreshCookieConfig::new(
|
||||
config.refresh_cookie_name.clone(),
|
||||
config.refresh_cookie_path.clone(),
|
||||
config.refresh_cookie_secure,
|
||||
refresh_cookie_same_site,
|
||||
config.refresh_session_ttl_days,
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_jwt_config,
|
||||
refresh_cookie_config,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn auth_jwt_config(&self) -> &JwtConfig {
|
||||
&self.auth_jwt_config
|
||||
}
|
||||
|
||||
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
|
||||
&self.refresh_cookie_config
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Jwt(error) => write!(f, "{error}"),
|
||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for AppStateInitError {}
|
||||
|
||||
impl From<JwtError> for AppStateInitError {
|
||||
fn from(value: JwtError) -> Self {
|
||||
Self::Jwt(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RefreshCookieError> for AppStateInitError {
|
||||
fn from(value: RefreshCookieError) -> Self {
|
||||
Self::RefreshCookie(value)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user