Files
IdeA/crates/infrastructure/src/pair_attempt_limiter.rs
Blomios 8fe93d1652 feat(backend): appareils appairés persistants, révocables et code éphémère (#77 B1-B4)
L'appairage ne survivait pas au redémarrage et son code, permanent, était
imprimé sur la sortie standard. Un appareil appairé devient une entité
persistante, nommée et révocable, derrière un code désormais éphémère.

- B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de
  domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens
  sont hachés en SHA-256 et comparés en temps constant (subtle) : le store
  ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à
  renouvellement glissant, lastSeenAtMs throttlé.
- B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute
  génération invalidant la précédente. POST /api/pairing-code authentifiée,
  flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait
  est supprimé.
- B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event
  DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans
  délai les WebSockets d'un appareil révoqué.
- B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et
  global sur horloge injectée, donc testable sans attente réelle.

La normalisation du code passe côté serveur : elle absorbe la dette #76, que
la seule normalisation frontend de #75 ne faisait que masquer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:26:56 +02:00

154 lines
4.5 KiB
Rust

//! In-memory pairing-attempt limiter.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use application::{PairAttemptDecision, PairAttemptLimiter, RateLimitKey};
use async_trait::async_trait;
use domain::ports::{Clock, StoreError};
const WINDOW_MS: i64 = 60_000;
const PER_ORIGIN_LIMIT: usize = 5;
const GLOBAL_LIMIT: usize = 30;
/// Process-local limiter for failed pairing attempts.
pub struct InMemoryPairAttemptLimiter {
clock: Arc<dyn Clock>,
inner: Mutex<LimiterState>,
}
#[derive(Default)]
struct LimiterState {
by_origin: HashMap<String, Vec<i64>>,
global: Vec<i64>,
}
impl InMemoryPairAttemptLimiter {
/// Creates a limiter using the supplied clock.
#[must_use]
pub fn new(clock: Arc<dyn Clock>) -> Self {
Self {
clock,
inner: Mutex::new(LimiterState::default()),
}
}
}
#[async_trait]
impl PairAttemptLimiter for InMemoryPairAttemptLimiter {
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError> {
let now = self.clock.now_millis().max(0);
let cutoff = now.saturating_sub(WINDOW_MS);
let mut inner = self.inner.lock().expect("pair limiter mutex poisoned");
prune(&mut inner.global, cutoff);
let origin = origin_key(&key);
let origin_bucket = inner.by_origin.entry(origin).or_default();
prune(origin_bucket, cutoff);
if origin_bucket.len() >= PER_ORIGIN_LIMIT || inner.global.len() >= GLOBAL_LIMIT {
return Ok(PairAttemptDecision::RateLimited);
}
Ok(PairAttemptDecision::Allowed)
}
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError> {
let now = self.clock.now_millis().max(0);
let cutoff = now.saturating_sub(WINDOW_MS);
let mut inner = self.inner.lock().expect("pair limiter mutex poisoned");
prune(&mut inner.global, cutoff);
inner.global.push(now);
let origin = origin_key(&key);
let origin_bucket = inner.by_origin.entry(origin).or_default();
prune(origin_bucket, cutoff);
origin_bucket.push(now);
Ok(())
}
}
fn origin_key(key: &RateLimitKey) -> String {
format!("{}:{}", key.route, key.origin)
}
fn prune(bucket: &mut Vec<i64>, cutoff: i64) {
bucket.retain(|timestamp| *timestamp > cutoff);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicI64, Ordering};
struct FakeClock(AtomicI64);
impl FakeClock {
fn new(now: i64) -> Self {
Self(AtomicI64::new(now))
}
fn set(&self, now: i64) {
self.0.store(now, Ordering::SeqCst);
}
}
impl Clock for FakeClock {
fn now_millis(&self) -> i64 {
self.0.load(Ordering::SeqCst)
}
}
fn key(origin: &str) -> RateLimitKey {
RateLimitKey {
origin: origin.to_owned(),
route: "/api/pair".to_owned(),
}
}
#[tokio::test]
async fn limits_five_failures_per_origin_per_minute_with_injected_clock() {
let clock = Arc::new(FakeClock::new(1_000));
let limiter = InMemoryPairAttemptLimiter::new(Arc::clone(&clock) as Arc<dyn Clock>);
for _ in 0..PER_ORIGIN_LIMIT {
assert_eq!(
limiter.check(key("1.2.3.4")).await.unwrap(),
PairAttemptDecision::Allowed
);
limiter.record_failure(key("1.2.3.4")).await.unwrap();
}
assert_eq!(
limiter.check(key("1.2.3.4")).await.unwrap(),
PairAttemptDecision::RateLimited
);
assert_eq!(
limiter.check(key("1.2.3.5")).await.unwrap(),
PairAttemptDecision::Allowed
);
clock.set(61_001);
assert_eq!(
limiter.check(key("1.2.3.4")).await.unwrap(),
PairAttemptDecision::Allowed
);
}
#[tokio::test]
async fn limits_thirty_failures_globally_per_minute() {
let clock = Arc::new(FakeClock::new(1_000));
let limiter = InMemoryPairAttemptLimiter::new(clock as Arc<dyn Clock>);
for n in 0..GLOBAL_LIMIT {
let key = key(&format!("10.0.0.{n}"));
assert_eq!(
limiter.check(key.clone()).await.unwrap(),
PairAttemptDecision::Allowed
);
limiter.record_failure(key).await.unwrap();
}
assert_eq!(
limiter.check(key("10.0.0.99")).await.unwrap(),
PairAttemptDecision::RateLimited
);
}
}