anti enumeration adn rate limit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12m50s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12m50s
This commit is contained in:
parent
f1ddaf5f2d
commit
8996161cc9
182
Cargo.lock
generated
182
Cargo.lock
generated
@ -135,6 +135,21 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-set"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@ -383,6 +398,55 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dashmap"
|
||||||
|
version = "6.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@ -403,6 +467,37 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_core"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_macro"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_core",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@ -549,6 +644,17 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fancy-regex"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277"
|
||||||
|
dependencies = [
|
||||||
|
"bit-set",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ff"
|
name = "ff"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -582,6 +688,12 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -727,6 +839,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@ -993,6 +1111,12 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -1026,6 +1150,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@ -1520,6 +1653,18 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@ -1554,7 +1699,9 @@ dependencies = [
|
|||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dashmap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@ -1565,6 +1712,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tower-cookies",
|
"tower-cookies",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
@ -1572,6 +1720,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tracing-tree",
|
"tracing-tree",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zxcvbn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2044,6 +2193,12 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@ -2563,6 +2718,16 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.95"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@ -2915,3 +3080,20 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zxcvbn"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9eaee90f4a795d1eb4ba6c51e1c1721d4784d550e8efa7b2600f29c867365e0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"derive_builder",
|
||||||
|
"fancy-regex",
|
||||||
|
"itertools",
|
||||||
|
"lazy_static",
|
||||||
|
"regex",
|
||||||
|
"time",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|||||||
@ -25,3 +25,7 @@ hex = "0.4.3"
|
|||||||
tower-cookies = "0.11.0"
|
tower-cookies = "0.11.0"
|
||||||
tower-http = { version = "0.6.8", features = ["trace"] }
|
tower-http = { version = "0.6.8", features = ["trace"] }
|
||||||
time = "0.3.47"
|
time = "0.3.47"
|
||||||
|
tower = "0.5.3"
|
||||||
|
futures-util = "0.3.32"
|
||||||
|
dashmap = "6.1.0"
|
||||||
|
zxcvbn = "3.1.1"
|
||||||
|
|||||||
60
README.md
Normal file
60
README.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Rhythm Backend API Documentation
|
||||||
|
|
||||||
|
## Authentication System Overview
|
||||||
|
|
||||||
|
The authentication system is built with a security-first approach, featuring multi-layered protection against common web vulnerabilities.
|
||||||
|
|
||||||
|
### Security Layers (Middleware)
|
||||||
|
|
||||||
|
1. **Rate Limiting (Anti-Spam Bucket)**
|
||||||
|
- **Mechanism:** Token Bucket (in-memory `DashMap`).
|
||||||
|
- **Logic:** Identifies users via the `X-Client-IP` header (trusted from proxy).
|
||||||
|
- **Config:** 5 attempts per minute, refilling 1 token every 12 seconds.
|
||||||
|
- **Response:** `429 Too Many Requests`.
|
||||||
|
|
||||||
|
2. **Anti-Enumeration (Timing Protection)**
|
||||||
|
- **Mechanism:** Variable response delay.
|
||||||
|
- **Logic:** Ensures every authentication request takes between 150ms and 300ms.
|
||||||
|
- **Purpose:** Hides whether an account exists or a password was correct from timing analysis.
|
||||||
|
|
||||||
|
### Current API Endpoints
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/register`
|
||||||
|
Registers a new user.
|
||||||
|
- **Payload:** `RegisterRequest { email, password }`
|
||||||
|
- **Response:** `200 OK` with `AuthResponse { access_token }`
|
||||||
|
- **Side Effect:** Sets an `HttpOnly`, `Secure`, `SameSite=Strict` cookie named `refresh_token`.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/login`
|
||||||
|
Authenticates a user.
|
||||||
|
- **Payload:** `LoginRequest { email, password }`
|
||||||
|
- **Response:** `200 OK` with `AuthResponse { access_token }`
|
||||||
|
- **Side Effect:** Sets a new `refresh_token` cookie.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/refresh`
|
||||||
|
Rotates tokens for an active session.
|
||||||
|
- **Requirement:** Valid `refresh_token` cookie.
|
||||||
|
- **Response:** `200 OK` with new `access_token`.
|
||||||
|
- **Rotation Logic:** Revokes the old refresh token and issues a completely new one (Rotation) to prevent session hijacking.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/logout`
|
||||||
|
Invalidates the current session.
|
||||||
|
- **Requirement:** Valid `refresh_token` cookie.
|
||||||
|
- **Response:** `200 OK`.
|
||||||
|
- **Logic:** Revokes the refresh token in the database and clears the HttpOnly cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Features Detail
|
||||||
|
|
||||||
|
### 1. Rate Limiting (Anti-Spam)
|
||||||
|
Protects against brute-force and DoS attacks by limiting requests per IP address. Uses an in-memory Token Bucket algorithm.
|
||||||
|
|
||||||
|
### 2. Anti-Enumeration (Timing Protection)
|
||||||
|
Ensures that the time taken to process an auth request is independent of the result (e.g., whether a user exists or not). This prevents attackers from using timing differences to discover valid emails.
|
||||||
|
|
||||||
|
### 3. Password Strength (zxcvbn)
|
||||||
|
Uses Dropbox's `zxcvbn` algorithm to estimate password entropy. Registration requires a score of at least 3/4.
|
||||||
|
|
||||||
|
### 4. Refresh Token Rotation
|
||||||
|
Every time a refresh token is used to get a new access token, the old refresh token is invalidated and a new one is issued. This limits the window of opportunity if a refresh token is leaked.
|
||||||
73
src/controller/middleware/AntiEnumerationLayer.rs
Normal file
73
src/controller/middleware/AntiEnumerationLayer.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use axum::{extract::Request, response::Response};
|
||||||
|
use futures_util::future::BoxFuture;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tower::{Layer, Service};
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
/// Middleware Layer that ensures every request takes a minimum amount of time.
|
||||||
|
/// This prevents "Timing Attacks" where an attacker can determine if a user exists
|
||||||
|
/// by observing how much faster the server responds when an email is not found
|
||||||
|
/// versus when a password check (expensive hash) is performed.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AntiEnumerationLayer {
|
||||||
|
pub min_ms: u64,
|
||||||
|
pub max_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Layer<S> for AntiEnumerationLayer {
|
||||||
|
type Service = AntiEnumerationService<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
AntiEnumerationService {
|
||||||
|
inner,
|
||||||
|
min_ms: self.min_ms,
|
||||||
|
max_ms: self.max_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Service implementation for AntiEnumeration.
|
||||||
|
/// It wraps the inner service, records the start time, and sleeps if the
|
||||||
|
/// inner service finishes too quickly.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AntiEnumerationService<S> {
|
||||||
|
inner: S,
|
||||||
|
min_ms: u64,
|
||||||
|
max_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Service<Request> for AntiEnumerationService<S>
|
||||||
|
where
|
||||||
|
S: Service<Request, Response = Response> + Send + 'static,
|
||||||
|
S::Future: Send + 'static,
|
||||||
|
{
|
||||||
|
type Response = S::Response;
|
||||||
|
type Error = S::Error;
|
||||||
|
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||||
|
|
||||||
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||||
|
self.inner.poll_ready(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, req: Request) -> Self::Future {
|
||||||
|
let start = Instant::now();
|
||||||
|
let min = self.min_ms;
|
||||||
|
let max = self.max_ms;
|
||||||
|
let future = self.inner.call(req);
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
let res = future.await?;
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// Pick a random target within the window to make timing analysis even harder
|
||||||
|
let target = Duration::from_millis(rand::rng().random_range(min..=max));
|
||||||
|
|
||||||
|
if elapsed < target {
|
||||||
|
sleep(target - elapsed).await;
|
||||||
|
}
|
||||||
|
Ok(res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/controller/middleware/RateLimitLayer.rs
Normal file
97
src/controller/middleware/RateLimitLayer.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
use std::task::{Context, Poll};
|
||||||
|
use axum::{extract::Request, response::Response};
|
||||||
|
use futures_util::future::BoxFuture;
|
||||||
|
use tower::{Layer, Service};
|
||||||
|
use std::time::Instant;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Middleware Layer for Rate Limiting.
|
||||||
|
/// It implements a Token Bucket algorithm to limit requests per IP.
|
||||||
|
/// This prevents spam, brute-force attacks, and DoS on expensive endpoints.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RateLimitLayer {
|
||||||
|
pub state: AppState,
|
||||||
|
pub max_tokens: f64,
|
||||||
|
pub refill_rate: f64, // tokens per second
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Layer<S> for RateLimitLayer {
|
||||||
|
type Service = RateLimitService<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
RateLimitService {
|
||||||
|
inner,
|
||||||
|
state: self.state.clone(),
|
||||||
|
max_tokens: self.max_tokens,
|
||||||
|
refill_rate: self.refill_rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Service implementation for RateLimiting.
|
||||||
|
/// It identifies the user via 'X-Client-IP' header (injected by the trusted proxy).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RateLimitService<S> {
|
||||||
|
inner: S,
|
||||||
|
state: AppState,
|
||||||
|
max_tokens: f64,
|
||||||
|
refill_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Service<Request> for RateLimitService<S>
|
||||||
|
where
|
||||||
|
S: Service<Request, Response = Response> + Send + 'static,
|
||||||
|
S::Future: Send + 'static,
|
||||||
|
{
|
||||||
|
type Response = S::Response;
|
||||||
|
type Error = S::Error;
|
||||||
|
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||||
|
|
||||||
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||||
|
self.inner.poll_ready(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, req: Request) -> Self::Future {
|
||||||
|
// 1. Extract IP from trusted header (X-Client-IP)
|
||||||
|
// Note: In a production Docker setup, Nginx should be configured to set this.
|
||||||
|
let client_ip = req.headers()
|
||||||
|
.get("X-Client-IP")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let state = self.state.clone();
|
||||||
|
let max_tokens = self.max_tokens;
|
||||||
|
let refill_rate = self.refill_rate;
|
||||||
|
|
||||||
|
// 2. Access the shared DashMap for the IP's bucket
|
||||||
|
let mut bucket = state.rate_limit.entry(client_ip).or_insert_with(|| crate::state::TokenBucket::new(max_tokens));
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
|
||||||
|
|
||||||
|
// 3. Token Bucket Refill logic: tokens = current + (time_passed * rate)
|
||||||
|
bucket.tokens = (bucket.tokens + elapsed * refill_rate).min(max_tokens);
|
||||||
|
bucket.last_refill = now;
|
||||||
|
|
||||||
|
// 4. Consumption check
|
||||||
|
if bucket.tokens >= 1.0 {
|
||||||
|
bucket.tokens -= 1.0;
|
||||||
|
drop(bucket); // CRITICAL: Release the lock before proceeding to allow other threads to access the map
|
||||||
|
let future = self.inner.call(req);
|
||||||
|
Box::pin(async move {
|
||||||
|
let res = future.await?;
|
||||||
|
Ok(res)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
drop(bucket);
|
||||||
|
// Limit exceeded: return 429 Too Many Requests
|
||||||
|
Box::pin(async move {
|
||||||
|
let err = AppError::Validation("Too many requests".to_string());
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
Ok(err.into_response())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/controller/middleware/mod.rs
Normal file
2
src/controller/middleware/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod AntiEnumerationLayer;
|
||||||
|
pub mod RateLimitLayer;
|
||||||
@ -3,12 +3,13 @@ use tower_http::trace::TraceLayer;
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
mod middleware;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
mod v1;
|
mod v1;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get("Server is going brr 🚀"))
|
.route("/", get("Server is going brr 🚀"))
|
||||||
.nest("/api/v1", v1::router_v1())
|
.nest("/api/v1", v1::router_v1(state))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,33 @@ use axum::{Json, Router, routing::post};
|
|||||||
use tower_cookies::{CookieManagerLayer, Cookies};
|
use tower_cookies::{CookieManagerLayer, Cookies};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
controller::middleware::AntiEnumerationLayer::AntiEnumerationLayer,
|
||||||
|
controller::middleware::RateLimitLayer::RateLimitLayer,
|
||||||
controller::model::auth_model::{AuthResponse, LoginRequest, RegisterRequest},
|
controller::model::auth_model::{AuthResponse, LoginRequest, RegisterRequest},
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
service::auth_service::{login, register},
|
service::auth_service::{login, register, refresh},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn auth_router() -> Router<AppState> {
|
pub fn auth_router(state: AppState) -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", post(login_handler))
|
.route("/login", post(login_handler))
|
||||||
.route("/register", post(register_handler))
|
.route("/register", post(register_handler))
|
||||||
.route("/refresh", post(refresh_handler))
|
.route("/refresh", post(refresh_handler))
|
||||||
|
.route("/logout", post(logout_handler))
|
||||||
|
.layer(AntiEnumerationLayer {
|
||||||
|
min_ms: 150,
|
||||||
|
max_ms: 300,
|
||||||
|
})
|
||||||
|
.layer(RateLimitLayer {
|
||||||
|
state,
|
||||||
|
max_tokens: 5.0,
|
||||||
|
refill_rate: 1.0 / 12.0, // 1 token every 12 seconds = 5 per minute
|
||||||
|
})
|
||||||
.layer(CookieManagerLayer::new())
|
.layer(CookieManagerLayer::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn login_handler(
|
async fn login_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
@ -32,4 +45,16 @@ async fn register_handler(
|
|||||||
register(&s, cookies, payload).await
|
register(&s, cookies, payload).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_handler(State(s): State<AppState>, cookies: Cookies) {}
|
async fn refresh_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
cookies: Cookies,
|
||||||
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
|
refresh(&s, cookies).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
cookies: Cookies,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
crate::service::auth_service::logout(&s, cookies).await
|
||||||
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
pub mod auth_controller;
|
pub mod auth_controller;
|
||||||
|
|
||||||
pub fn router_v1() -> Router<AppState> {
|
pub fn router_v1(state: AppState) -> Router<AppState> {
|
||||||
Router::new().nest("/auth", auth_controller::auth_router())
|
Router::new().nest("/auth", auth_controller::auth_router(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,9 @@ pub async fn init(cfg: &config::Config, db: PgPool) -> Result<(), AppError> {
|
|||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
jwt_secret: cfg.jwt_secret.clone(),
|
jwt_secret: cfg.jwt_secret.clone(),
|
||||||
|
rate_limit: std::sync::Arc::new(dashmap::DashMap::new()),
|
||||||
};
|
};
|
||||||
let app = Router::new().merge(controller::router()).with_state(state);
|
let app = Router::new().merge(controller::router(state.clone())).with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&cfg.socket_address)
|
let listener = tokio::net::TcpListener::bind(&cfg.socket_address)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -1,85 +1,69 @@
|
|||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
|
use tower_cookies::cookie::SameSite;
|
||||||
use tower_cookies::{Cookie, Cookies};
|
use tower_cookies::{Cookie, Cookies};
|
||||||
|
|
||||||
use crate::controller::model::auth_model::*;
|
use crate::controller::model::auth_model::*;
|
||||||
use crate::db::repository::refresh_token_repository::create_refresh_token;
|
use crate::db::repository::refresh_token_repository::{create_refresh_token, find_by_hash, revoke};
|
||||||
use crate::db::repository::user_repository;
|
use crate::db::repository::user_repository;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::anti_enumeration::anti_enumeration_delay;
|
|
||||||
use crate::utils::hash;
|
use crate::utils::hash;
|
||||||
use crate::utils::jwt::generate_access_token;
|
use crate::utils::jwt::generate_access_token;
|
||||||
use crate::utils::refresh_token::generate_refresh_token;
|
use crate::utils::refresh_token::generate_refresh_token;
|
||||||
|
|
||||||
const MIN_DELAY_MS: u64 = 150;
|
|
||||||
const MAX_DELAY_MS: u64 = 300;
|
|
||||||
|
|
||||||
/**
|
|
||||||
This function role is to parse the email and password from the request and validate the user to give
|
|
||||||
back in the happy path the access token (jwt) and the refresh token (opaque token).
|
|
||||||
|
|
||||||
An anti enumeration mechanism is in place to have a variable deplay in ms for every case of user authentication (error or happy validation).
|
|
||||||
TODO: add a bucket strategy for rate limiting for all of this endpoints
|
|
||||||
*/
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
req: LoginRequest,
|
req: LoginRequest,
|
||||||
) -> Result<Json<AuthResponse>, AppError> {
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
let start = Instant::now();
|
let mut tx = state.db.begin().await?;
|
||||||
let login_result: Result<(String, String), AppError> = async {
|
|
||||||
let mut tx = state.db.begin().await?;
|
|
||||||
|
|
||||||
let user = user_repository::get_user_by_email(&mut *tx, &req.email)
|
let user = user_repository::get_user_by_email(&mut *tx, &req.email)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::InvalidCredentials)?;
|
.ok_or_else(|| {
|
||||||
if !hash::verify(&req.password, &user.password)? {
|
tracing::warn!(email = %req.email, "Login failed: user not found");
|
||||||
return Err(AppError::InvalidCredentials);
|
AppError::InvalidCredentials
|
||||||
}
|
})?;
|
||||||
let access_token = generate_access_token(user.id, &state.jwt_secret)?;
|
|
||||||
let (refresh_plain, refresh_hash) = generate_refresh_token();
|
|
||||||
let expires_at = chrono::Utc::now() + Duration::days(7);
|
|
||||||
|
|
||||||
create_refresh_token(&mut *tx, user.id, refresh_hash, expires_at).await?;
|
if !hash::verify(&req.password, &user.password)? {
|
||||||
tx.commit().await?;
|
tracing::warn!(email = %req.email, "Login failed: invalid password");
|
||||||
Ok((access_token, refresh_plain))
|
return Err(AppError::InvalidCredentials);
|
||||||
}
|
}
|
||||||
.await;
|
|
||||||
|
|
||||||
anti_enumeration_delay(start, MIN_DELAY_MS, MAX_DELAY_MS).await;
|
let access_token = generate_access_token(user.id, &state.jwt_secret)?;
|
||||||
|
let (refresh_plain, refresh_hash) = generate_refresh_token();
|
||||||
|
let expires_at = chrono::Utc::now() + Duration::days(7);
|
||||||
|
|
||||||
return match login_result {
|
create_refresh_token(&mut *tx, user.id, refresh_hash, expires_at).await?;
|
||||||
Ok((access_token, refresh_token)) => {
|
tx.commit().await?;
|
||||||
set_refresh_cookie(&cookies, &refresh_token);
|
|
||||||
Ok(Json(AuthResponse { access_token }))
|
set_refresh_cookie(&cookies, &refresh_plain);
|
||||||
}
|
|
||||||
Err(e) => {
|
Ok(Json(AuthResponse { access_token }))
|
||||||
if let AppError::InvalidCredentials = e {
|
|
||||||
tracing::warn!("Invalid login attempt for {}", req.email);
|
|
||||||
}
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
req: RegisterRequest,
|
req: RegisterRequest,
|
||||||
) -> Result<Json<AuthResponse>, AppError> {
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
let start = Instant::now();
|
let estimate = zxcvbn::zxcvbn(&req.password, &[]);
|
||||||
let mut tx = state.db.begin().await?;
|
|
||||||
{
|
if (estimate.score() as u8) < 3 {
|
||||||
let user = user_repository::get_user_by_email(&mut *tx, &req.email).await?;
|
tracing::warn!(email = %req.email, score = ?estimate.score(), "Registration failed: password too weak");
|
||||||
if user.is_some() {
|
return Err(AppError::Validation(
|
||||||
// user already registered
|
"Password is too weak. Please use a more complex password.".to_string(),
|
||||||
anti_enumeration_delay(start, 150, 300).await;
|
));
|
||||||
tracing::warn!("registering with an already used email address");
|
|
||||||
return Err(AppError::Validation("bad request".to_string()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
if let Some(_) = user_repository::get_user_by_email(&mut *tx, &req.email).await? {
|
||||||
|
tracing::warn!(email = %req.email, "Registration failed: email already exists");
|
||||||
|
return Err(AppError::Validation("bad request".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let h = hash::hash(&req.password)?;
|
let h = hash::hash(&req.password)?;
|
||||||
let user = user_repository::create_user(&mut *tx, req.email, h).await?;
|
let user = user_repository::create_user(&mut *tx, req.email, h).await?;
|
||||||
let access_token = generate_access_token(user.id, &state.jwt_secret)?;
|
let access_token = generate_access_token(user.id, &state.jwt_secret)?;
|
||||||
@ -89,25 +73,75 @@ pub async fn register(
|
|||||||
create_refresh_token(&mut *tx, user.id, refresh_hash, expires_at).await?;
|
create_refresh_token(&mut *tx, user.id, refresh_hash, expires_at).await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
anti_enumeration_delay(start, 150, 300).await;
|
|
||||||
|
|
||||||
set_refresh_cookie(&cookies, &refresh_plain);
|
set_refresh_cookie(&cookies, &refresh_plain);
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse { access_token }))
|
||||||
access_token: access_token,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh(state: &AppState, cookies: Cookies) -> Result<(), AppError> {
|
pub async fn refresh(state: &AppState, cookies: Cookies) -> Result<Json<AuthResponse>, AppError> {
|
||||||
todo!()
|
let refresh_token = get_refresh_cookie(&cookies).ok_or(AppError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
let token_data = find_by_hash(&mut *tx, &refresh_token)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
if token_data.revoked_at.is_some() || token_data.expires_at < chrono::Utc::now() {
|
||||||
|
return Err(AppError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke(&mut *tx, token_data.id).await?;
|
||||||
|
|
||||||
|
let access_token = generate_access_token(token_data.user_id, &state.jwt_secret)?;
|
||||||
|
let (refresh_plain, refresh_hash) = generate_refresh_token();
|
||||||
|
let expires_at = chrono::Utc::now() + Duration::days(7);
|
||||||
|
|
||||||
|
create_refresh_token(&mut *tx, token_data.user_id, refresh_hash, expires_at).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
set_refresh_cookie(&cookies, &refresh_plain);
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse { access_token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(state: &AppState, cookies: Cookies) -> Result<(), AppError> {
|
||||||
|
let refresh_token = match get_refresh_cookie(&cookies) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(()), // Already logged out
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
if let Some(token_data) = find_by_hash(&mut *tx, &refresh_token).await? {
|
||||||
|
revoke(&mut *tx, token_data.id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
remove_refresh_cookie(&cookies);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||||
|
|
||||||
|
fn remove_refresh_cookie(cookies: &Cookies) {
|
||||||
|
let cookie = Cookie::build((REFRESH_COOKIE_NAME, ""))
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::ZERO) // Expire immediately
|
||||||
|
.build();
|
||||||
|
|
||||||
|
cookies.remove(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
fn set_refresh_cookie(cookies: &Cookies, token: &str) {
|
fn set_refresh_cookie(cookies: &Cookies, token: &str) {
|
||||||
let cookie = Cookie::build((REFRESH_COOKIE_NAME, token.to_owned()))
|
let cookie = Cookie::build((REFRESH_COOKIE_NAME, token.to_owned()))
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.secure(true)
|
.secure(true)
|
||||||
.same_site(tower_cookies::cookie::SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.path("/")
|
.path("/")
|
||||||
.max_age(time::Duration::days(7))
|
.max_age(time::Duration::days(7))
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
18
src/state.rs
18
src/state.rs
@ -1,7 +1,25 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use dashmap::DashMap;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: PgPool,
|
pub db: PgPool,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
|
pub rate_limit: Arc<DashMap<String, TokenBucket>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TokenBucket {
|
||||||
|
pub tokens: f64,
|
||||||
|
pub last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenBucket {
|
||||||
|
pub fn new(max_tokens: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
tokens: max_tokens,
|
||||||
|
last_refill: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user