Fixed critical problems from phase 1

master
oabrivard 3 months ago
parent 355dbf6a5a
commit a36e3732bf

@ -3,20 +3,6 @@
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}, },
"permissions": { "permissions": {
"allow": [ "defaultMode": "bypassPermissions"
"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:*)"
]
} }
} }

10
backend/Cargo.lock generated

@ -22,6 +22,7 @@ dependencies = [
"clap", "clap",
"dashmap", "dashmap",
"dotenvy", "dotenvy",
"email_address",
"hex", "hex",
"http-body-util", "http-body-util",
"rand", "rand",
@ -472,6 +473,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"

@ -46,6 +46,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
dotenvy = "0.15" dotenvy = "0.15"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
# Email validation
email_address = "0.2"
# Error handling # Error handling
anyhow = "1" anyhow = "1"
thiserror = "2" thiserror = "2"

@ -14,6 +14,8 @@ use axum::response::{IntoResponse, Redirect, Response};
use axum::Json; use axum::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use email_address::EmailAddress;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::db; use crate::db;
use crate::errors::AppError; use crate::errors::AppError;
@ -63,13 +65,12 @@ pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<RegisterRequest>, Json(body): Json<RegisterRequest>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// Validate email format (basic check) // Validate email format using RFC 5321 validation
if !body.email.contains('@') || body.email.len() > 254 { 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())); return Err(AppError::BadRequest("Invalid email address".into()));
} }
let email_lower = body.email.trim().to_lowercase();
// Rate limit by email // Rate limit by email
if !state.auth_rate_limiter.check(&format!("register:{}", email_lower)) { if !state.auth_rate_limiter.check(&format!("register:{}", email_lower)) {
return Err(AppError::RateLimited( return Err(AppError::RateLimited(
@ -124,12 +125,12 @@ pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<LoginRequest>, Json(body): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
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())); return Err(AppError::BadRequest("Invalid email address".into()));
} }
let email_lower = body.email.trim().to_lowercase();
// Rate limit by email // Rate limit by email
if !state.auth_rate_limiter.check(&format!("login:{}", email_lower)) { if !state.auth_rate_limiter.check(&format!("login:{}", email_lower)) {
return Err(AppError::RateLimited( return Err(AppError::RateLimited(
@ -174,26 +175,25 @@ pub async fn login(
)) ))
} }
/// `GET /api/v1/auth/verify?token=...` /// Request body for `POST /api/v1/auth/verify`.
/// #[derive(Debug, Deserialize)]
/// Verifies the magic link token, creates a session, sets the session cookie, pub struct VerifyBody {
/// and redirects to the app root. pub token: String,
pub async fn verify( }
State(state): State<AppState>,
headers: HeaderMap, /// Shared verification logic used by both GET and POST verify endpoints.
Query(query): Query<VerifyQuery>, async fn verify_token(
) -> Result<Response, AppError> { state: &AppState,
let email = auth::verify_magic_link(&state.pool, &query.token).await?; 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 { let email = match email {
Some(e) => e, Some(e) => e,
None => { None => {
tracing::warn!("Magic link verification failed (invalid, used, or expired)"); tracing::warn!("Magic link verification failed (invalid, used, or expired)");
return Ok(Redirect::to(&format!( return Err(AppError::BadRequest("Invalid or expired token".into()));
"{}/?error=invalid_token",
state.config.app_url
))
.into_response());
} }
}; };
@ -201,7 +201,7 @@ pub async fn verify(
let user = auth::ensure_user(&state.pool, &email, None).await?; let user = auth::ensure_user(&state.pool, &email, None).await?;
// Extract IP and User-Agent for session metadata // 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 let user_agent = headers
.get(header::USER_AGENT) .get(header::USER_AGENT)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@ -216,22 +216,78 @@ pub async fn verify(
) )
.await?; .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<String, AppError> {
let secure_flag = if state.config.is_secure() { let secure_flag = if state.config.is_secure() {
"; Secure" "; Secure"
} else { } else {
"" ""
}; };
let cookie_value = format!( Ok(format!(
"{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", "{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
auth::SESSION_COOKIE_NAME, auth::SESSION_COOKIE_NAME,
session_token, session_token,
auth::SESSION_COOKIE_MAX_AGE, auth::SESSION_COOKIE_MAX_AGE,
secure_flag, 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<AppState>,
headers: HeaderMap,
Query(query): Query<VerifyQuery>,
) -> Result<Response, AppError> {
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<AppState>,
headers: HeaderMap,
Json(body): Json<VerifyBody>,
) -> Result<Response, AppError> {
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( response.headers_mut().insert(
header::SET_COOKIE, header::SET_COOKIE,
cookie_value cookie_value
@ -239,7 +295,6 @@ pub async fn verify(
.map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to build cookie header")))?, .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) Ok(response)
} }

@ -7,6 +7,7 @@
//! - CORS configuration //! - CORS configuration
//! - Static file serving with SPA fallback //! - Static file serving with SPA fallback
use axum::extract::DefaultBodyLimit;
use axum::http::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use axum::http::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use axum::http::Method; use axum::http::Method;
use axum::middleware as axum_mw; 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) // Auth routes (public, but CSRF-protected on POST)
.route("/auth/register", post(handlers::auth::register)) .route("/auth/register", post(handlers::auth::register))
.route("/auth/login", post(handlers::auth::login)) .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/logout", post(handlers::auth::logout))
.route("/auth/me", get(handlers::auth::me)) .route("/auth/me", get(handlers::auth::me))
// Settings routes (authenticated) // Settings routes (authenticated)
@ -57,6 +59,7 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router {
// Apply global middleware layers // Apply global middleware layers
app = app app = app
.layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB max request body
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(build_cors_layer(config)) .layer(build_cors_layer(config))
.layer(SetResponseHeaderLayer::overriding( .layer(SetResponseHeaderLayer::overriding(
@ -96,15 +99,23 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router {
/// Build the CORS layer based on configuration. /// Build the CORS layer based on configuration.
/// ///
/// Allows the configured `APP_URL` as origin, with credentials (cookies). /// 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 { fn build_cors_layer(config: &AppConfig) -> CorsLayer {
let origin = config.app_url.parse::<HeaderValue>().unwrap_or_else(|_| { let origin = config
tracing::warn!("Failed to parse APP_URL as header value, using permissive CORS"); .app_url
HeaderValue::from_static("*") .parse::<HeaderValue>()
}); .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() CorsLayer::new()
.allow_origin(origin) .allow_origin(origin)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) .allow_methods([Method::GET, Method::POST, Method::PUT])
.allow_headers([ .allow_headers([
CONTENT_TYPE, CONTENT_TYPE,
ACCEPT, ACCEPT,

@ -16,7 +16,7 @@ export const authApi = {
api.post<LoginResponse>('/auth/login', data), api.post<LoginResponse>('/auth/login', data),
verify: (token: string): Promise<VerifyResponse> => verify: (token: string): Promise<VerifyResponse> =>
api.get<VerifyResponse>(`/auth/verify?token=${encodeURIComponent(token)}`), api.post<VerifyResponse>('/auth/verify', { token }),
logout: (): Promise<void> => api.post<void>('/auth/logout'), logout: (): Promise<void> => api.post<void>('/auth/logout'),

@ -43,7 +43,7 @@ const MobileMenu: Component<MobileMenuProps> = (props) => {
{/* Menu panel */} {/* Menu panel */}
<div class="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-50"> <div class="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-50">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200"> <div class="flex items-center justify-between px-4 py-4 border-b border-gray-200">
<span class="text-lg font-semibold text-gray-900">Menu</span> <span class="text-lg font-semibold text-gray-900">{t('nav.menu')}</span>
<button <button
onClick={() => props.onClose()} onClick={() => props.onClose()}
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100" class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"

@ -8,6 +8,7 @@ import {
} from 'solid-js'; } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import { CheckCircle, XCircle, Info, X } from 'lucide-solid'; import { CheckCircle, XCircle, Info, X } from 'lucide-solid';
import { useI18n } from '~/i18n';
interface Toast { interface Toast {
id: string; id: string;
@ -81,6 +82,8 @@ const ToastItem: Component<{
toast: Toast; toast: Toast;
onDismiss: (id: string) => void; onDismiss: (id: string) => void;
}> = (props) => { }> = (props) => {
const { t } = useI18n();
const Icon = () => { const Icon = () => {
const IconComp = iconMap[props.toast.type]; const IconComp = iconMap[props.toast.type];
if (IconComp) { if (IconComp) {
@ -99,7 +102,7 @@ const ToastItem: Component<{
<button <button
onClick={() => props.onDismiss(props.toast.id)} onClick={() => props.onDismiss(props.toast.id)}
class="flex-shrink-0 hover:opacity-70" class="flex-shrink-0 hover:opacity-70"
aria-label="Fermer" aria-label={t('common.close')}
> >
<X class="h-4 w-4" /> <X class="h-4 w-4" />
</button> </button>

@ -5,6 +5,7 @@ const fr = {
'nav.settings': 'Parametres', 'nav.settings': 'Parametres',
'nav.admin': 'Administration', 'nav.admin': 'Administration',
'nav.logout': 'Deconnexion', 'nav.logout': 'Deconnexion',
'nav.menu': 'Menu',
'nav.menuOpen': 'Ouvrir le menu', 'nav.menuOpen': 'Ouvrir le menu',
'nav.menuClose': 'Fermer le menu', 'nav.menuClose': 'Fermer le menu',
@ -49,6 +50,7 @@ const fr = {
'home.empty.description': 'home.empty.description':
'Commencez par generer votre premiere synthese hebdomadaire.', 'Commencez par generer votre premiere synthese hebdomadaire.',
'home.empty.action': 'Generer', 'home.empty.action': 'Generer',
'home.comingSoon': 'Disponible prochainement',
// Settings // Settings
'settings.title': 'Parametres de generation', 'settings.title': 'Parametres de generation',

@ -16,7 +16,7 @@ const Home: Component = () => {
<button <button
disabled disabled
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
title="Disponible prochainement" title={t('home.comingSoon')}
> >
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" /> <Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{t('home.newSynthesis')} {t('home.newSynthesis')}

Loading…
Cancel
Save