- Switched to secure httpOnly, SameSite=Strict cookies for JWT authentication. - Refactored backend to use AppState for shared secrets and database pool caching. - Modernized frontend with Svelte 5 runes ($state) and removed localStorage reliance. - Gated destructive test endpoints behind debug_assertions and fixed unsafe test patterns. - Enhanced CI pipeline with cargo clippy, cargo fmt, and pinned pnpm version. - Updated documentation and implementation plans to match the hardened architecture.
102 lines
2.9 KiB
Rust
102 lines
2.9 KiB
Rust
use crate::{AppState, error::AppError};
|
|
use axum::{RequestPartsExt, extract::FromRef, extract::FromRequestParts, http::request::Parts};
|
|
use axum_extra::extract::CookieJar;
|
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct TutorClaims {
|
|
pub sub: i64,
|
|
pub email: String,
|
|
pub is_superadmin: bool,
|
|
pub exp: u64,
|
|
}
|
|
|
|
pub fn encode_jwt(
|
|
id: i64,
|
|
email: &str,
|
|
is_superadmin: bool,
|
|
secret: &str,
|
|
) -> Result<String, AppError> {
|
|
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
|
|
let claims = TutorClaims {
|
|
sub: id,
|
|
email: email.into(),
|
|
is_superadmin,
|
|
exp,
|
|
};
|
|
encode(
|
|
&Header::default(),
|
|
&claims,
|
|
&EncodingKey::from_secret(secret.as_bytes()),
|
|
)
|
|
.map_err(|_| AppError::Unauthorized)
|
|
}
|
|
|
|
pub fn decode_jwt(token: &str, secret: &str) -> Result<TutorClaims, AppError> {
|
|
decode::<TutorClaims>(
|
|
token,
|
|
&DecodingKey::from_secret(secret.as_bytes()),
|
|
&Validation::default(),
|
|
)
|
|
.map(|d| d.claims)
|
|
.map_err(|e| {
|
|
tracing::debug!(error = %e, "JWT decode failed");
|
|
AppError::Unauthorized
|
|
})
|
|
}
|
|
|
|
// Axum extractor: pulls JWT from httpOnly cookie or Authorization: Bearer header
|
|
impl<S> FromRequestParts<S> for TutorClaims
|
|
where
|
|
S: Send + Sync,
|
|
AppState: axum::extract::FromRef<S>,
|
|
{
|
|
type Rejection = AppError;
|
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
let app_state = AppState::from_ref(state);
|
|
|
|
// Try cookie first
|
|
let jar = parts
|
|
.extract::<CookieJar>()
|
|
.await
|
|
.map_err(|_| AppError::Unauthorized)?;
|
|
let token = if let Some(cookie) = jar.get("token") {
|
|
cookie.value().to_string()
|
|
} else {
|
|
// Fallback to header for compatibility/testing
|
|
parts
|
|
.headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.strip_prefix("Bearer "))
|
|
.ok_or(AppError::Unauthorized)?
|
|
.to_string()
|
|
};
|
|
|
|
decode_jwt(&token, &app_state.jwt_secret)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serial_test::serial;
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn jwt_roundtrip_and_rejection() {
|
|
temp_env::with_var("JWT_SECRET", Some("testsecret_auth"), || {
|
|
let secret = "testsecret_auth";
|
|
// roundtrip
|
|
let token = encode_jwt(1, "test@example.com", true, secret).unwrap();
|
|
let claims = decode_jwt(&token, secret).unwrap();
|
|
assert_eq!(claims.sub, 1);
|
|
assert!(claims.is_superadmin);
|
|
|
|
// rejection
|
|
assert!(decode_jwt("not.a.token", secret).is_err());
|
|
});
|
|
}
|
|
}
|