Copied from api_gateway, removed REST handlers (port 8080), added tonic-web + CORS for grpc-web browser access. Binary renamed: api-gateway → api Changes: - Package name: api-gateway → api - Deleted src/handlers/ (REST ML endpoints on port 8080) - Added tonic-web 0.13 + tower-http CORS layer - Server::builder().accept_http1(true) for grpc-web - CORS_ORIGINS env var (default http://localhost:5173) - Metrics server on port 9091 (axum) preserved - All 95 lib tests pass, 0 clippy warnings - Added services/api to workspace members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1072 lines
33 KiB
Rust
1072 lines
33 KiB
Rust
//! High-Performance gRPC Authentication Interceptor with 6-Layer Security
|
|
//!
|
|
//! This module implements a comprehensive authentication system optimized for HFT requirements:
|
|
//! - Total overhead: <10μs per request
|
|
//! - JWT validation: <1μs (cached keys)
|
|
//! - Revocation check: <500ns (Redis in-memory)
|
|
//! - Authorization: <100ns (cached permissions)
|
|
//! - Rate limiting: <50ns (in-memory counters)
|
|
//!
|
|
//! ## 6-Layer Authentication Architecture
|
|
//!
|
|
//! ```text
|
|
//! Layer 1: mTLS Client Certificate (handled by tonic-tls)
|
|
//! Layer 2: JWT Extraction from Authorization header
|
|
//! Layer 3: JWT Revocation Check (Redis - <500ns)
|
|
//! Layer 4: JWT Signature & Expiration Validation (<1μs)
|
|
//! Layer 5: RBAC Permission Check (<100ns)
|
|
//! Layer 6: Rate Limiting (<50ns)
|
|
//! Layer 7: User Context Injection (metadata enrichment)
|
|
//! Layer 8: Async Audit Logging (non-blocking)
|
|
//! ```
|
|
|
|
use anyhow::{Context, Result};
|
|
use dashmap::DashMap;
|
|
use governor::{state::keyed::DefaultKeyedStateStore, Quota, RateLimiter as GovernorRateLimiter};
|
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
|
use redis::{aio::ConnectionManager, AsyncCommands};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::num::NonZeroU32;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use tonic::{Request, Status};
|
|
use tracing::{debug, error, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
/// JWT Token ID (`JTI`) for unique token identification and revocation
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct Jti(pub String);
|
|
|
|
impl Jti {
|
|
/// Create new random JTI
|
|
pub fn new() -> Self {
|
|
Self(Uuid::new_v4().to_string())
|
|
}
|
|
|
|
/// Parse `JTI` from string
|
|
pub fn from_string(s: String) -> Self {
|
|
Self(s)
|
|
}
|
|
|
|
/// Get Redis key for this JTI
|
|
fn redis_key(&self) -> String {
|
|
format!("jwt:blacklist:{}", self.0)
|
|
}
|
|
|
|
/// Get `JTI` as string reference
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Default for Jti {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Enhanced JWT claims with revocation support
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct JwtClaims {
|
|
/// JWT ID for revocation tracking (MANDATORY)
|
|
pub jti: String,
|
|
/// Subject (user ID)
|
|
pub sub: String,
|
|
/// Issued at timestamp
|
|
pub iat: u64,
|
|
/// Expiration timestamp
|
|
pub exp: u64,
|
|
/// Not before timestamp (optional)
|
|
pub nbf: Option<u64>,
|
|
/// Issuer
|
|
pub iss: String,
|
|
/// Audience
|
|
pub aud: String,
|
|
/// User roles (for RBAC)
|
|
pub roles: Vec<String>,
|
|
/// Granular permissions
|
|
pub permissions: Vec<String>,
|
|
/// Token type: "access" or "refresh"
|
|
#[serde(default = "default_token_type")]
|
|
pub token_type: String,
|
|
/// Session ID for tracking related tokens
|
|
#[serde(default)]
|
|
pub session_id: Option<String>,
|
|
}
|
|
|
|
fn default_token_type() -> String {
|
|
"access".to_string()
|
|
}
|
|
|
|
/// User context injected into gRPC metadata after successful authentication
|
|
#[derive(Debug, Clone)]
|
|
pub struct UserContext {
|
|
pub user_id: String,
|
|
pub roles: Vec<String>,
|
|
pub permissions: Vec<String>,
|
|
pub session_id: String,
|
|
pub authenticated_at: Instant,
|
|
}
|
|
|
|
/// Cached revocation result with timestamp
|
|
#[derive(Debug, Clone)]
|
|
struct CachedRevocationResult {
|
|
is_revoked: bool,
|
|
cached_at: Instant,
|
|
}
|
|
|
|
/// Local in-memory cache for revocation checks
|
|
///
|
|
/// PERFORMANCE: Reduces Redis network latency (500μs → <10ns for cache hits)
|
|
pub struct LocalRevocationCache {
|
|
cache: Arc<DashMap<String, CachedRevocationResult>>,
|
|
ttl: Duration,
|
|
hits: Arc<std::sync::atomic::AtomicU64>,
|
|
misses: Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
|
|
impl LocalRevocationCache {
|
|
/// Create new local revocation cache
|
|
///
|
|
/// # Arguments
|
|
/// * `ttl` - Time-to-live for cached entries (recommended: 60s)
|
|
pub fn new(ttl: Duration) -> Self {
|
|
Self {
|
|
cache: Arc::new(DashMap::new()),
|
|
ttl,
|
|
hits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
misses: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
}
|
|
}
|
|
|
|
/// Check if token is revoked (with local cache)
|
|
///
|
|
/// # Performance
|
|
/// - Cache hit: <10ns (DashMap lookup)
|
|
///
|
|
/// - Cache miss: ~500μs (Redis network latency)
|
|
/// - Expected hit rate: >95%
|
|
pub async fn check_revoked(
|
|
&self,
|
|
token_id: &str,
|
|
redis: &mut ConnectionManager,
|
|
) -> Result<bool> {
|
|
// Check cache first
|
|
if let Some(entry) = self.cache.get(token_id) {
|
|
if entry.cached_at.elapsed() < self.ttl {
|
|
// Cache hit - increment counter
|
|
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
return Ok(entry.is_revoked);
|
|
} else {
|
|
// Entry expired - remove it
|
|
drop(entry); // Release read lock
|
|
self.cache.remove(token_id);
|
|
}
|
|
}
|
|
|
|
// Cache miss - check Redis
|
|
self.misses
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
let key = format!("jwt:blacklist:{}", token_id);
|
|
let is_revoked: bool = redis
|
|
.exists(&key)
|
|
.await
|
|
.context("Failed to check token revocation status in Redis")?;
|
|
|
|
// Update cache
|
|
self.cache.insert(
|
|
token_id.to_string(),
|
|
CachedRevocationResult {
|
|
is_revoked,
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
|
|
Ok(is_revoked)
|
|
}
|
|
|
|
/// Invalidate cache entry for a specific token
|
|
///
|
|
/// Called when a token is revoked to immediately reflect the change
|
|
pub fn invalidate(&self, token_id: &str) {
|
|
self.cache.remove(token_id);
|
|
}
|
|
|
|
/// Clear all cached entries
|
|
pub fn clear(&self) {
|
|
self.cache.clear();
|
|
}
|
|
|
|
/// Get cache statistics
|
|
pub fn stats(&self) -> CacheStats {
|
|
let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
|
|
let misses = self.misses.load(std::sync::atomic::Ordering::Relaxed);
|
|
let total = hits.saturating_add(misses);
|
|
let hit_rate = if total > 0 {
|
|
let rate = (hits as f64 / total as f64) * 100.0;
|
|
// f64 multiplication is safe, check for valid result
|
|
if rate.is_finite() {
|
|
rate
|
|
} else {
|
|
0.0
|
|
}
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
CacheStats {
|
|
hits,
|
|
misses,
|
|
total,
|
|
hit_rate,
|
|
entries: self.cache.len(),
|
|
}
|
|
}
|
|
|
|
/// Reset cache statistics
|
|
pub fn reset_stats(&self) {
|
|
self.hits.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
self.misses.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Cache statistics for monitoring
|
|
#[derive(Debug, Clone)]
|
|
pub struct CacheStats {
|
|
pub hits: u64,
|
|
pub misses: u64,
|
|
pub total: u64,
|
|
pub hit_rate: f64,
|
|
pub entries: usize,
|
|
}
|
|
|
|
/// JWT Revocation Service (Redis-backed with local cache)
|
|
#[derive(Clone)]
|
|
pub struct RevocationService {
|
|
redis: ConnectionManager,
|
|
cache: Arc<LocalRevocationCache>,
|
|
}
|
|
|
|
impl RevocationService {
|
|
/// Create new revocation service with local cache
|
|
///
|
|
/// # Arguments
|
|
/// * `redis_url` - Redis connection URL
|
|
///
|
|
/// * `cache_ttl` - TTL for local cache entries (default: 60s)
|
|
pub async fn new(redis_url: &str) -> Result<Self> {
|
|
Self::new_with_cache_ttl(redis_url, Duration::from_secs(60)).await
|
|
}
|
|
|
|
/// Create new revocation service with custom cache TTL
|
|
pub async fn new_with_cache_ttl(redis_url: &str, cache_ttl: Duration) -> Result<Self> {
|
|
let client = redis::Client::open(redis_url)
|
|
.context("Failed to create Redis client for revocation service")?;
|
|
|
|
let redis = ConnectionManager::new(client)
|
|
.await
|
|
.context("Failed to connect to Redis for revocation service")?;
|
|
|
|
Ok(Self {
|
|
redis,
|
|
cache: Arc::new(LocalRevocationCache::new(cache_ttl)),
|
|
})
|
|
}
|
|
|
|
/// Check if token is revoked (TARGET: <10ns for cache hits, <500μs for cache misses)
|
|
///
|
|
/// PERFORMANCE: Uses local DashMap cache to avoid Redis network latency
|
|
pub async fn is_revoked(&self, jti: &Jti) -> Result<bool> {
|
|
let mut conn = self.redis.clone();
|
|
self.cache.check_revoked(jti.as_str(), &mut conn).await
|
|
}
|
|
|
|
/// Add token to blacklist (for revocation)
|
|
pub async fn revoke_token(&self, jti: &Jti, ttl_seconds: u64) -> Result<()> {
|
|
if ttl_seconds == 0 {
|
|
return Ok(()); // Already expired
|
|
}
|
|
|
|
let key = jti.redis_key();
|
|
let mut conn = self.redis.clone();
|
|
|
|
// Set with TTL = remaining token lifetime
|
|
// Redis will auto-clean when token would have expired
|
|
let _: () = conn
|
|
.set_ex(&key, "revoked", ttl_seconds)
|
|
.await
|
|
.context("Failed to add token to blacklist")?;
|
|
|
|
// Invalidate cache entry to immediately reflect revocation
|
|
self.cache.invalidate(jti.as_str());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get cache statistics for monitoring
|
|
pub fn cache_stats(&self) -> CacheStats {
|
|
self.cache.stats()
|
|
}
|
|
|
|
/// Clear local cache (useful for testing or troubleshooting)
|
|
pub fn clear_cache(&self) {
|
|
self.cache.clear();
|
|
}
|
|
|
|
/// Reset cache statistics
|
|
pub fn reset_cache_stats(&self) {
|
|
self.cache.reset_stats();
|
|
}
|
|
}
|
|
|
|
/// High-performance JWT validator with key caching
|
|
pub struct JwtService {
|
|
/// Cached decoding key (avoids repeated parsing)
|
|
decoding_key: Arc<DecodingKey>,
|
|
/// Validation rules
|
|
validation: Validation,
|
|
}
|
|
|
|
impl JwtService {
|
|
/// Create new JWT service with cached key
|
|
///
|
|
/// PERFORMANCE: Key is parsed once and cached in Arc for <1μs validation
|
|
pub fn new(secret: String, issuer: String, audience: String) -> Self {
|
|
let decoding_key = Arc::new(DecodingKey::from_secret(secret.as_bytes()));
|
|
|
|
let mut validation = Validation::new(Algorithm::HS256);
|
|
validation.set_issuer(&[&issuer]);
|
|
validation.set_audience(&[&audience]);
|
|
validation.validate_exp = true;
|
|
validation.validate_nbf = true;
|
|
validation.leeway = 0; // Strict for HFT security
|
|
|
|
Self {
|
|
decoding_key,
|
|
validation,
|
|
}
|
|
}
|
|
|
|
/// Validate JWT and extract claims (TARGET: <1μs with cached key)
|
|
///
|
|
/// PERFORMANCE: Cached DecodingKey eliminates key parsing overhead
|
|
pub fn validate_token(&self, token: &str) -> Result<JwtClaims> {
|
|
// Basic validation before expensive decode
|
|
if token.is_empty() || token.len() > 8192 {
|
|
return Err(anyhow::anyhow!("Invalid token format"));
|
|
}
|
|
|
|
let token_data =
|
|
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
|
|
error!("JWT decode failed in interceptor: {:?}", e);
|
|
let issuer_str = self
|
|
.validation
|
|
.iss
|
|
.as_ref()
|
|
.map(|v| v.iter().cloned().collect::<Vec<_>>().join(", "))
|
|
.unwrap_or_default();
|
|
let audience_str = self
|
|
.validation
|
|
.aud
|
|
.as_ref()
|
|
.map(|v| v.iter().cloned().collect::<Vec<_>>().join(", "))
|
|
.unwrap_or_default();
|
|
error!(
|
|
"Expected issuer: {}, audience: {}",
|
|
issuer_str, audience_str
|
|
);
|
|
anyhow::anyhow!("JWT validation failed: {}", e)
|
|
})?;
|
|
|
|
// Additional security checks
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.context("System time error")?
|
|
.as_secs();
|
|
|
|
if token_data.claims.exp <= now {
|
|
return Err(anyhow::anyhow!("Token expired"));
|
|
}
|
|
|
|
if token_data.claims.jti.is_empty() {
|
|
return Err(anyhow::anyhow!("Missing JTI (required for revocation)"));
|
|
}
|
|
|
|
if token_data.claims.sub.is_empty() {
|
|
return Err(anyhow::anyhow!("Missing subject claim"));
|
|
}
|
|
|
|
// Check if token was issued in the future (clock skew attack)
|
|
if token_data.claims.iat > now + 60 {
|
|
return Err(anyhow::anyhow!("Token issued in the future"));
|
|
}
|
|
|
|
// Check token age (max 1 hour from issuance)
|
|
let token_age = now
|
|
.checked_sub(token_data.claims.iat)
|
|
.ok_or_else(|| anyhow::anyhow!("Invalid token timestamp (iat in future)"))?;
|
|
|
|
if token_age > 3600 {
|
|
return Err(anyhow::anyhow!("Token too old (max age: 1 hour)"));
|
|
}
|
|
|
|
Ok(token_data.claims)
|
|
}
|
|
}
|
|
|
|
/// High-performance authorization service with permission caching
|
|
pub struct AuthzService {
|
|
/// Cached user permissions (TARGET: <100ns lookups)
|
|
///
|
|
/// PERFORMANCE: DashMap provides concurrent access without RwLock overhead
|
|
permission_cache: Arc<DashMap<String, Vec<String>>>,
|
|
}
|
|
|
|
impl Default for AuthzService {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl AuthzService {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
permission_cache: Arc::new(DashMap::new()),
|
|
}
|
|
}
|
|
|
|
/// Check if user has required permission (TARGET: <100ns)
|
|
///
|
|
/// PERFORMANCE: In-memory DashMap lookup, no database queries
|
|
pub fn has_permission(&self, user_id: &str, permission: &str) -> bool {
|
|
if let Some(permissions) = self.permission_cache.get(user_id) {
|
|
permissions.contains(&permission.to_string())
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Cache user permissions for fast lookups
|
|
///
|
|
/// Called during user context injection (Layer 7)
|
|
pub fn cache_permissions(&self, user_id: String, permissions: Vec<String>) {
|
|
self.permission_cache.insert(user_id, permissions);
|
|
}
|
|
|
|
/// Clear cached permissions (for revocation or permission updates)
|
|
pub fn clear_cache(&self, user_id: &str) {
|
|
self.permission_cache.remove(user_id);
|
|
}
|
|
}
|
|
|
|
/// High-performance rate limiter with in-memory counters
|
|
#[derive(Clone)]
|
|
pub struct RateLimiter {
|
|
/// Per-user rate limiters (TARGET: <50ns)
|
|
///
|
|
/// PERFORMANCE: Governor provides O(1) atomic counter checks
|
|
limiters: Arc<
|
|
DashMap<
|
|
String,
|
|
Arc<
|
|
GovernorRateLimiter<
|
|
String,
|
|
DefaultKeyedStateStore<String>,
|
|
governor::clock::DefaultClock,
|
|
>,
|
|
>,
|
|
>,
|
|
>,
|
|
/// Default quota (requests per second)
|
|
default_quota: Quota,
|
|
}
|
|
|
|
impl RateLimiter {
|
|
pub fn new(requests_per_second: u32) -> Result<Self, String> {
|
|
let default_quota =
|
|
Quota::per_second(NonZeroU32::new(requests_per_second).ok_or_else(|| {
|
|
format!("Invalid rate limit: {} (must be > 0)", requests_per_second)
|
|
})?);
|
|
|
|
Ok(Self {
|
|
limiters: Arc::new(DashMap::new()),
|
|
default_quota,
|
|
})
|
|
}
|
|
|
|
/// Check if request is allowed (TARGET: <50ns)
|
|
///
|
|
/// PERFORMANCE: Atomic counter increment, no locks
|
|
pub fn check_rate_limit(&self, user_id: &str) -> bool {
|
|
let limiter = self
|
|
.limiters
|
|
.entry(user_id.to_string())
|
|
.or_insert_with(|| Arc::new(GovernorRateLimiter::dashmap(self.default_quota)));
|
|
|
|
limiter.check_key(&user_id.to_string()).is_ok()
|
|
}
|
|
}
|
|
|
|
/// Async audit logger (non-blocking)
|
|
pub struct AuditLogger {
|
|
/// Audit log buffer (async writes)
|
|
enabled: bool,
|
|
}
|
|
|
|
impl AuditLogger {
|
|
pub fn new(enabled: bool) -> Self {
|
|
Self { enabled }
|
|
}
|
|
|
|
/// Log authentication success (non-blocking)
|
|
///
|
|
/// PERFORMANCE: Spawns background task, does not block request path
|
|
pub fn log_auth_success(&self, user_id: &str, client_ip: Option<&str>) {
|
|
if !self.enabled {
|
|
return;
|
|
}
|
|
|
|
let user_id = user_id.to_string();
|
|
let client_ip = client_ip.map(|s| s.to_string());
|
|
|
|
tokio::spawn(async move {
|
|
info!(
|
|
user_id = %user_id,
|
|
client_ip = ?client_ip,
|
|
"Authentication successful"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Log authentication failure (non-blocking)
|
|
pub fn log_auth_failure(&self, reason: &str, client_ip: Option<&str>) {
|
|
if !self.enabled {
|
|
return;
|
|
}
|
|
|
|
let reason = reason.to_string();
|
|
let client_ip = client_ip.map(|s| s.to_string());
|
|
|
|
tokio::spawn(async move {
|
|
warn!(
|
|
reason = %reason,
|
|
client_ip = ?client_ip,
|
|
"Authentication failed"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Main authentication interceptor with 6-layer security
|
|
#[derive(Clone)]
|
|
pub struct AuthInterceptor {
|
|
jwt_service: Arc<JwtService>,
|
|
revocation_service: Arc<RevocationService>,
|
|
authz_service: Arc<AuthzService>,
|
|
rate_limiter: Arc<RateLimiter>,
|
|
audit_logger: Arc<AuditLogger>,
|
|
}
|
|
|
|
impl AuthInterceptor {
|
|
/// Create new authentication interceptor
|
|
pub fn new(
|
|
jwt_service: JwtService,
|
|
revocation_service: RevocationService,
|
|
authz_service: AuthzService,
|
|
rate_limiter: RateLimiter,
|
|
audit_logger: AuditLogger,
|
|
) -> Self {
|
|
Self {
|
|
jwt_service: Arc::new(jwt_service),
|
|
revocation_service: Arc::new(revocation_service),
|
|
authz_service: Arc::new(authz_service),
|
|
rate_limiter: Arc::new(rate_limiter),
|
|
audit_logger: Arc::new(audit_logger),
|
|
}
|
|
}
|
|
|
|
/// Authenticate request with 6-layer security (TARGET: <10μs total)
|
|
///
|
|
/// ## Performance Breakdown (Target vs Actual)
|
|
/// - Layer 1 (mTLS): Handled by tonic-tls (0μs in-band)
|
|
///
|
|
/// - Layer 2 (Extract JWT): <100ns (header lookup)
|
|
/// - Layer 3 (Revocation): <500ns (Redis in-memory)
|
|
///
|
|
/// - Layer 4 (JWT Validation): <1μs (cached key)
|
|
/// - Layer 5 (Authorization): <100ns (cached permissions)
|
|
///
|
|
/// - Layer 6 (Rate Limit): <50ns (atomic counter)
|
|
/// - Layer 7 (Context Inject): <100ns (metadata write)
|
|
///
|
|
/// - Layer 8 (Audit Log): 0ns (async, non-blocking)
|
|
/// **Total**: ~2μs (well under 10μs target)
|
|
pub async fn authenticate<T>(&self, mut request: Request<T>) -> Result<Request<T>, Status> {
|
|
let start = Instant::now();
|
|
|
|
// Extract client IP for audit logging
|
|
let client_ip = self.extract_client_ip(&request);
|
|
|
|
// Layer 1: mTLS client certificate
|
|
// NOTE: Handled by tonic-tls at transport layer, certificate is already validated
|
|
// We can extract it from request extensions if needed for additional checks
|
|
|
|
// Layer 2: Extract JWT from authorization header (~100ns)
|
|
let bearer_token = self.extract_bearer_token(&request).ok_or_else(|| {
|
|
self.audit_logger
|
|
.log_auth_failure("missing_token", client_ip.as_deref());
|
|
Status::unauthenticated("Authorization header required")
|
|
})?;
|
|
|
|
// Layer 4: Validate JWT signature and expiration (<1μs with cached key)
|
|
// NOTE: Moved before revocation check for fail-fast on invalid tokens
|
|
let claims = self
|
|
.jwt_service
|
|
.validate_token(&bearer_token)
|
|
.map_err(|e| {
|
|
self.audit_logger
|
|
.log_auth_failure(&format!("invalid_jwt: {}", e), client_ip.as_deref());
|
|
Status::unauthenticated("Invalid or expired token")
|
|
})?;
|
|
|
|
// Layer 3: Check JWT revocation (<500ns with Redis)
|
|
let jti = Jti::from_string(claims.jti.clone());
|
|
let is_revoked = self
|
|
.revocation_service
|
|
.is_revoked(&jti)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Revocation check failed: {}", e);
|
|
Status::unauthenticated("Authentication service unavailable")
|
|
})?;
|
|
|
|
if is_revoked {
|
|
self.audit_logger
|
|
.log_auth_failure("token_revoked", client_ip.as_deref());
|
|
return Err(Status::unauthenticated("Token has been revoked"));
|
|
}
|
|
|
|
// Layer 5: RBAC permission check (<100ns with cached permissions)
|
|
// Cache permissions for this user if not already cached
|
|
self.authz_service
|
|
.cache_permissions(claims.sub.clone(), claims.permissions.clone());
|
|
|
|
// Basic permission check example (service-specific checks happen in handlers)
|
|
if !self.authz_service.has_permission(&claims.sub, "api.access") {
|
|
self.audit_logger
|
|
.log_auth_failure("insufficient_permissions", client_ip.as_deref());
|
|
return Err(Status::permission_denied("Insufficient permissions"));
|
|
}
|
|
|
|
// Layer 6: Rate limiting check (<50ns with atomic counters)
|
|
if !self.rate_limiter.check_rate_limit(&claims.sub) {
|
|
self.audit_logger
|
|
.log_auth_failure("rate_limit_exceeded", client_ip.as_deref());
|
|
return Err(Status::resource_exhausted("Rate limit exceeded"));
|
|
}
|
|
|
|
// Layer 7: Inject user context into request metadata
|
|
let user_context = UserContext {
|
|
user_id: claims.sub.clone(),
|
|
roles: claims.roles.clone(),
|
|
permissions: claims.permissions.clone(),
|
|
session_id: claims
|
|
.session_id
|
|
.clone()
|
|
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
|
authenticated_at: start,
|
|
};
|
|
|
|
// Add context to request extensions for downstream handlers
|
|
request.extensions_mut().insert(user_context);
|
|
|
|
// CRITICAL: Add x-user-id to metadata for proxy forwarding
|
|
// The trading/backtesting/ML proxies expect this header to identify the user
|
|
request.metadata_mut().insert(
|
|
"x-user-id",
|
|
claims
|
|
.sub
|
|
.parse()
|
|
.map_err(|_| Status::internal("Invalid user_id encoding"))?,
|
|
);
|
|
|
|
// Layer 8: Async audit logging (non-blocking, 0ns overhead)
|
|
self.audit_logger
|
|
.log_auth_success(&claims.sub, client_ip.as_deref());
|
|
|
|
let elapsed = start.elapsed();
|
|
debug!(
|
|
"Authentication successful for user {} in {:?}",
|
|
claims.sub, elapsed
|
|
);
|
|
|
|
// Warn if we exceed 10μs target
|
|
if elapsed > Duration::from_micros(10) {
|
|
warn!(
|
|
"Authentication latency exceeded target: {:?} > 10μs",
|
|
elapsed
|
|
);
|
|
}
|
|
|
|
Ok(request)
|
|
}
|
|
|
|
/// Extract bearer token from Authorization header
|
|
fn extract_bearer_token<T>(&self, request: &Request<T>) -> Option<String> {
|
|
request
|
|
.metadata()
|
|
.get("authorization")
|
|
.and_then(|auth| auth.to_str().ok())
|
|
.and_then(|auth| {
|
|
if auth.starts_with("Bearer ") {
|
|
Some(auth[7..].to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Extract client IP from request metadata
|
|
fn extract_client_ip<T>(&self, request: &Request<T>) -> Option<String> {
|
|
request
|
|
.metadata()
|
|
.get("x-forwarded-for")
|
|
.and_then(|ip| ip.to_str().ok())
|
|
.map(|ip| ip.to_string())
|
|
.or_else(|| {
|
|
request
|
|
.metadata()
|
|
.get("x-real-ip")
|
|
.and_then(|ip| ip.to_str().ok())
|
|
.map(|ip| ip.to_string())
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Implement Tonic's Interceptor trait for gRPC integration
|
|
impl tonic::service::Interceptor for AuthInterceptor {
|
|
fn call(&mut self, request: Request<()>) -> Result<Request<()>, Status> {
|
|
// Tonic's Interceptor is synchronous, but we need async authentication
|
|
// Use block_in_place to run async code in a blocking context
|
|
tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(self.authenticate(request))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_jti_generation() {
|
|
let jti1 = Jti::new();
|
|
let jti2 = Jti::new();
|
|
|
|
assert_ne!(jti1, jti2);
|
|
assert!(!jti1.as_str().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_jwt_claims_defaults() {
|
|
let claims = JwtClaims {
|
|
jti: "test-jti".to_string(),
|
|
sub: "user123".to_string(),
|
|
iat: 1234567890,
|
|
exp: 1234571490,
|
|
nbf: Some(1234567890),
|
|
iss: "foxhunt".to_string(),
|
|
aud: "api-gateway".to_string(),
|
|
roles: vec!["trader".to_string()],
|
|
permissions: vec!["api.access".to_string()],
|
|
token_type: "access".to_string(),
|
|
session_id: Some("session-123".to_string()),
|
|
};
|
|
|
|
assert_eq!(claims.token_type, "access");
|
|
assert!(claims.session_id.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_jwt_service_validation() {
|
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
|
|
|
let secret = "test-secret-must-be-at-least-64-characters-long-for-security-validation-ok"
|
|
.to_string();
|
|
let jwt_service = JwtService::new(
|
|
secret.clone(),
|
|
"test-issuer".to_string(),
|
|
"test-audience".to_string(),
|
|
);
|
|
|
|
let claims = JwtClaims {
|
|
jti: Jti::new().0,
|
|
sub: "user123".to_string(),
|
|
iat: SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs(),
|
|
exp: SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
+ 3600,
|
|
nbf: Some(
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs(),
|
|
),
|
|
iss: "test-issuer".to_string(),
|
|
aud: "test-audience".to_string(),
|
|
roles: vec!["trader".to_string()],
|
|
permissions: vec!["api.access".to_string()],
|
|
token_type: "access".to_string(),
|
|
session_id: Some(Uuid::new_v4().to_string()),
|
|
};
|
|
|
|
let token = encode(
|
|
&Header::default(),
|
|
&claims,
|
|
&EncodingKey::from_secret(secret.as_bytes()),
|
|
)
|
|
.unwrap();
|
|
|
|
let validated = jwt_service.validate_token(&token).unwrap();
|
|
assert_eq!(validated.sub, "user123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_authz_service_permissions() {
|
|
let authz = AuthzService::new();
|
|
|
|
authz.cache_permissions(
|
|
"user123".to_string(),
|
|
vec!["api.access".to_string(), "trading.submit".to_string()],
|
|
);
|
|
|
|
assert!(authz.has_permission("user123", "api.access"));
|
|
assert!(authz.has_permission("user123", "trading.submit"));
|
|
assert!(!authz.has_permission("user123", "admin.access"));
|
|
|
|
authz.clear_cache("user123");
|
|
assert!(!authz.has_permission("user123", "api.access"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rate_limiter() {
|
|
let limiter = RateLimiter::new(10).expect("Valid rate limit"); // 10 requests per second
|
|
|
|
// Should allow first request
|
|
assert!(limiter.check_rate_limit("user123"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_revocation_cache_hit() {
|
|
// Create a mock Redis connection manager for testing
|
|
// In real tests, you would use a real Redis instance or mock
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Test cache statistics initialization
|
|
let stats = cache.stats();
|
|
assert_eq!(stats.hits, 0);
|
|
assert_eq!(stats.misses, 0);
|
|
assert_eq!(stats.total, 0);
|
|
assert_eq!(stats.hit_rate, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_ttl_expiration() {
|
|
let cache = LocalRevocationCache::new(Duration::from_millis(10));
|
|
|
|
// Insert an entry
|
|
cache.cache.insert(
|
|
"test_token".to_string(),
|
|
CachedRevocationResult {
|
|
is_revoked: false,
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
|
|
// Should be present immediately
|
|
assert!(cache.cache.contains_key("test_token"));
|
|
|
|
// Wait for TTL to expire
|
|
std::thread::sleep(Duration::from_millis(15));
|
|
|
|
// Entry should still exist in DashMap but will be removed on next access
|
|
// The check_revoked method handles TTL expiration
|
|
assert_eq!(cache.cache.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_invalidation() {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Insert an entry
|
|
cache.cache.insert(
|
|
"test_token".to_string(),
|
|
CachedRevocationResult {
|
|
is_revoked: false,
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
|
|
assert!(cache.cache.contains_key("test_token"));
|
|
|
|
// Invalidate the entry
|
|
cache.invalidate("test_token");
|
|
|
|
assert!(!cache.cache.contains_key("test_token"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_clear() {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Insert multiple entries
|
|
for i in 0..10 {
|
|
cache.cache.insert(
|
|
format!("token_{}", i),
|
|
CachedRevocationResult {
|
|
is_revoked: false,
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
}
|
|
|
|
assert_eq!(cache.cache.len(), 10);
|
|
|
|
// Clear all entries
|
|
cache.clear();
|
|
|
|
assert_eq!(cache.cache.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_stats_tracking() {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Simulate cache hits
|
|
cache
|
|
.hits
|
|
.fetch_add(95, std::sync::atomic::Ordering::Relaxed);
|
|
cache
|
|
.misses
|
|
.fetch_add(5, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
let stats = cache.stats();
|
|
assert_eq!(stats.hits, 95);
|
|
assert_eq!(stats.misses, 5);
|
|
assert_eq!(stats.total, 100);
|
|
assert_eq!(stats.hit_rate, 95.0);
|
|
|
|
// Reset stats
|
|
cache.reset_stats();
|
|
|
|
let stats = cache.stats();
|
|
assert_eq!(stats.hits, 0);
|
|
assert_eq!(stats.misses, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_concurrent_access() {
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
|
|
let cache = Arc::new(LocalRevocationCache::new(Duration::from_secs(60)));
|
|
|
|
// Insert initial entry
|
|
cache.cache.insert(
|
|
"shared_token".to_string(),
|
|
CachedRevocationResult {
|
|
is_revoked: false,
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
|
|
let mut handles = vec![];
|
|
|
|
// Spawn 10 threads that all try to access the same cache entry
|
|
for _ in 0..10 {
|
|
let cache_clone = cache.clone();
|
|
let handle = thread::spawn(move || {
|
|
for _ in 0..100 {
|
|
let entry = cache_clone.cache.get("shared_token");
|
|
assert!(entry.is_some());
|
|
}
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Wait for all threads to complete
|
|
for handle in handles {
|
|
handle.join().expect("INVARIANT: Thread should complete successfully");
|
|
}
|
|
|
|
// Entry should still exist
|
|
assert!(cache.cache.contains_key("shared_token"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_stats_struct() {
|
|
let stats = CacheStats {
|
|
hits: 950,
|
|
misses: 50,
|
|
total: 1000,
|
|
hit_rate: 95.0,
|
|
entries: 100,
|
|
};
|
|
|
|
assert_eq!(stats.hits, 950);
|
|
assert_eq!(stats.misses, 50);
|
|
assert_eq!(stats.total, 1000);
|
|
assert_eq!(stats.hit_rate, 95.0);
|
|
assert_eq!(stats.entries, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cached_revocation_result() {
|
|
let result = CachedRevocationResult {
|
|
is_revoked: true,
|
|
cached_at: Instant::now(),
|
|
};
|
|
|
|
assert!(result.is_revoked);
|
|
assert!(result.cached_at.elapsed() < Duration::from_secs(1));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_memory_efficiency() {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Insert 1000 entries
|
|
for i in 0u32..1000 {
|
|
cache.cache.insert(
|
|
format!("token_{}", i),
|
|
CachedRevocationResult {
|
|
is_revoked: i.checked_rem(100).expect("Modulo overflow") == 0, // 1% revoked
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
}
|
|
|
|
assert_eq!(cache.cache.len(), 1000);
|
|
|
|
// Verify mix of revoked and valid tokens
|
|
let mut revoked_count: u32 = 0;
|
|
for i in 0u32..1000 {
|
|
if let Some(entry) = cache.cache.get(&format!("token_{}", i)) {
|
|
if entry.is_revoked {
|
|
revoked_count = revoked_count
|
|
.checked_add(1)
|
|
.expect("Revoked count overflow");
|
|
}
|
|
}
|
|
}
|
|
|
|
assert_eq!(revoked_count, 10); // 1% of 1000 = 10
|
|
}
|
|
}
|