convertor/api-server/src/auth.rs
Your Name e083e5d11d feat: Add Rust API Server with REST and GraphQL support
- Implement JWT authentication layer
- Add Engine Registry with 20+ conversion engines
- Implement Conversion Service with background job processing
- REST API endpoints for file conversion operations
- GraphQL API with queries and mutations
- Comprehensive error handling with conversion suggestions
- Integration tests for both REST and GraphQL APIs
- Complete API documentation

Features:
- REST API: /api/v1/* endpoints
- GraphQL API: /graphql endpoint with playground
- JWT Bearer token authentication
- Engine validation with alternative suggestions
- File download via API (not exposing file paths)
- Support for FFmpeg, ImageMagick, LibreOffice, Pandoc, and more
2026-01-21 11:40:03 +08:00

287 lines
8.4 KiB
Rust

//! JWT Authentication module
//!
//! Handles JWT token validation for API requests.
use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts, StatusCode},
response::{IntoResponse, Response},
Json, RequestPartsExt,
};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use crate::error::{ApiError, ErrorResponse};
/// JWT Claims structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
/// Subject (user ID)
pub sub: String,
/// Expiration time (Unix timestamp)
pub exp: i64,
/// Issued at (Unix timestamp)
pub iat: i64,
/// Optional user email
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
/// Optional user roles
#[serde(default)]
pub roles: Vec<String>,
}
impl Claims {
/// Create new claims for a user
pub fn new(user_id: String, expiration_secs: i64) -> Self {
let now = Utc::now().timestamp();
Self {
sub: user_id,
exp: now + expiration_secs,
iat: now,
email: None,
roles: vec![],
}
}
/// Check if the token is expired
pub fn is_expired(&self) -> bool {
let now = Utc::now().timestamp();
self.exp < now
}
/// Get expiration as DateTime
pub fn expiration(&self) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(self.exp, 0)
}
}
/// Authenticated user extracted from JWT
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user_id: String,
pub email: Option<String>,
pub roles: Vec<String>,
pub claims: Claims,
}
impl AuthenticatedUser {
/// Check if user has a specific role
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
}
/// JWT Validator
pub struct JwtValidator {
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtValidator {
/// Create a new JWT validator with the given secret
pub fn new(secret: &str) -> Self {
let decoding_key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
validation.validate_aud = false;
Self {
decoding_key,
validation,
}
}
/// Validate a JWT token and return the claims
pub fn validate(&self, token: &str) -> Result<Claims, ApiError> {
let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => ApiError::TokenExpired,
jsonwebtoken::errors::ErrorKind::InvalidToken => {
ApiError::InvalidToken("Token format is invalid".into())
}
jsonwebtoken::errors::ErrorKind::InvalidSignature => {
ApiError::InvalidToken("Token signature is invalid".into())
}
_ => ApiError::InvalidToken(e.to_string()),
})?;
Ok(token_data.claims)
}
/// Extract token from Authorization header
pub fn extract_token(auth_header: &str) -> Result<&str, ApiError> {
if !auth_header.starts_with("Bearer ") {
return Err(ApiError::InvalidToken(
"Authorization header must use Bearer scheme".into(),
));
}
let token = auth_header.trim_start_matches("Bearer ").trim();
if token.is_empty() {
return Err(ApiError::InvalidToken("Token is empty".into()));
}
Ok(token)
}
}
/// Extractor for authenticated requests
/// Use this in route handlers to require authentication
#[derive(Debug, Clone)]
pub struct RequireAuth(pub AuthenticatedUser);
impl<S> FromRequestParts<S> for RequireAuth
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Get the JWT secret from environment or use default
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
let validator = JwtValidator::new(&jwt_secret);
// Extract Authorization header
let auth_header = parts
.headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or(AuthError(ApiError::MissingAuthHeader))?;
// Extract and validate token
let token = JwtValidator::extract_token(auth_header).map_err(AuthError)?;
let claims = validator.validate(token).map_err(AuthError)?;
let user = AuthenticatedUser {
user_id: claims.sub.clone(),
email: claims.email.clone(),
roles: claims.roles.clone(),
claims,
};
Ok(RequireAuth(user))
}
}
/// Auth error wrapper for proper response formatting
pub struct AuthError(pub ApiError);
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let status = self.0.status_code();
let body = Json(self.0.to_error_response());
(status, body).into_response()
}
}
/// Generate a JWT token for testing purposes
///
/// Note: The API server does NOT generate tokens in production.
/// This is only for testing and development.
#[cfg(any(test, feature = "dev-utils"))]
pub fn generate_test_token(secret: &str, user_id: &str, expiration_secs: i64) -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
let claims = Claims::new(user_id.to_string(), expiration_secs);
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.expect("Failed to generate test token")
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "test-secret-key-for-testing";
fn create_test_token(user_id: &str, exp_offset: i64) -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
let now = Utc::now().timestamp();
let claims = Claims {
sub: user_id.to_string(),
exp: now + exp_offset,
iat: now,
email: Some("test@example.com".into()),
roles: vec!["user".into()],
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.unwrap()
}
#[test]
fn test_valid_token() {
let validator = JwtValidator::new(TEST_SECRET);
let token = create_test_token("user123", 3600);
let claims = validator.validate(&token).unwrap();
assert_eq!(claims.sub, "user123");
assert_eq!(claims.email, Some("test@example.com".into()));
}
#[test]
fn test_expired_token() {
let validator = JwtValidator::new(TEST_SECRET);
let token = create_test_token("user123", -3600); // Expired 1 hour ago
let result = validator.validate(&token);
assert!(matches!(result, Err(ApiError::TokenExpired)));
}
#[test]
fn test_invalid_signature() {
let validator = JwtValidator::new("different-secret");
let token = create_test_token("user123", 3600);
let result = validator.validate(&token);
assert!(matches!(result, Err(ApiError::InvalidToken(_))));
}
#[test]
fn test_extract_token() {
let header = "Bearer eyJhbGciOiJIUzI1NiJ9.test.signature";
let token = JwtValidator::extract_token(header).unwrap();
assert_eq!(token, "eyJhbGciOiJIUzI1NiJ9.test.signature");
}
#[test]
fn test_extract_token_invalid_scheme() {
let header = "Basic dXNlcjpwYXNz";
let result = JwtValidator::extract_token(header);
assert!(matches!(result, Err(ApiError::InvalidToken(_))));
}
#[test]
fn test_claims_is_expired() {
let now = Utc::now().timestamp();
let valid_claims = Claims {
sub: "user".into(),
exp: now + 3600,
iat: now,
email: None,
roles: vec![],
};
assert!(!valid_claims.is_expired());
let expired_claims = Claims {
sub: "user".into(),
exp: now - 3600,
iat: now - 7200,
email: None,
roles: vec![],
};
assert!(expired_claims.is_expired());
}
}