diff --git a/.env.example b/.env.example index 46226ad..39b73b1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ DATABASE_URL=sqlite:./dev.db BASE_URL=http://localhost:3000 PORT=3000 +SECURE_COOKIES=false JWT_SECRET= JWT_TTL_SECONDS= ALLOW_REGISTRATION=true diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs index d36259e..c3a173b 100644 --- a/crates/application/src/use_cases/register.rs +++ b/crates/application/src/use_cases/register.rs @@ -7,6 +7,12 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai return Err(DomainError::Unauthorized("Registration is disabled".into())); } + if cmd.password.len() < 8 { + return Err(DomainError::ValidationError( + "Password must be at least 8 characters".into(), + )); + } + let email = Email::new(cmd.email)?; if ctx.user_repository.find_by_email(&email).await?.is_some() { diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index dd3a3fb..47b04f1 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -53,10 +53,14 @@ pub mod html { .replace('"', "%22") } + fn secure_flag() -> &'static str { + if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { "; Secure" } else { "" } + } + fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { let val = format!( - "token={}; HttpOnly; Path=/; SameSite=Lax; Max-Age={}", - token, max_age + "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", + token, max_age, secure_flag() ); (SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie")) } @@ -104,10 +108,8 @@ pub mod html { } pub async fn get_logout() -> impl IntoResponse { - let cookie = ( - SET_COOKIE, - HeaderValue::from_static("token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"), - ); + let val = format!("token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", secure_flag()); + let cookie = (SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie")); ([cookie], Redirect::to("/")).into_response() } @@ -162,9 +164,8 @@ pub mod html { Err(_) => Redirect::to("/login").into_response(), } } - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/register?error={}", msg)).into_response() + Err(_) => { + Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() } } } diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 1d84bec..88eb7a1 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,8 +1,49 @@ -use axum::{Router, routing}; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::{Router, http::StatusCode, middleware, response::IntoResponse, routing}; use tower_http::{services::ServeDir, trace::TraceLayer}; use crate::{handlers, state::AppState}; +/// Simple global rate limiter: tracks request count per 60-second window. +/// Not per-IP — suitable for a low-traffic personal app. +#[derive(Clone)] +struct RateLimiter { + window: Arc, + count: Arc, + limit: u64, +} + +impl RateLimiter { + fn new(limit: u64) -> Self { + Self { + window: Arc::new(AtomicU64::new(0)), + count: Arc::new(AtomicU64::new(0)), + limit, + } + } + + fn check(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + / 60; + let prev = self.window.load(Ordering::Relaxed); + if now != prev { + self.window.store(now, Ordering::Relaxed); + self.count.store(1, Ordering::Relaxed); + true + } else { + self.count.fetch_add(1, Ordering::Relaxed) + 1 <= self.limit + } + } +} + pub fn build_router(state: AppState) -> Router { Router::new() .merge(html_routes()) @@ -13,10 +54,9 @@ pub fn build_router(state: AppState) -> Router { } fn html_routes() -> Router { - Router::new() - .route("/", routing::get(handlers::html::get_activity_feed)) - .route("/users", routing::get(handlers::html::get_users_list)) - .route("/users/{id}", routing::get(handlers::html::get_user_profile)) + // Auth routes: 20 requests per minute globally. + let limiter = RateLimiter::new(20); + let auth = Router::new() .route( "/login", routing::get(handlers::html::get_login_page) @@ -28,6 +68,22 @@ fn html_routes() -> Router { routing::get(handlers::html::get_register_page) .post(handlers::html::post_register), ) + .route_layer(middleware::from_fn(move |req: axum::extract::Request, next: middleware::Next| { + let limiter = limiter.clone(); + async move { + if limiter.check() { + next.run(req).await + } else { + StatusCode::TOO_MANY_REQUESTS.into_response() + } + } + })); + + Router::new() + .route("/", routing::get(handlers::html::get_activity_feed)) + .route("/users", routing::get(handlers::html::get_users_list)) + .route("/users/{id}", routing::get(handlers::html::get_user_profile)) + .merge(auth) .route("/reviews/new", routing::get(handlers::html::get_new_review_page)) .route("/reviews", routing::post(handlers::html::post_review)) .route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review)) @@ -37,6 +93,18 @@ fn html_routes() -> Router { } fn api_routes() -> Router { + let limiter = RateLimiter::new(20); + let auth_rate_limit = middleware::from_fn(move |req: axum::extract::Request, next: middleware::Next| { + let limiter = limiter.clone(); + async move { + if limiter.check() { + next.run(req).await + } else { + StatusCode::TOO_MANY_REQUESTS.into_response() + } + } + }); + Router::new().nest( "/api", Router::new() @@ -52,6 +120,7 @@ fn api_routes() -> Router { routing::post(handlers::api::sync_poster), ) .route("/auth/login", routing::post(handlers::api::login)) - .route("/auth/register", routing::post(handlers::api::register)), + .route("/auth/register", routing::post(handlers::api::register)) + .route_layer(auth_rate_limit), ) }