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"
},
"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"
}
}

10
backend/Cargo.lock generated

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

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

@ -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<AppState>,
Json(body): Json<RegisterRequest>,
) -> Result<impl IntoResponse, AppError> {
// 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<AppState>,
Json(body): Json<LoginRequest>,
) -> 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()));
}
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<AppState>,
headers: HeaderMap,
Query(query): Query<VerifyQuery>,
) -> Result<Response, AppError> {
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<String, AppError> {
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,
);
))
}
// Redirect to app root with the session cookie set
/// `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();
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)
}

@ -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::<HeaderValue>().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::<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()
.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,

@ -16,7 +16,7 @@ export const authApi = {
api.post<LoginResponse>('/auth/login', data),
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'),

@ -43,7 +43,7 @@ const MobileMenu: Component<MobileMenuProps> = (props) => {
{/* Menu panel */}
<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">
<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
onClick={() => props.onClose()}
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"

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

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

@ -16,7 +16,7 @@ const Home: Component = () => {
<button
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"
title="Disponible prochainement"
title={t('home.comingSoon')}
>
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{t('home.newSynthesis')}

Loading…
Cancel
Save