anti enumeration adn rate limit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12m50s

This commit is contained in:
Dmitri 2026-05-02 14:06:54 +02:00
parent f1ddaf5f2d
commit 8996161cc9
Signed by: kanopo
GPG Key ID: 759ADD40E3132AC7
12 changed files with 565 additions and 68 deletions

182
Cargo.lock generated
View File

@ -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",
]

View File

@ -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
View 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.

View 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)
})
}
}

View 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())
})
}
}
}

View File

@ -0,0 +1,2 @@
pub mod AntiEnumerationLayer;
pub mod RateLimitLayer;

View File

@ -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())
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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

View File

@ -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();

View File

@ -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(),
}
}
}