Skip to main content

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}