use chrono::{DateTime, Utc};
use http::HeaderMap;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AuthMethod {
Passkey,
OAuth2,
FedCM,
}
impl fmt::Display for AuthMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthMethod::Passkey => write!(f, "passkey"),
AuthMethod::OAuth2 => write!(f, "oauth2"),
AuthMethod::FedCM => write!(f, "fedcm"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct LoginHistoryEntry {
pub id: Option<i64>,
pub user_id: String,
pub timestamp: DateTime<Utc>,
pub auth_method: String,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub success: bool,
pub credential_id: Option<String>,
pub provider: Option<String>,
pub provider_user_id: Option<String>,
pub failure_reason: Option<String>,
pub aaguid: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct AuthMethodDetails {
pub credential_id: Option<String>,
pub provider: Option<String>,
pub provider_user_id: Option<String>,
pub aaguid: Option<String>,
pub email: Option<String>,
}
impl LoginHistoryEntry {
pub(crate) fn success(
user_id: String,
auth_method: AuthMethod,
context: LoginContext,
details: AuthMethodDetails,
) -> Self {
Self {
id: None,
user_id,
timestamp: Utc::now(),
auth_method: auth_method.to_string(),
ip_address: context.ip_address,
user_agent: context.user_agent.map(|ua| truncate_user_agent(&ua)),
success: true,
credential_id: details.credential_id,
provider: details.provider,
provider_user_id: details.provider_user_id,
failure_reason: None,
aaguid: details.aaguid,
email: details.email,
}
}
pub(crate) fn failure(
user_id: String,
auth_method: AuthMethod,
context: LoginContext,
credential_id: Option<String>,
reason: String,
) -> Self {
Self {
id: None,
user_id,
timestamp: Utc::now(),
auth_method: auth_method.to_string(),
ip_address: context.ip_address,
user_agent: context.user_agent.map(|ua| truncate_user_agent(&ua)),
success: false,
credential_id,
provider: None,
provider_user_id: None,
failure_reason: Some(reason),
aaguid: None,
email: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct LoginContext {
ip_address: Option<String>,
user_agent: Option<String>,
}
impl LoginContext {
pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
let ip_address = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.or_else(|| {
headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
Self {
ip_address,
user_agent,
}
}
}
fn truncate_user_agent(ua: &str) -> String {
const MAX_LENGTH: usize = 512;
if ua.len() > MAX_LENGTH {
ua[..MAX_LENGTH].to_string()
} else {
ua.to_string()
}
}
#[cfg(test)]
mod tests;