1
//! Throttle for the unauthenticated auth endpoints.
2
//!
3
//! `POST /auth/login` verifies a password against a hash, so without this it is
4
//! an unmetered online password oracle on a public site. The limiter keys on
5
//! the resolved client address (see [`crate::client_ip`]) rather than on
6
//! anything the request can choose.
7

            
8
use crate::client_ip::{TrustedProxies, throttle_key};
9
use axum::extract::{ConnectInfo, Request, State};
10
use axum::http::StatusCode;
11
use axum::middleware::Next;
12
use axum::response::{IntoResponse, Response};
13
use ratelimit::{Bucket, Decision, Limits};
14
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
15
use std::sync::{Arc, Mutex};
16
use std::time::Duration;
17

            
18
#[cfg(test)]
19
mod tests;
20

            
21
/// Attempts one address may make against the auth endpoints per window.
22
/// Generous for a human typing a password, cheap against a guessing run.
23
const MAX_ATTEMPTS: u32 = 10;
24

            
25
const WINDOW: Duration = Duration::from_secs(60);
26

            
27
/// Addresses tracked at once. Beyond this the least-recently-seen is dropped;
28
/// see [`ratelimit`] for why eviction beats refusal.
29
const TRACKED_ADDRESSES: usize = 8192;
30

            
31
/// Auth attempts allowed across *all* clients per window.
32
///
33
/// This is what actually binds when the per-client key is cheap to change.
34
/// Rotating addresses defeats the per-client bucket — and, past
35
/// [`TRACKED_ADDRESSES`], evicts other clients' counters into fresh budgets —
36
/// so without a ceiling on the total the throttle can be walked around by
37
/// anyone holding an IPv6 allocation. Set well above real traffic: legitimate
38
/// users share it, and exhausting it refuses logins site-wide.
39
const GLOBAL_MAX_ATTEMPTS: u32 = 300;
40

            
41
/// Token verifications one address may make per window.
42
///
43
/// Every authenticated request decodes a JWT, and decoding needs the signing
44
/// key of whoever the token *claims* to be — an indexed lookup in the global
45
/// directory, performed before anything is authenticated. Attacker-chosen
46
/// subjects therefore reach the database freely, and the admin pool that serves
47
/// them is five connections wide (ADR 0032).
48
///
49
/// Far above real traffic, because legitimate users share this with every page
50
/// load and HTMX poll. It bounds one source, not a distributed flood; the
51
/// remaining defence is that a verification is a single primary-key read.
52
const MAX_VERIFICATIONS: u32 = 600;
53

            
54
/// Key used when a request arrives with no peer address.
55
///
56
/// Should not happen — the server installs `ConnectInfo` — but an
57
/// unidentifiable request must still be counted rather than waved through, so
58
/// they all share one bucket.
59
const UNIDENTIFIED: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
60

            
61
/// Shared throttle state, built once at startup.
62
pub struct AuthLimiter {
63
    per_client: Mutex<Bucket<IpAddr>>,
64
    global: Mutex<Bucket<()>>,
65
    verification: Mutex<Bucket<IpAddr>>,
66
    trusted: TrustedProxies,
67
}
68

            
69
impl AuthLimiter {
70
    #[must_use]
71
38
    pub fn new(trusted: TrustedProxies) -> Self {
72
38
        Self {
73
38
            per_client: Mutex::new(Bucket::new(Limits {
74
38
                max_attempts: MAX_ATTEMPTS,
75
38
                window: WINDOW,
76
38
                capacity: TRACKED_ADDRESSES,
77
38
            })),
78
38
            global: Mutex::new(Bucket::new(Limits {
79
38
                max_attempts: GLOBAL_MAX_ATTEMPTS,
80
38
                window: WINDOW,
81
38
                capacity: 1,
82
38
            })),
83
38
            verification: Mutex::new(Bucket::new(Limits {
84
38
                max_attempts: MAX_VERIFICATIONS,
85
38
                window: WINDOW,
86
38
                capacity: TRACKED_ADDRESSES,
87
38
            })),
88
38
            trusted,
89
38
        }
90
38
    }
91

            
92
    /// Records an attempt from `peer`, honouring `forwarded_for` only if the
93
    /// peer is a trusted proxy.
94
    ///
95
    /// The per-client ceiling is charged first, and the global one only if the
96
    /// client had budget left.
97
    ///
98
    /// That order is load-bearing. Charging global first spends it on requests
99
    /// that are about to be refused anyway, so one address sitting on its own
100
    /// exhausted bucket could drain the site-wide budget and deny login to
101
    /// everyone — a cheaper attack than the identity rotation the global ceiling
102
    /// exists to stop.
103
    ///
104
    /// A poisoned lock denies: the limiter failing open would silently restore
105
    /// the unmetered oracle this exists to close.
106
2067
    pub fn check(&self, peer: Option<SocketAddr>, forwarded_for: Option<&str>) -> Decision {
107
2067
        let client = self.client_key(peer, forwarded_for);
108

            
109
2067
        let per_client = self
110
2067
            .per_client
111
2067
            .lock()
112
2067
            .map_or(Decision::Deny, |mut bucket| bucket.check(&client));
113

            
114
2067
        if per_client.is_denied() {
115
1214
            return Decision::Deny;
116
853
        }
117

            
118
853
        self.global
119
853
            .lock()
120
853
            .map_or(Decision::Deny, |mut bucket| bucket.check(&()))
121
2067
    }
122

            
123
    /// Records a token verification from `peer`.
124
    ///
125
    /// Separate from the login budget: these are ordinary authenticated
126
    /// requests, so the ceiling is far higher and exhausting it must not lock a
127
    /// client out of logging in.
128
4816
    pub fn check_verification(
129
4816
        &self,
130
4816
        peer: Option<SocketAddr>,
131
4816
        forwarded_for: Option<&str>,
132
4816
    ) -> Decision {
133
4816
        let client = self.client_key(peer, forwarded_for);
134

            
135
4816
        self.verification
136
4816
            .lock()
137
4816
            .map_or(Decision::Deny, |mut bucket| bucket.check(&client))
138
4816
    }
139

            
140
6883
    fn client_key(&self, peer: Option<SocketAddr>, forwarded_for: Option<&str>) -> IpAddr {
141
6883
        peer.map_or(UNIDENTIFIED, |peer| {
142
6838
            throttle_key(self.trusted.client_ip(peer, forwarded_for))
143
6838
        })
144
6883
    }
145
}
146

            
147
/// Rejects auth requests from an address that has exceeded its window.
148
69
pub async fn throttle_auth(
149
69
    State(limiter): State<Arc<AuthLimiter>>,
150
69
    request: Request,
151
69
    next: Next,
152
69
) -> Response {
153
69
    let forwarded = crate::client_ip::forwarded_for(request.headers());
154
    // Read rather than extracted: a request with no connect info must be
155
    // counted, not rejected outright by a failing extractor.
156
69
    let peer = request
157
69
        .extensions()
158
69
        .get::<ConnectInfo<SocketAddr>>()
159
69
        .map(|ConnectInfo(peer)| *peer);
160

            
161
69
    if limiter.check(peer, forwarded.as_deref()).is_denied() {
162
6
        metrics::counter!(crate::telemetry::AUTH_THROTTLED).increment(1);
163
6
        log::warn!("throttled auth attempt from {peer:?}");
164
6
        return (
165
6
            StatusCode::TOO_MANY_REQUESTS,
166
6
            "Too many attempts. Try again shortly.",
167
6
        )
168
6
            .into_response();
169
63
    }
170

            
171
63
    next.run(request).await
172
69
}
173

            
174
/// Rejects requests from an address making more token verifications than any
175
/// real client would.
176
1204
pub async fn throttle_verification(
177
1204
    State(limiter): State<Arc<AuthLimiter>>,
178
1204
    request: Request,
179
1204
    next: Next,
180
1204
) -> Response {
181
1204
    let forwarded = crate::client_ip::forwarded_for(request.headers());
182
1204
    let peer = request
183
1204
        .extensions()
184
1204
        .get::<ConnectInfo<SocketAddr>>()
185
1204
        .map(|ConnectInfo(peer)| *peer);
186

            
187
1204
    if limiter
188
1204
        .check_verification(peer, forwarded.as_deref())
189
1204
        .is_denied()
190
    {
191
2
        metrics::counter!(crate::telemetry::AUTH_THROTTLED).increment(1);
192
2
        log::warn!("throttled token verification from {peer:?}");
193
2
        return (
194
2
            StatusCode::TOO_MANY_REQUESTS,
195
2
            "Too many requests. Try again shortly.",
196
2
        )
197
2
            .into_response();
198
1202
    }
199

            
200
1202
    next.run(request).await
201
1204
}