151 lines
4.9 KiB
Rust
151 lines
4.9 KiB
Rust
use axum::{
|
|
extract::FromRequestParts,
|
|
http::{request::Parts, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::future::Future;
|
|
use uuid::Uuid;
|
|
|
|
// ── JWT Claims ────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct Claims {
|
|
pub sub: String, // user_id (UUID string)
|
|
pub email: String,
|
|
pub roles: Vec<String>,
|
|
pub active_role: String,
|
|
pub exp: usize,
|
|
pub iat: usize,
|
|
}
|
|
|
|
// ── AuthUser extractor ────────────────────────────────────────────────────────
|
|
|
|
/// Axum extractor: validates the Bearer token in the Authorization header.
|
|
/// Usage: `async fn handler(auth: AuthUser, ...) -> impl IntoResponse`
|
|
#[derive(Debug, Clone)]
|
|
pub struct AuthUser {
|
|
pub user_id: Uuid,
|
|
pub email: String,
|
|
pub claims: Claims,
|
|
}
|
|
|
|
impl<S> FromRequestParts<S> for AuthUser
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = AuthError;
|
|
|
|
fn from_request_parts(
|
|
parts: &mut Parts,
|
|
_state: &S,
|
|
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
|
|
let auth_header = parts
|
|
.headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_string);
|
|
|
|
async move {
|
|
let auth_header = auth_header.ok_or(AuthError::MissingToken)?;
|
|
|
|
// 2. Strip "Bearer " prefix
|
|
let token = auth_header
|
|
.strip_prefix("Bearer ")
|
|
.ok_or(AuthError::InvalidToken)?;
|
|
|
|
// 3. Decode & verify
|
|
let jwt_secret = std::env::var("JWT_SECRET")
|
|
.expect("JWT_SECRET must be set — refusing to start with insecure default");
|
|
|
|
let token_data = decode::<Claims>(
|
|
token,
|
|
&DecodingKey::from_secret(jwt_secret.as_bytes()),
|
|
&Validation::new(Algorithm::HS256),
|
|
)
|
|
.map_err(|e| {
|
|
tracing::debug!("JWT decode error: {}", e);
|
|
AuthError::InvalidToken
|
|
})?;
|
|
|
|
// 4. Parse user_id as UUID
|
|
let user_id = Uuid::parse_str(&token_data.claims.sub)
|
|
.map_err(|_| AuthError::InvalidToken)?;
|
|
|
|
Ok(AuthUser {
|
|
user_id,
|
|
email: token_data.claims.email.clone(),
|
|
claims: token_data.claims,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Auth Error types ──────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub enum AuthError {
|
|
MissingToken,
|
|
InvalidToken,
|
|
InsufficientPermissions,
|
|
}
|
|
|
|
impl IntoResponse for AuthError {
|
|
fn into_response(self) -> Response {
|
|
let (status, code, message) = match self {
|
|
AuthError::MissingToken => (
|
|
StatusCode::UNAUTHORIZED,
|
|
"MISSING_TOKEN",
|
|
"Authorization header required",
|
|
),
|
|
AuthError::InvalidToken => (
|
|
StatusCode::UNAUTHORIZED,
|
|
"INVALID_TOKEN",
|
|
"Token is invalid or expired",
|
|
),
|
|
AuthError::InsufficientPermissions => (
|
|
StatusCode::FORBIDDEN,
|
|
"INSUFFICIENT_PERMISSIONS",
|
|
"You do not have permission to access this resource",
|
|
),
|
|
};
|
|
|
|
(
|
|
status,
|
|
Json(serde_json::json!({ "error": message, "code": code })),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
// ── Role guard helper ─────────────────────────────────────────────────────────
|
|
|
|
/// Returns Ok if the user's active_role matches the expected role key.
|
|
/// Otherwise returns AuthError::InsufficientPermissions.
|
|
pub fn require_role(auth: &AuthUser, expected_role: &str) -> Result<(), AuthError> {
|
|
if auth.claims.active_role == expected_role
|
|
|| auth.claims.roles.contains(&expected_role.to_string())
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(AuthError::InsufficientPermissions)
|
|
}
|
|
}
|
|
|
|
/// Returns Ok if the user has an internal admin role (ADMIN or SUPER_ADMIN).
|
|
pub fn require_admin(auth: &AuthUser) -> Result<(), AuthError> {
|
|
let active = auth.claims.active_role.as_str();
|
|
let has_internal_admin =
|
|
active == "ADMIN"
|
|
|| active == "SUPER_ADMIN"
|
|
|| auth.claims.roles.contains(&"ADMIN".to_string())
|
|
|| auth.claims.roles.contains(&"SUPER_ADMIN".to_string());
|
|
|
|
if has_internal_admin {
|
|
Ok(())
|
|
} else {
|
|
Err(AuthError::InsufficientPermissions)
|
|
}
|
|
}
|