presentation: HTTP server crate w/ handlers, routes, background tasks
Axum binary that wires all clean-arch crates together: - AppState holds pre-built Deps structs (auth, channels, schedule, library, etc.) - JWT extractors (CurrentUser, AdminUser, OptionalCurrentUser) - Handlers delegate to application use cases, map to api-types DTOs - Routes: auth, channels, schedule, library, admin, providers, config, iptv - Background: auto-scheduler, broadcast poller, webhook consumer, library sync - Factory builds everything from Config + DbPool - SimpleProviderRegistry impl of IProviderRegistry trait - NoopMediaProvider fallback
This commit is contained in:
151
crates/presentation/src/extractors.rs
Normal file
151
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Auth extractors for API handlers.
|
||||
//!
|
||||
//! Provides `CurrentUser`, `OptionalCurrentUser`, and `AdminUser` extractors.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use domain::User;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extracted current user from JWT Bearer token.
|
||||
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 missing.
|
||||
///
|
||||
/// Checks `Authorization: Bearer <token>` first; falls back to `?token=<jwt>`.
|
||||
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")]
|
||||
{
|
||||
if let Ok(user) = try_jwt_auth(parts, state).await {
|
||||
return Ok(OptionalCurrentUser(Some(user)));
|
||||
}
|
||||
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 via JWT Bearer token from `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 {
|
||||
adapter_auth::JwtError::Expired => {
|
||||
ApiError::Unauthorized("Token expired".to_string())
|
||||
}
|
||||
adapter_auth::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
|
||||
.auth_deps
|
||||
.user_query
|
||||
.find_by_id(domain::UserId::from(user_id))
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?
|
||||
.ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
Reference in New Issue
Block a user