Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
151 lines
4.9 KiB
Rust
151 lines
4.9 KiB
Rust
//! Auth extractors for API handlers
|
|
//!
|
|
//! Provides the `CurrentUser` extractor that validates JWT Bearer tokens.
|
|
|
|
use axum::{extract::FromRequestParts, http::request::Parts};
|
|
use domain::User;
|
|
|
|
use crate::error::ApiError;
|
|
use crate::state::AppState;
|
|
|
|
/// Extracted current user from the request.
|
|
///
|
|
/// Validates a JWT Bearer token from the `Authorization` header.
|
|
pub struct CurrentUser(pub User);
|
|
|
|
impl FromRequestParts<AppState> for CurrentUser {
|
|
type Rejection = ApiError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
#[cfg(feature = "auth-jwt")]
|
|
{
|
|
return match try_jwt_auth(parts, state).await {
|
|
Ok(user) => Ok(CurrentUser(user)),
|
|
Err(e) => Err(e),
|
|
};
|
|
}
|
|
|
|
#[cfg(not(feature = "auth-jwt"))]
|
|
{
|
|
let _ = (parts, state);
|
|
Err(ApiError::Unauthorized(
|
|
"No authentication backend configured".to_string(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Optional current user — returns None instead of error when auth is missing/invalid.
|
|
///
|
|
/// Checks `Authorization: Bearer <token>` first; falls back to `?token=<jwt>` query param
|
|
/// so IPTV clients and direct stream links work without custom headers.
|
|
pub struct OptionalCurrentUser(pub Option<User>);
|
|
|
|
impl FromRequestParts<AppState> for OptionalCurrentUser {
|
|
type Rejection = ApiError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
#[cfg(feature = "auth-jwt")]
|
|
{
|
|
// Try Authorization header first
|
|
if let Ok(user) = try_jwt_auth(parts, state).await {
|
|
return Ok(OptionalCurrentUser(Some(user)));
|
|
}
|
|
// Fall back to ?token= query param
|
|
let query_token = parts.uri.query().and_then(|q| {
|
|
q.split('&')
|
|
.find(|seg| seg.starts_with("token="))
|
|
.map(|seg| seg[6..].to_owned())
|
|
});
|
|
if let Some(token) = query_token {
|
|
let user = validate_jwt_token(&token, state).await.ok();
|
|
return Ok(OptionalCurrentUser(user));
|
|
}
|
|
Ok(OptionalCurrentUser(None))
|
|
}
|
|
|
|
#[cfg(not(feature = "auth-jwt"))]
|
|
{
|
|
let _ = (parts, state);
|
|
Ok(OptionalCurrentUser(None))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extracted admin user — returns 403 if user is not an admin.
|
|
pub struct AdminUser(pub User);
|
|
|
|
impl FromRequestParts<AppState> for AdminUser {
|
|
type Rejection = ApiError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
|
let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?;
|
|
if !user.is_admin {
|
|
return Err(ApiError::Forbidden("Admin access required".to_string()));
|
|
}
|
|
Ok(AdminUser(user))
|
|
}
|
|
}
|
|
|
|
/// Authenticate using JWT Bearer token from the `Authorization` header.
|
|
#[cfg(feature = "auth-jwt")]
|
|
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
|
use axum::http::header::AUTHORIZATION;
|
|
|
|
let auth_header = parts
|
|
.headers
|
|
.get(AUTHORIZATION)
|
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
|
|
|
let auth_str = auth_header
|
|
.to_str()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
|
|
|
|
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
|
|
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
|
|
})?;
|
|
|
|
validate_jwt_token(token, state).await
|
|
}
|
|
|
|
/// Validate a raw JWT string and return the corresponding `User`.
|
|
#[cfg(feature = "auth-jwt")]
|
|
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, ApiError> {
|
|
let validator = state
|
|
.jwt_validator
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
|
|
|
|
let claims = validator.validate_access_token(token).map_err(|e| {
|
|
tracing::debug!("JWT validation failed: {:?}", e);
|
|
match e {
|
|
infra::auth::jwt::JwtError::Expired => {
|
|
ApiError::Unauthorized("Token expired".to_string())
|
|
}
|
|
infra::auth::jwt::JwtError::InvalidFormat => {
|
|
ApiError::Unauthorized("Invalid token format".to_string())
|
|
}
|
|
_ => ApiError::Unauthorized("Token validation failed".to_string()),
|
|
}
|
|
})?;
|
|
|
|
let user_id: uuid::Uuid = claims
|
|
.sub
|
|
.parse()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
|
|
|
|
let user = state
|
|
.user_service
|
|
.find_by_id(user_id)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?;
|
|
|
|
Ok(user)
|
|
}
|