diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b9acf56..da0d2a2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,20 +3,6 @@ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }, "permissions": { - "allow": [ - "Skill(update-config)", - "Bash(jq:*)", - "WebSearch", - "WebFetch(domain:mcaptcha.org)", - "WebFetch(domain:github.com)", - "Bash(cargo check:*)", - "Bash(npm install:*)", - "Bash(npx tsc:*)", - "Bash(npx vitest:*)", - "Bash(cargo test:*)", - "Bash(npx vite:*)", - "Bash(grep -rn \"password\\\\|secret\\\\|key\" /Users/oabrivard/Projects/rust/ai_synth/backend/src/*.rs)", - "Bash(cargo build:*)" - ] + "defaultMode": "bypassPermissions" } } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d069f89..08b430a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "clap", "dashmap", "dotenvy", + "email_address", "hex", "http-body-util", "rand", @@ -472,6 +473,15 @@ dependencies = [ "serde", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9d70f00..38eedb9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -46,6 +46,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } dotenvy = "0.15" clap = { version = "4", features = ["derive"] } +# Email validation +email_address = "0.2" + # Error handling anyhow = "1" thiserror = "2" diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 5607c83..fa3f78b 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -14,6 +14,8 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::Json; use serde::{Deserialize, Serialize}; +use email_address::EmailAddress; + use crate::app_state::AppState; use crate::db; use crate::errors::AppError; @@ -63,13 +65,12 @@ pub async fn register( State(state): State, Json(body): Json, ) -> Result { - // Validate email format (basic check) - if !body.email.contains('@') || body.email.len() > 254 { + // Validate email format using RFC 5321 validation + let email_lower = body.email.trim().to_lowercase(); + if !EmailAddress::is_valid(&email_lower) || email_lower.len() > 254 { return Err(AppError::BadRequest("Invalid email address".into())); } - let email_lower = body.email.trim().to_lowercase(); - // Rate limit by email if !state.auth_rate_limiter.check(&format!("register:{}", email_lower)) { return Err(AppError::RateLimited( @@ -124,12 +125,12 @@ pub async fn login( State(state): State, Json(body): Json, ) -> Result { - if !body.email.contains('@') || body.email.len() > 254 { + // Validate email format using RFC 5321 validation + let email_lower = body.email.trim().to_lowercase(); + if !EmailAddress::is_valid(&email_lower) || email_lower.len() > 254 { return Err(AppError::BadRequest("Invalid email address".into())); } - let email_lower = body.email.trim().to_lowercase(); - // Rate limit by email if !state.auth_rate_limiter.check(&format!("login:{}", email_lower)) { return Err(AppError::RateLimited( @@ -174,26 +175,25 @@ pub async fn login( )) } -/// `GET /api/v1/auth/verify?token=...` -/// -/// Verifies the magic link token, creates a session, sets the session cookie, -/// and redirects to the app root. -pub async fn verify( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let email = auth::verify_magic_link(&state.pool, &query.token).await?; +/// Request body for `POST /api/v1/auth/verify`. +#[derive(Debug, Deserialize)] +pub struct VerifyBody { + pub token: String, +} + +/// Shared verification logic used by both GET and POST verify endpoints. +async fn verify_token( + state: &AppState, + headers: &HeaderMap, + raw_token: &str, +) -> Result<(uuid::Uuid, String, String), AppError> { + let email = auth::verify_magic_link(&state.pool, raw_token).await?; let email = match email { Some(e) => e, None => { tracing::warn!("Magic link verification failed (invalid, used, or expired)"); - return Ok(Redirect::to(&format!( - "{}/?error=invalid_token", - state.config.app_url - )) - .into_response()); + return Err(AppError::BadRequest("Invalid or expired token".into())); } }; @@ -201,7 +201,7 @@ pub async fn verify( let user = auth::ensure_user(&state.pool, &email, None).await?; // Extract IP and User-Agent for session metadata - let ip_address = extract_client_ip(&headers); + let ip_address = extract_client_ip(headers); let user_agent = headers .get(header::USER_AGENT) .and_then(|v| v.to_str().ok()) @@ -216,22 +216,78 @@ pub async fn verify( ) .await?; - // Build the Set-Cookie header + tracing::info!(user_id = %user.id, email = %email, "User authenticated via magic link"); + Ok((user.id, email, session_token)) +} + +/// Build the Set-Cookie header value for a session. +fn build_session_cookie(state: &AppState, session_token: &str) -> Result { let secure_flag = if state.config.is_secure() { "; Secure" } else { "" }; - let cookie_value = format!( + Ok(format!( "{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", auth::SESSION_COOKIE_NAME, session_token, auth::SESSION_COOKIE_MAX_AGE, secure_flag, - ); + )) +} + +/// `GET /api/v1/auth/verify?token=...` +/// +/// Called when the user clicks the magic link in their email. +/// Verifies the token, creates a session, sets the session cookie, +/// and redirects to the app root. +pub async fn verify_get( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + match verify_token(&state, &headers, &query.token).await { + Ok((_user_id, _email, session_token)) => { + let cookie_value = build_session_cookie(&state, &session_token)?; + let mut response = Redirect::to(&state.config.app_url).into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + cookie_value.parse().map_err(|_| { + AppError::Internal(anyhow::anyhow!("Failed to build cookie header")) + })?, + ); + Ok(response) + } + Err(_) => Ok(Redirect::to(&format!( + "{}/?error=invalid_token", + state.config.app_url + )) + .into_response()), + } +} + +/// `POST /api/v1/auth/verify` +/// +/// Called from the frontend to verify a magic link token. +/// Returns JSON with user info and sets the session cookie. +/// The token is sent in the request body (not URL) to avoid +/// exposure in browser history and server logs. +pub async fn verify_post( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let (_user_id, _email, session_token) = + verify_token(&state, &headers, &body.token).await?; + + let cookie_value = build_session_cookie(&state, &session_token)?; + + let mut response = ( + StatusCode::OK, + Json(serde_json::json!({ "message": "Verified" })), + ) + .into_response(); - // Redirect to app root with the session cookie set - let mut response = Redirect::to(&state.config.app_url).into_response(); response.headers_mut().insert( header::SET_COOKIE, cookie_value @@ -239,7 +295,6 @@ pub async fn verify( .map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to build cookie header")))?, ); - tracing::info!(user_id = %user.id, email = %email, "User authenticated via magic link"); Ok(response) } diff --git a/backend/src/router.rs b/backend/src/router.rs index b8e781d..20e4e49 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -7,6 +7,7 @@ //! - CORS configuration //! - Static file serving with SPA fallback +use axum::extract::DefaultBodyLimit; use axum::http::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use axum::http::Method; use axum::middleware as axum_mw; @@ -29,7 +30,8 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { // Auth routes (public, but CSRF-protected on POST) .route("/auth/register", post(handlers::auth::register)) .route("/auth/login", post(handlers::auth::login)) - .route("/auth/verify", get(handlers::auth::verify)) + .route("/auth/verify", get(handlers::auth::verify_get)) + .route("/auth/verify", post(handlers::auth::verify_post)) .route("/auth/logout", post(handlers::auth::logout)) .route("/auth/me", get(handlers::auth::me)) // Settings routes (authenticated) @@ -57,6 +59,7 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { // Apply global middleware layers app = app + .layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB max request body .layer(TraceLayer::new_for_http()) .layer(build_cors_layer(config)) .layer(SetResponseHeaderLayer::overriding( @@ -96,15 +99,23 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { /// Build the CORS layer based on configuration. /// /// Allows the configured `APP_URL` as origin, with credentials (cookies). +/// Panics at startup if `APP_URL` cannot be parsed — this is intentional +/// to prevent running with a misconfigured (and potentially insecure) CORS policy. fn build_cors_layer(config: &AppConfig) -> CorsLayer { - let origin = config.app_url.parse::().unwrap_or_else(|_| { - tracing::warn!("Failed to parse APP_URL as header value, using permissive CORS"); - HeaderValue::from_static("*") - }); + let origin = config + .app_url + .parse::() + .unwrap_or_else(|e| { + panic!( + "FATAL: APP_URL '{}' is not a valid HTTP header value: {}. \ + Fix APP_URL in your environment before starting the server.", + config.app_url, e + ) + }); CorsLayer::new() .allow_origin(origin) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_methods([Method::GET, Method::POST, Method::PUT]) .allow_headers([ CONTENT_TYPE, ACCEPT, diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8b84fea..d8a07c3 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -16,7 +16,7 @@ export const authApi = { api.post('/auth/login', data), verify: (token: string): Promise => - api.get(`/auth/verify?token=${encodeURIComponent(token)}`), + api.post('/auth/verify', { token }), logout: (): Promise => api.post('/auth/logout'), diff --git a/frontend/src/components/MobileMenu.tsx b/frontend/src/components/MobileMenu.tsx index 4f53d7a..3c42a54 100644 --- a/frontend/src/components/MobileMenu.tsx +++ b/frontend/src/components/MobileMenu.tsx @@ -43,7 +43,7 @@ const MobileMenu: Component = (props) => { {/* Menu panel */}
- Menu + {t('nav.menu')} diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 0ca75fa..9dd37e9 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -5,6 +5,7 @@ const fr = { 'nav.settings': 'Parametres', 'nav.admin': 'Administration', 'nav.logout': 'Deconnexion', + 'nav.menu': 'Menu', 'nav.menuOpen': 'Ouvrir le menu', 'nav.menuClose': 'Fermer le menu', @@ -49,6 +50,7 @@ const fr = { 'home.empty.description': 'Commencez par generer votre premiere synthese hebdomadaire.', 'home.empty.action': 'Generer', + 'home.comingSoon': 'Disponible prochainement', // Settings 'settings.title': 'Parametres de generation', diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index c395882..2b413f4 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -16,7 +16,7 @@ const Home: Component = () => {