From 8996161cc976aaa077c55e82c1e86445aab76f1d Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sat, 2 May 2026 14:06:54 +0200 Subject: [PATCH] anti enumeration adn rate limit --- Cargo.lock | 182 ++++++++++++++++++ Cargo.toml | 4 + README.md | 60 ++++++ .../middleware/AntiEnumerationLayer.rs | 73 +++++++ src/controller/middleware/RateLimitLayer.rs | 97 ++++++++++ src/controller/middleware/mod.rs | 2 + src/controller/mod.rs | 5 +- src/controller/v1/auth_controller.rs | 31 ++- src/controller/v1/mod.rs | 4 +- src/server.rs | 3 +- src/service/auth_service.rs | 154 +++++++++------ src/state.rs | 18 ++ 12 files changed, 565 insertions(+), 68 deletions(-) create mode 100644 README.md create mode 100644 src/controller/middleware/AntiEnumerationLayer.rs create mode 100644 src/controller/middleware/RateLimitLayer.rs create mode 100644 src/controller/middleware/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 0e1f858..e540aab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 8a93e8c..28d0b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a160ab --- /dev/null +++ b/README.md @@ -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. diff --git a/src/controller/middleware/AntiEnumerationLayer.rs b/src/controller/middleware/AntiEnumerationLayer.rs new file mode 100644 index 0000000..1632388 --- /dev/null +++ b/src/controller/middleware/AntiEnumerationLayer.rs @@ -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 Layer for AntiEnumerationLayer { + type Service = AntiEnumerationService; + + 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 { + inner: S, + min_ms: u64, + max_ms: u64, +} + +impl Service for AntiEnumerationService +where + S: Service + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + 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) + }) + } +} diff --git a/src/controller/middleware/RateLimitLayer.rs b/src/controller/middleware/RateLimitLayer.rs new file mode 100644 index 0000000..4dbba09 --- /dev/null +++ b/src/controller/middleware/RateLimitLayer.rs @@ -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 Layer for RateLimitLayer { + type Service = RateLimitService; + + 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 { + inner: S, + state: AppState, + max_tokens: f64, + refill_rate: f64, +} + +impl Service for RateLimitService +where + S: Service + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + 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()) + }) + } + } +} diff --git a/src/controller/middleware/mod.rs b/src/controller/middleware/mod.rs new file mode 100644 index 0000000..c48fe14 --- /dev/null +++ b/src/controller/middleware/mod.rs @@ -0,0 +1,2 @@ +pub mod AntiEnumerationLayer; +pub mod RateLimitLayer; diff --git a/src/controller/mod.rs b/src/controller/mod.rs index 389aeef..e76ac26 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -3,12 +3,13 @@ use tower_http::trace::TraceLayer; use crate::state::AppState; +mod middleware; pub mod model; mod v1; -pub fn router() -> Router { +pub fn router(state: AppState) -> Router { 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()) } diff --git a/src/controller/v1/auth_controller.rs b/src/controller/v1/auth_controller.rs index 04ded5d..2b7716e 100644 --- a/src/controller/v1/auth_controller.rs +++ b/src/controller/v1/auth_controller.rs @@ -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 { +pub fn auth_router(state: AppState) -> Router { 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, cookies: Cookies, @@ -32,4 +45,16 @@ async fn register_handler( register(&s, cookies, payload).await } -async fn refresh_handler(State(s): State, cookies: Cookies) {} +async fn refresh_handler( + State(s): State, + cookies: Cookies, +) -> Result, AppError> { + refresh(&s, cookies).await +} + +async fn logout_handler( + State(s): State, + cookies: Cookies, +) -> Result<(), AppError> { + crate::service::auth_service::logout(&s, cookies).await +} diff --git a/src/controller/v1/mod.rs b/src/controller/v1/mod.rs index 352de45..59b7964 100644 --- a/src/controller/v1/mod.rs +++ b/src/controller/v1/mod.rs @@ -4,7 +4,7 @@ use crate::state::AppState; pub mod auth_controller; -pub fn router_v1() -> Router { - Router::new().nest("/auth", auth_controller::auth_router()) +pub fn router_v1(state: AppState) -> Router { + Router::new().nest("/auth", auth_controller::auth_router(state)) } diff --git a/src/server.rs b/src/server.rs index 59d7a0b..afb2939 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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 diff --git a/src/service/auth_service.rs b/src/service/auth_service.rs index b9d46cd..602afcd 100644 --- a/src/service/auth_service.rs +++ b/src/service/auth_service.rs @@ -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, 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, 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, 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(); diff --git a/src/state.rs b/src/state.rs index db39793..2741bca 100644 --- a/src/state.rs +++ b/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>, +} + +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(), + } + } }