authentication for the backend
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 11m16s

This commit is contained in:
Dmitri 2026-05-03 22:23:11 +02:00
parent f78054fecd
commit e3d4f8eac8
Signed by: kanopo
GPG Key ID: 759ADD40E3132AC7
14 changed files with 1415 additions and 47 deletions

1067
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -29,3 +29,8 @@ tower = "0.5.3"
futures-util = "0.3.32" futures-util = "0.3.32"
dashmap = "6.1.0" dashmap = "6.1.0"
zxcvbn = "3.1.1" zxcvbn = "3.1.1"
[dev-dependencies]
testcontainers = "0.23.1"
testcontainers-modules = { version = "0.11.4", features = ["postgres"] }
reqwest = { version = "0.12", features = ["json", "cookies"] }

View File

@ -12,6 +12,7 @@ pub async fn rate_limiting_middleware(
request: Request, request: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
// 1. Identify client by IP (x-client-ip header or socket address)
let client_ip = request let client_ip = request
.headers() .headers()
.get("x-client-ip") .get("x-client-ip")
@ -25,14 +26,16 @@ pub async fn rate_limiting_middleware(
.unwrap_or_else(|| "unknown".to_string()) .unwrap_or_else(|| "unknown".to_string())
}); });
// 2. Retrieve or create a TokenBucket for this IP and try to drain 1 token
let has_tokens = { let has_tokens = {
let mut entry = state let mut entry = state
.rate_limit .rate_limit
.entry(client_ip) .entry(client_ip)
.or_insert_with(crate::state::TokenBucket::new); .or_insert_with(crate::state::TokenBucket::new);
entry.value_mut().take() entry.value_mut().try_drain()
}; };
// 3. If successful, proceed; else return 429 Too Many Requests
if has_tokens { if has_tokens {
next.run(request).await next.run(request).await
} else { } else {

View File

@ -1,10 +1,10 @@
use axum::{ use axum::{
Router, Router,
middleware::{from_fn, from_fn_with_state}, middleware::from_fn_with_state,
}; };
use crate::{ use crate::{
controller::{middleware::auth_middleware::auth_middleware, v1::protected::protected_router}, controller::middleware::auth_middleware::auth_middleware,
state::AppState, state::AppState,
}; };

10
src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod config;
pub mod controller;
pub mod database;
pub mod db;
pub mod errors;
pub mod logging;
pub mod server;
pub mod service;
pub mod state;
pub mod utils;

View File

@ -1,14 +1,4 @@
mod config; use rhythm_backend::{config, database, errors::MainError, logging, server};
mod controller;
mod database;
mod db;
mod errors;
mod logging;
mod server;
mod service;
mod state;
mod utils;
use errors::MainError;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), MainError> { async fn main() -> Result<(), MainError> {

View File

@ -3,9 +3,6 @@ use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
const REFILL_RATE: f64 = 1.0;
const MAX_TOKENS: f64 = 10.0;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: PgPool, pub db: PgPool,
@ -18,21 +15,34 @@ pub struct TokenBucket {
pub last_refill: Instant, pub last_refill: Instant,
} }
// --- Rate Limiting Configuration ---
// Strategy: 5 requests per minute.
// This means 1 new token is mathematically generated every 12 seconds.
const REQUESTS_PER_MINUTE: f64 = 5.0;
// Maximum burst capacity. Users can make up to 5 rapid requests if their bucket is full.
const BUCKET_CAPACITY: f64 = 5.0;
impl TokenBucket { impl TokenBucket {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
tokens: MAX_TOKENS, tokens: BUCKET_CAPACITY,
last_refill: Instant::now(), last_refill: Instant::now(),
} }
} }
pub fn take(&mut self) -> bool { /// Refills the bucket based on time passed since the last request.
/// Mathematically simulates a background process adding 1 token every (60/RPM) seconds.
fn refill(&mut self) {
let now = Instant::now(); let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64(); let elapsed = now.duration_since(self.last_refill).as_secs_f64();
let tokens_per_second = REQUESTS_PER_MINUTE / 60.0;
self.tokens = (self.tokens + elapsed * REFILL_RATE).min(MAX_TOKENS); self.tokens = (self.tokens + elapsed * tokens_per_second).min(BUCKET_CAPACITY);
self.last_refill = now; self.last_refill = now;
}
/// Attempts to consume 1 token from the bucket.
pub fn try_drain(&mut self) -> bool {
self.refill();
if self.tokens >= 1.0 { if self.tokens >= 1.0 {
self.tokens -= 1.0; self.tokens -= 1.0;
true true

3
tests/auth/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod registration_login;
pub mod security;
pub mod session;

View File

@ -0,0 +1,34 @@
use crate::common::{setup_app, spawn_server};
#[tokio::test]
async fn test_register_and_login() {
let (app, _db) = setup_app().await;
let (base_url, client) = spawn_server(app).await;
let email = format!("user_{}@test.com", uuid::Uuid::new_v4());
// Register
let resp = client
.post(format!("{}/api/v1/auth/register", base_url))
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
.send().await.unwrap();
assert!(resp.status().is_success(), "Register failed: {}", resp.text().await.unwrap_or_default());
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["access_token"].is_string());
// Login success
let resp = client
.post(format!("{}/api/v1/auth/login", base_url))
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
.send().await.unwrap();
assert!(resp.status().is_success());
// Login failure
let resp = client
.post(format!("{}/api/v1/auth/login", base_url))
.json(&serde_json::json!({"email": email, "password": "WrongPassword"}))
.send().await.unwrap();
assert_eq!(resp.status(), 401);
}

83
tests/auth/security.rs Normal file
View File

@ -0,0 +1,83 @@
use crate::common::{setup_app, spawn_server};
use std::time::{Duration, Instant};
#[tokio::test]
async fn test_rate_limiting_blocks_spam() {
let (app, _db) = setup_app().await;
let (base_url, client) = spawn_server(app).await;
// Send 6 requests CONCURRENTLY so they arrive nearly simultaneously
// Capacity is 5, so exactly 5 should pass and 1 should fail.
let mut handles = Vec::new();
for _ in 0..6 {
let c = client.clone();
let url = format!("{}/api/v1/auth/login", base_url);
handles.push(tokio::spawn(async move {
c.post(&url)
.header("x-client-ip", "1.2.3.4")
.json(&serde_json::json!({"email": "a@test.com", "password": "b"}))
.send().await.unwrap()
.status().as_u16()
}));
}
let statuses: Vec<u16> = futures_util::future::join_all(handles).await
.into_iter().map(|r| r.unwrap()).collect();
let successes = statuses.iter().filter(|&&s| s != 429).count();
let blocked = statuses.iter().filter(|&&s| s == 429).count();
assert!(successes <= 5, "At most 5 rapid requests should be allowed, got {} passing", successes);
assert!(blocked >= 1, "At least 1 request should be rate-limited (429)");
// Different IP should still work
let ok = client
.post(format!("{}/api/v1/auth/login", base_url))
.header("x-client-ip", "5.6.7.8")
.json(&serde_json::json!({"email": "a@test.com", "password": "b"}))
.send().await.unwrap();
assert_ne!(ok.status(), 429, "Different IP should not be rate-limited");
}
#[tokio::test]
async fn test_anti_enumeration_timing() {
let (app, _db) = setup_app().await;
let (base_url, client) = spawn_server(app).await;
let start = Instant::now();
let _ = client
.post(format!("{}/api/v1/auth/login", base_url))
.header("x-client-ip", "1.1.1.1") // Different IP to avoid rate limits from previous requests in other tests if run concurrently
.json(&serde_json::json!({"email": "ghost_not_real@test.com", "password": "irrelevant"}))
.send().await.unwrap();
let duration_nonexistent = start.elapsed();
let email = format!("timing_{}@test.com", uuid::Uuid::new_v4());
let resp = client
.post(format!("{}/api/v1/auth/register", base_url))
.header("x-client-ip", "2.2.2.2")
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
.send().await.unwrap();
assert!(resp.status().is_success());
let start = Instant::now();
let _ = client
.post(format!("{}/api/v1/auth/login", base_url))
.header("x-client-ip", "3.3.3.3")
.json(&serde_json::json!({"email": email, "password": "WrongPassword123!"}))
.send().await.unwrap();
let duration_existent = start.elapsed();
// Anti-enumeration middleware ensures BOTH take >= 150ms
assert!(duration_nonexistent >= Duration::from_millis(150), "Fast path should be padded to >= 150ms");
assert!(duration_existent >= Duration::from_millis(150), "Slow path should be padded to >= 150ms");
let diff = if duration_nonexistent > duration_existent {
duration_nonexistent - duration_existent
} else {
duration_existent - duration_nonexistent
};
assert!(diff < Duration::from_millis(300),
"Timing difference should be <300ms because both paths are padded by random delay: got {:?}", diff);
}

62
tests/auth/session.rs Normal file
View File

@ -0,0 +1,62 @@
use crate::common::{setup_app, spawn_server};
#[tokio::test]
async fn test_protected_route_requires_auth() {
let (app, _db) = setup_app().await;
let (base_url, client) = spawn_server(app).await;
// No token → 401
let resp = client
.get(format!("{}/api/v1/protected/ping", base_url))
.send().await.unwrap();
assert_eq!(resp.status(), 401, "Protected route should require auth");
// With token → 200
let email = format!("protected_{}@test.com", uuid::Uuid::new_v4());
let reg = client
.post(format!("{}/api/v1/auth/register", base_url))
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
.send().await.unwrap();
let token: serde_json::Value = reg.json().await.unwrap();
let resp = client
.get(format!("{}/api/v1/protected/ping", base_url))
.bearer_auth(token["access_token"].as_str().unwrap())
.send().await.unwrap();
assert_eq!(resp.status(), 200, "Protected route should succeed with valid token");
}
#[tokio::test]
async fn test_refresh_and_logout_all() {
let (app, _db) = setup_app().await;
let (base_url, client) = spawn_server(app).await;
// Register + login to get a valid session
let email = format!("refresh_{}@test.com", uuid::Uuid::new_v4());
let reg = client
.post(format!("{}/api/v1/auth/register", base_url))
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
.send().await.unwrap();
let _token: serde_json::Value = reg.json().await.unwrap();
// Refresh should work
let refreshed = client
.post(format!("{}/api/v1/auth/refresh", base_url))
.send().await.unwrap();
assert!(refreshed.status().is_success(), "Refresh should succeed with cookie");
let new_token: serde_json::Value = refreshed.json().await.unwrap();
assert!(new_token["access_token"].is_string());
// Logout all
let resp = client
.post(format!("{}/api/v1/protected/auth/logout-all", base_url))
.bearer_auth(new_token["access_token"].as_str().unwrap())
.send().await.unwrap();
assert!(resp.status().is_success(), "logout-all should succeed");
// After logout-all, refresh should fail
let fail = client
.post(format!("{}/api/v1/auth/refresh", base_url))
.send().await.unwrap();
assert_eq!(fail.status(), 401, "Refresh should fail after logout-all");
}

2
tests/auth_tests.rs Normal file
View File

@ -0,0 +1,2 @@
mod common;
mod auth;

103
tests/basic_test.rs Normal file
View File

@ -0,0 +1,103 @@
use reqwest::Client;
use rhythm_backend::{
controller, state::AppState,
};
use axum::Router;
use sqlx::PgPool;
use std::sync::Arc;
use dashmap::DashMap;
async fn setup_app() -> (Router, PgPool) {
let db_url = "postgres://user:password@localhost:5432/rhythm-dev?sslmode=disable";
let db = PgPool::connect(db_url)
.await
.expect("Failed to connect to Postgres at localhost:5432");
// Run migrations
sqlx::migrate!("./migrations")
.run(&db)
.await
.expect("Failed to run migrations");
let state = AppState {
db: db.clone(),
jwt_secret: "test-secret-key-12345678901234567890".to_string(),
rate_limit: Arc::new(DashMap::new()),
};
let app = controller::router(state.clone()).with_state(state);
(app, db)
}
#[tokio::test]
async fn test_register_and_login() {
let (app, _db) = setup_app().await;
// Start the server on a random port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app.into_make_service()).await.unwrap();
});
// Give server a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let base_url = format!("http://127.0.0.1:{}", port);
let client = Client::new();
// Use a unique email so we don't conflict with previous test runs
let unique_email = format!("testuser_{}@test.com", uuid::Uuid::new_v4());
// Test 1: Register
let reg_resp = client
.post(format!("{}/api/v1/auth/register", base_url))
.json(&serde_json::json!({
"email": unique_email,
"password": "SuperSecureP@ssw0rd2024!"
}))
.send()
.await
.unwrap();
println!("Register status: {}", reg_resp.status());
assert!(
reg_resp.status().is_success(),
"Register should succeed: {}",
reg_resp.text().await.unwrap_or_default()
);
// Test 2: Login with correct password
let login_resp = client
.post(format!("{}/api/v1/auth/login", base_url))
.json(&serde_json::json!({
"email": unique_email,
"password": "SuperSecureP@ssw0rd2024!"
}))
.send()
.await
.unwrap();
println!("Login status: {}", login_resp.status());
assert!(login_resp.status().is_success(), "Login should succeed with correct password: {}", login_resp.text().await.unwrap_or_default());
let body: serde_json::Value = login_resp.json().await.unwrap();
assert!(body.get("access_token").is_some(), "Login response should have access_token");
// Test 3: Login with wrong password
let bad_login = client
.post(format!("{}/api/v1/auth/login", base_url))
.json(&serde_json::json!({
"email": unique_email,
"password": "WrongPassword1!"
}))
.send()
.await
.unwrap();
println!("Bad login status: {}", bad_login.status());
assert_eq!(bad_login.status(), 401, "Login with wrong password should return 401");
}

48
tests/common/mod.rs Normal file
View File

@ -0,0 +1,48 @@
use reqwest::Client;
use std::time::Duration;
use rhythm_backend::{controller, state::AppState};
use axum::Router;
use sqlx::PgPool;
use std::sync::Arc;
use dashmap::DashMap;
pub async fn setup_app() -> (Router, PgPool) {
let db_url = "postgres://user:password@localhost:5432/rhythm-dev?sslmode=disable";
let db = PgPool::connect(db_url)
.await
.expect("Failed to connect to Postgres at localhost:5432");
sqlx::migrate!("./migrations")
.run(&db)
.await
.expect("Failed to run migrations");
let state = AppState {
db: db.clone(),
jwt_secret: "test-secret-key-12345678901234567890".to_string(),
rate_limit: Arc::new(DashMap::new()),
};
let app = controller::router(state.clone()).with_state(state);
(app, db)
}
pub async fn spawn_server(app: axum::Router) -> (String, Client) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app.into_make_service()).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
let base_url = format!("http://127.0.0.1:{}", port);
let client = Client::builder()
.cookie_store(true)
.build()
.unwrap();
(base_url, client)
}