use std::{
net::{IpAddr, Ipv6Addr, SocketAddr},
sync::Arc,
};
use axum::{
Json,
extract::{ConnectInfo, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use dashmap::DashMap;
use serde::Serialize;
use uuid::Uuid;
use crate::{
audit::logger::{AuditEventType, SecretType, get_audit_logger},
error::Result,
session::{SessionStore, unix_now},
};
const ANON_SESSION_TTL_SECS: u64 = 7 * 24 * 3600;
const ANON_RATE_WINDOW_SECS: u64 = 3_600;
const ANON_RATE_MAX: u32 = 10;
const ANON_MAX_TRACKED: usize = 100_000;
const EVICTION_SAMPLE: usize = 32;
#[derive(Debug, Clone)]
struct RateRecord {
count: u32,
window_start: u64,
}
#[derive(Clone)]
pub struct AnonSignupState {
pub session_store: Arc<dyn SessionStore>,
rate_counters: Option<Arc<DashMap<String, RateRecord>>>,
max_tracked: usize,
}
impl AnonSignupState {
#[must_use]
pub fn new(session_store: Arc<dyn SessionStore>) -> Self {
Self {
session_store,
rate_counters: Some(Arc::new(DashMap::new())),
max_tracked: ANON_MAX_TRACKED,
}
}
#[must_use]
pub const fn new_without_limiter(session_store: Arc<dyn SessionStore>) -> Self {
Self {
session_store,
rate_counters: None,
max_tracked: ANON_MAX_TRACKED,
}
}
#[must_use]
pub const fn with_max_tracked(mut self, max_tracked: usize) -> Self {
self.max_tracked = max_tracked;
self
}
#[must_use]
pub fn tracked_count(&self) -> usize {
self.rate_counters.as_ref().map_or(0, |counters| counters.len())
}
fn check_rate_limit(&self, ip: &str, now: u64) -> bool {
let Some(counters) = &self.rate_counters else {
return true;
};
let key = client_key(ip);
if !counters.contains_key(&key) && counters.len() >= self.max_tracked {
self.make_room(counters, now);
}
let mut record = counters.entry(key).or_insert(RateRecord {
count: 0,
window_start: now,
});
if now.saturating_sub(record.window_start) >= ANON_RATE_WINDOW_SECS {
record.count = 0;
record.window_start = now;
}
if record.count >= ANON_RATE_MAX {
return false;
}
record.count += 1;
true
}
fn make_room(&self, counters: &DashMap<String, RateRecord>, now: u64) {
counters.retain(|_, r| now.saturating_sub(r.window_start) < ANON_RATE_WINDOW_SECS);
let target = self.max_tracked - (self.max_tracked / 16).max(1);
while counters.len() > target {
let oldest = counters
.iter()
.take(EVICTION_SAMPLE)
.min_by_key(|r| r.window_start)
.map(|r| r.key().clone());
match oldest {
Some(key) => {
counters.remove(&key);
},
None => break,
}
}
}
}
fn client_key(ip: &str) -> String {
match ip.parse::<IpAddr>().map(|addr| addr.to_canonical()) {
Ok(IpAddr::V6(v6)) => {
let prefix = u128::from(v6) & !u128::from(u64::MAX);
format!("{}/64", Ipv6Addr::from(prefix))
},
Ok(v4) => v4.to_string(),
Err(_) => ip.to_string(),
}
}
#[derive(Debug, Serialize)]
pub struct AnonSignupResponse {
pub user_id: String,
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
}
pub async fn anon_signup(
State(state): State<Arc<AnonSignupState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Response {
let logger = get_audit_logger();
let ip = addr.ip().to_string();
let now = match unix_now() {
Ok(t) => t,
Err(e) => {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
"anon_signup:clock",
&e.to_string(),
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
},
};
if !state.check_rate_limit(&ip, now) {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
"anon_signup:rate_limited",
"too many anonymous signups from this IP",
);
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({"error": "rate_limited"})),
)
.into_response();
}
let user_id = format!("anon_{}", Uuid::new_v4().as_simple());
let expires_at = now + ANON_SESSION_TTL_SECS;
match state.session_store.create_session(&user_id, expires_at).await {
Ok(tokens) => {
logger.log_success(
AuditEventType::SessionTokenCreated,
SecretType::SessionToken,
Some(user_id.clone()),
"anon_signup",
);
(
StatusCode::OK,
Json(AnonSignupResponse {
user_id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
}),
)
.into_response()
},
Err(e) => {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
"anon_signup:session_create",
&e.to_string(),
);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
},
}
}
pub async fn upgrade_anonymous_session(
session_store: &dyn SessionStore,
anon_user_id: &str,
new_user_id: &str,
expires_at: u64,
) -> Result<crate::session::TokenPair> {
session_store.revoke_all_sessions(anon_user_id).await?;
session_store.create_session(new_user_id, expires_at).await
}
#[allow(clippy::unwrap_used)] #[cfg(test)]
mod tests;