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"
|
||||
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]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
@ -383,6 +398,55 @@ dependencies = [
|
||||
"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]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@ -403,6 +467,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@ -549,6 +644,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
@ -582,6 +688,12 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@ -727,6 +839,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@ -993,6 +1111,12 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@ -1026,6 +1150,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@ -1520,6 +1653,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@ -1554,7 +1699,9 @@ dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
"rand 0.10.1",
|
||||
@ -1565,6 +1712,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-cookies",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@ -1572,6 +1720,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tracing-tree",
|
||||
"uuid",
|
||||
"zxcvbn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2044,6 +2193,12 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@ -2563,6 +2718,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
@ -2915,3 +3080,20 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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-http = { version = "0.6.8", features = ["trace"] }
|
||||
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;
|
||||
|
||||
mod middleware;
|
||||
pub mod model;
|
||||
mod v1;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get("Server is going brr 🚀"))
|
||||
.nest("/api/v1", v1::router_v1())
|
||||
.nest("/api/v1", v1::router_v1(state))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
@ -3,20 +3,33 @@ use axum::{Json, Router, routing::post};
|
||||
use tower_cookies::{CookieManagerLayer, Cookies};
|
||||
|
||||
use crate::{
|
||||
controller::middleware::AntiEnumerationLayer::AntiEnumerationLayer,
|
||||
controller::middleware::RateLimitLayer::RateLimitLayer,
|
||||
controller::model::auth_model::{AuthResponse, LoginRequest, RegisterRequest},
|
||||
errors::AppError,
|
||||
service::auth_service::{login, register},
|
||||
service::auth_service::{login, register, refresh},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn auth_router() -> Router<AppState> {
|
||||
pub fn auth_router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(login_handler))
|
||||
.route("/register", post(register_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())
|
||||
}
|
||||
|
||||
|
||||
async fn login_handler(
|
||||
State(s): State<AppState>,
|
||||
cookies: Cookies,
|
||||
@ -32,4 +45,16 @@ async fn register_handler(
|
||||
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 fn router_v1() -> Router<AppState> {
|
||||
Router::new().nest("/auth", auth_controller::auth_router())
|
||||
pub fn router_v1(state: AppState) -> Router<AppState> {
|
||||
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 {
|
||||
db,
|
||||
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)
|
||||
.await
|
||||
|
||||
@ -1,85 +1,69 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::Json;
|
||||
use chrono::Duration;
|
||||
use tower_cookies::cookie::SameSite;
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
|
||||
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::errors::AppError;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::anti_enumeration::anti_enumeration_delay;
|
||||
use crate::utils::hash;
|
||||
use crate::utils::jwt::generate_access_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(
|
||||
state: &AppState,
|
||||
cookies: Cookies,
|
||||
req: LoginRequest,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
let start = Instant::now();
|
||||
let login_result: Result<(String, String), AppError> = async {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let user = user_repository::get_user_by_email(&mut *tx, &req.email)
|
||||
.await?
|
||||
.ok_or(AppError::InvalidCredentials)?;
|
||||
if !hash::verify(&req.password, &user.password)? {
|
||||
return Err(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);
|
||||
let user = user_repository::get_user_by_email(&mut *tx, &req.email)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
tracing::warn!(email = %req.email, "Login failed: user not found");
|
||||
AppError::InvalidCredentials
|
||||
})?;
|
||||
|
||||
create_refresh_token(&mut *tx, user.id, refresh_hash, expires_at).await?;
|
||||
tx.commit().await?;
|
||||
Ok((access_token, refresh_plain))
|
||||
if !hash::verify(&req.password, &user.password)? {
|
||||
tracing::warn!(email = %req.email, "Login failed: invalid password");
|
||||
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 {
|
||||
Ok((access_token, refresh_token)) => {
|
||||
set_refresh_cookie(&cookies, &refresh_token);
|
||||
Ok(Json(AuthResponse { access_token }))
|
||||
}
|
||||
Err(e) => {
|
||||
if let AppError::InvalidCredentials = e {
|
||||
tracing::warn!("Invalid login attempt for {}", req.email);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
create_refresh_token(&mut *tx, user.id, refresh_hash, expires_at).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
set_refresh_cookie(&cookies, &refresh_plain);
|
||||
|
||||
Ok(Json(AuthResponse { access_token }))
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
state: &AppState,
|
||||
cookies: Cookies,
|
||||
req: RegisterRequest,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
let start = Instant::now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
{
|
||||
let user = user_repository::get_user_by_email(&mut *tx, &req.email).await?;
|
||||
if user.is_some() {
|
||||
// user already registered
|
||||
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 estimate = zxcvbn::zxcvbn(&req.password, &[]);
|
||||
|
||||
if (estimate.score() as u8) < 3 {
|
||||
tracing::warn!(email = %req.email, score = ?estimate.score(), "Registration failed: password too weak");
|
||||
return Err(AppError::Validation(
|
||||
"Password is too weak. Please use a more complex password.".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 user = user_repository::create_user(&mut *tx, req.email, h).await?;
|
||||
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?;
|
||||
|
||||
tx.commit().await?;
|
||||
anti_enumeration_delay(start, 150, 300).await;
|
||||
|
||||
set_refresh_cookie(&cookies, &refresh_plain);
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: access_token,
|
||||
}))
|
||||
Ok(Json(AuthResponse { access_token }))
|
||||
}
|
||||
|
||||
pub async fn refresh(state: &AppState, cookies: Cookies) -> Result<(), AppError> {
|
||||
todo!()
|
||||
pub async fn refresh(state: &AppState, cookies: Cookies) -> Result<Json<AuthResponse>, AppError> {
|
||||
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";
|
||||
|
||||
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) {
|
||||
let cookie = Cookie::build((REFRESH_COOKIE_NAME, token.to_owned()))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(tower_cookies::cookie::SameSite::Strict)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/")
|
||||
.max_age(time::Duration::days(7))
|
||||
.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;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
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