arcly_http_identity/risk.rs
1//! Adaptive / risk-based authentication. A [`RiskEngine`] scores a login
2//! attempt from environmental signals; the login flow uses the verdict to
3//! allow, force step-up MFA, or deny.
4//!
5//! The engine is a trait so apps can plug in device fingerprint stores, geo/IP
6//! reputation feeds, or an external fraud service. A default heuristic engine is
7//! provided for the common "new IP / new device ⇒ step up" case.
8
9use async_trait::async_trait;
10
11/// Signals available for a risk decision, gathered at the boundary. Extend by
12/// reading `RequestContext::client_ip()` and headers in your controller.
13#[derive(Debug, Clone, Default)]
14pub struct RiskSignal {
15 pub user_id: String,
16 pub ip: Option<String>,
17 /// A stable device/browser fingerprint, if the app computes one.
18 pub device_id: Option<String>,
19 /// Whether this IP has been seen for this user before.
20 pub known_ip: bool,
21 /// Whether this device has been seen for this user before.
22 pub known_device: bool,
23 /// Coarse geo (country code) if resolved from IP.
24 pub country: Option<String>,
25 /// The user's usual country, if known.
26 pub usual_country: Option<String>,
27 /// The originating request's W3C trace id (`ctx.trace_id_hex()`), carried so
28 /// a federated-login audit record correlates to the request span.
29 pub trace_id: Option<String>,
30}
31
32/// What to do with an attempt given its risk.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum RiskVerdict {
35 /// Proceed normally.
36 Allow,
37 /// Force a second factor even if the account would otherwise skip it.
38 StepUp,
39 /// Block outright.
40 Deny,
41}
42
43/// Scores login attempts. Implementations must be cheap and non-blocking on the
44/// hot path (do any I/O against a cache, not a synchronous DB call).
45#[async_trait]
46pub trait RiskEngine: Send + Sync + 'static {
47 async fn assess(&self, signal: &RiskSignal) -> RiskVerdict;
48}
49
50/// A conservative default: unknown device *or* a country change forces step-up;
51/// everything familiar is allowed. Never denies outright (that's a policy an app
52/// layers on top with its own reputation data).
53pub struct HeuristicRiskEngine;
54
55#[async_trait]
56impl RiskEngine for HeuristicRiskEngine {
57 async fn assess(&self, s: &RiskSignal) -> RiskVerdict {
58 let country_changed = matches!(
59 (&s.country, &s.usual_country),
60 (Some(c), Some(u)) if c != u
61 );
62 if !s.known_device || country_changed || !s.known_ip {
63 RiskVerdict::StepUp
64 } else {
65 RiskVerdict::Allow
66 }
67 }
68}