use super::types::AuthResult;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::path::PathBuf;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use tracing::{error, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthAuditEvent {
pub timestamp: DateTime<Utc>,
pub ip_address: String,
pub user_agent: Option<String>,
pub auth_method: AuthMethod,
pub result: AuthAuditResult,
pub username: Option<String>,
pub failure_reason: Option<String>,
pub path: Option<String>,
pub http_method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Jwt,
OAuth2,
ApiKey,
Basic,
None,
}
impl std::fmt::Display for AuthMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthMethod::Jwt => write!(f, "jwt"),
AuthMethod::OAuth2 => write!(f, "oauth2"),
AuthMethod::ApiKey => write!(f, "api_key"),
AuthMethod::Basic => write!(f, "basic"),
AuthMethod::None => write!(f, "none"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthAuditResult {
Success,
Failure,
Expired,
Invalid,
NetworkError,
ServerError,
NoAuth,
}
impl From<&AuthResult> for AuthAuditResult {
fn from(result: &AuthResult) -> Self {
match result {
AuthResult::Success(_) => AuthAuditResult::Success,
AuthResult::Failure(_) => AuthAuditResult::Failure,
AuthResult::TokenExpired => AuthAuditResult::Expired,
AuthResult::TokenInvalid(_) => AuthAuditResult::Invalid,
AuthResult::NetworkError(_) => AuthAuditResult::NetworkError,
AuthResult::ServerError(_) => AuthAuditResult::ServerError,
AuthResult::None => AuthAuditResult::NoAuth,
}
}
}
#[derive(Debug, Clone)]
pub struct AuditLogConfig {
pub enabled: bool,
pub file_path: PathBuf,
pub log_success: bool,
pub log_failures: bool,
pub json_format: bool,
}
impl Default for AuditLogConfig {
fn default() -> Self {
Self {
enabled: true,
file_path: PathBuf::from("/var/log/mockforge/auth-audit.log"),
log_success: true,
log_failures: true,
json_format: true,
}
}
}
pub struct AuthAuditLogger {
config: AuditLogConfig,
}
impl AuthAuditLogger {
pub fn new(config: AuditLogConfig) -> Self {
Self { config }
}
pub async fn log_event(&self, event: AuthAuditEvent) {
if !self.config.enabled {
return;
}
let should_log = match event.result {
AuthAuditResult::Success => self.config.log_success,
_ => self.config.log_failures,
};
if !should_log {
return;
}
match event.result {
AuthAuditResult::Success => {
info!(
ip = %event.ip_address,
method = %event.auth_method,
username = ?event.username,
path = ?event.path,
"Authentication successful"
);
}
_ => {
info!(
ip = %event.ip_address,
method = %event.auth_method,
result = ?event.result,
reason = ?event.failure_reason,
path = ?event.path,
"Authentication failed"
);
}
}
if let Err(e) = self.write_to_file(&event).await {
error!("Failed to write audit log: {}", e);
}
}
async fn write_to_file(&self, event: &AuthAuditEvent) -> std::io::Result<()> {
if let Some(parent) = self.config.file_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.config.file_path)
.await?;
let log_entry = if self.config.json_format {
serde_json::to_string(event).unwrap_or_else(|_| {
format!(
"{{\"timestamp\":\"{}\",\"error\":\"Failed to serialize event\"}}",
event.timestamp.to_rfc3339()
)
})
} else {
format!(
"[{}] {} {} {} -> {:?} (user: {:?}, reason: {:?})\n",
event.timestamp.to_rfc3339(),
event.ip_address,
event.auth_method,
event.http_method.as_deref().unwrap_or("?"),
event.result,
event.username,
event.failure_reason
)
};
file.write_all(log_entry.as_bytes()).await?;
file.write_all(b"\n").await?;
file.flush().await?;
Ok(())
}
pub fn create_event(
ip: IpAddr,
user_agent: Option<String>,
method: AuthMethod,
result: &AuthResult,
path: Option<String>,
http_method: Option<String>,
) -> AuthAuditEvent {
let (username, failure_reason) = match result {
AuthResult::Success(claims) => (claims.username.clone(), None),
AuthResult::Failure(reason) => (None, Some(reason.clone())),
AuthResult::TokenInvalid(reason) => (None, Some(reason.clone())),
AuthResult::NetworkError(reason) => (None, Some(reason.clone())),
AuthResult::ServerError(reason) => (None, Some(reason.clone())),
AuthResult::TokenExpired => (None, Some("Token expired".to_string())),
AuthResult::None => (None, None),
};
AuthAuditEvent {
timestamp: Utc::now(),
ip_address: ip.to_string(),
user_agent,
auth_method: method,
result: AuthAuditResult::from(result),
username,
failure_reason,
path,
http_method,
}
}
}
pub struct AuthAuditEventBuilder {
event: AuthAuditEvent,
}
impl AuthAuditEventBuilder {
pub fn new(ip: IpAddr, method: AuthMethod) -> Self {
Self {
event: AuthAuditEvent {
timestamp: Utc::now(),
ip_address: ip.to_string(),
user_agent: None,
auth_method: method,
result: AuthAuditResult::NoAuth,
username: None,
failure_reason: None,
path: None,
http_method: None,
},
}
}
pub fn user_agent(mut self, ua: String) -> Self {
self.event.user_agent = Some(ua);
self
}
pub fn result(mut self, result: &AuthResult) -> Self {
self.event.result = AuthAuditResult::from(result);
match result {
AuthResult::Success(claims) => {
self.event.username = claims.username.clone();
}
AuthResult::Failure(reason) => {
self.event.failure_reason = Some(reason.clone());
}
AuthResult::TokenInvalid(reason) => {
self.event.failure_reason = Some(reason.clone());
}
AuthResult::NetworkError(reason) => {
self.event.failure_reason = Some(reason.clone());
}
AuthResult::ServerError(reason) => {
self.event.failure_reason = Some(reason.clone());
}
AuthResult::TokenExpired => {
self.event.failure_reason = Some("Token expired".to_string());
}
AuthResult::None => {}
}
self
}
pub fn path(mut self, path: String) -> Self {
self.event.path = Some(path);
self
}
pub fn http_method(mut self, method: String) -> Self {
self.event.http_method = Some(method);
self
}
pub fn build(self) -> AuthAuditEvent {
self.event
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::types::AuthClaims;
use tempfile::TempDir;
#[test]
fn test_auth_method_display() {
assert_eq!(AuthMethod::Jwt.to_string(), "jwt");
assert_eq!(AuthMethod::OAuth2.to_string(), "oauth2");
assert_eq!(AuthMethod::ApiKey.to_string(), "api_key");
assert_eq!(AuthMethod::Basic.to_string(), "basic");
assert_eq!(AuthMethod::None.to_string(), "none");
}
#[test]
fn test_auth_audit_result_from_auth_result() {
let result = AuthResult::Success(AuthClaims::new());
assert!(matches!(AuthAuditResult::from(&result), AuthAuditResult::Success));
let result = AuthResult::Failure("test".to_string());
assert!(matches!(AuthAuditResult::from(&result), AuthAuditResult::Failure));
let result = AuthResult::TokenExpired;
assert!(matches!(AuthAuditResult::from(&result), AuthAuditResult::Expired));
}
#[test]
fn test_event_builder() {
let ip = "127.0.0.1".parse().unwrap();
let event = AuthAuditEventBuilder::new(ip, AuthMethod::Jwt)
.user_agent("Mozilla/5.0".to_string())
.path("/api/test".to_string())
.http_method("GET".to_string())
.result(&AuthResult::Success(AuthClaims::new()))
.build();
assert_eq!(event.ip_address, "127.0.0.1");
assert_eq!(event.user_agent, Some("Mozilla/5.0".to_string()));
assert_eq!(event.path, Some("/api/test".to_string()));
assert_eq!(event.http_method, Some("GET".to_string()));
assert!(matches!(event.result, AuthAuditResult::Success));
}
#[tokio::test]
async fn test_audit_logger_creation() {
let temp_dir = TempDir::new().unwrap();
let log_path = temp_dir.path().join("audit.log");
let config = AuditLogConfig {
enabled: true,
file_path: log_path.clone(),
log_success: true,
log_failures: true,
json_format: true,
};
let logger = AuthAuditLogger::new(config);
let ip = "192.168.1.1".parse().unwrap();
let event = AuthAuditEventBuilder::new(ip, AuthMethod::ApiKey)
.result(&AuthResult::Success(AuthClaims::new()))
.build();
logger.log_event(event).await;
assert!(log_path.exists());
}
#[tokio::test]
async fn test_audit_logger_json_format() {
let temp_dir = TempDir::new().unwrap();
let log_path = temp_dir.path().join("audit-json.log");
let config = AuditLogConfig {
enabled: true,
file_path: log_path.clone(),
log_success: true,
log_failures: true,
json_format: true,
};
let logger = AuthAuditLogger::new(config);
let ip = "10.0.0.1".parse().unwrap();
let mut claims = AuthClaims::new();
claims.username = Some("testuser".to_string());
let event = AuthAuditEventBuilder::new(ip, AuthMethod::Basic)
.result(&AuthResult::Success(claims))
.build();
logger.log_event(event).await;
let content = tokio::fs::read_to_string(&log_path).await.unwrap();
assert!(content.contains("\"ip_address\":\"10.0.0.1\""));
assert!(content.contains("\"auth_method\":\"basic\""));
assert!(content.contains("\"username\":\"testuser\""));
}
#[tokio::test]
async fn test_audit_logger_disabled() {
let temp_dir = TempDir::new().unwrap();
let log_path = temp_dir.path().join("audit-disabled.log");
let config = AuditLogConfig {
enabled: false, file_path: log_path.clone(),
log_success: true,
log_failures: true,
json_format: true,
};
let logger = AuthAuditLogger::new(config);
let ip = "172.16.0.1".parse().unwrap();
let event = AuthAuditEventBuilder::new(ip, AuthMethod::Jwt)
.result(&AuthResult::Success(AuthClaims::new()))
.build();
logger.log_event(event).await;
assert!(!log_path.exists());
}
}