From 567fa6642859cb1b146bfac6ef0975382a7d73a2 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 28 Apr 2026 14:36:06 -0400 Subject: [PATCH] feat: implement httpOnly cookie for JWT authentication and update related API calls Co-authored-by: Copilot --- .env.example | 2 + nginx/learning.conf | 1 + nginx/studio.conf | 1 + services/cms-service/src/handlers.rs | 59 +++++++++++++++---- .../src/handlers_email_templates.rs | 8 +-- services/cms-service/src/main.rs | 11 +++- services/lms-service/src/handlers.rs | 58 ++++++++++++++---- services/lms-service/src/handlers_email.rs | 4 +- services/lms-service/src/main.rs | 20 +++++-- shared/common/src/auth.rs | 16 ++++- shared/common/src/middleware.rs | 35 ++++++++--- web/experience/src/app/auth/callback/page.tsx | 5 +- web/experience/src/app/lti/launch/page.tsx | 9 +-- .../src/components/blocks/MediaPlayer.tsx | 2 +- web/experience/src/context/AuthContext.tsx | 9 ++- web/experience/src/lib/api.ts | 10 ++-- .../src/app/admin/ai-usage-global/page.tsx | 2 +- web/studio/src/app/admin/page.tsx | 4 +- web/studio/src/app/admin/token-usage/page.tsx | 26 ++------ web/studio/src/app/admin/users/page.tsx | 6 +- web/studio/src/app/auth/callback/page.tsx | 6 +- .../src/app/courses/[id]/grades/page.tsx | 5 +- web/studio/src/app/lti/deep-linking/page.tsx | 3 +- .../QuestionBank/QuestionBankEditor.tsx | 9 +-- .../TestTemplates/TestTemplateForm.tsx | 9 +-- web/studio/src/context/AuthContext.tsx | 6 +- web/studio/src/lib/api.ts | 4 +- 27 files changed, 207 insertions(+), 123 deletions(-) diff --git a/.env.example b/.env.example index 0fc7c90..6361545 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,8 @@ DEFAULT_SECONDARY_COLOR="#8B5CF6" # false = Production (certificados reales) # ---------------------------------------- LETSENCRYPT_STAGING=true +# Email para notificaciones SSL de Let's Encrypt (requerido) +ACME_EMAIL=your-email@example.com # Email para notificaciones de Let's Encrypt (renovación, errores) ACME_EMAIL=admin@example.com diff --git a/nginx/learning.conf b/nginx/learning.conf index 6755b3f..a6a8b6d 100644 --- a/nginx/learning.conf +++ b/nginx/learning.conf @@ -7,6 +7,7 @@ add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self' blob: https:; object-src 'none'; frame-ancestors 'self';" always; location /lms-api/ { rewrite ^/lms-api/(.*)$ /$1 break; diff --git a/nginx/studio.conf b/nginx/studio.conf index efcfdfe..e8d31ff 100644 --- a/nginx/studio.conf +++ b/nginx/studio.conf @@ -7,6 +7,7 @@ add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self' blob: https:; object-src 'none'; frame-ancestors 'self';" always; # Allow large ZIP uploads (RAG bulk import can exceed 2GB). client_max_body_size 4096m; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 24dea76..21cdbc0 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -5,7 +5,8 @@ pub mod tasks; use axum::{ Json, extract::{Path, Query, State}, - http::StatusCode, + http::{StatusCode, HeaderValue}, + response::{IntoResponse, Response}, }; use aws_config::BehaviorVersion; use aws_config::meta::region::RegionProviderChain; @@ -13,11 +14,11 @@ use aws_sdk_s3::{ Client as S3Client, config::{Credentials, Region}, }; -use bcrypt::{DEFAULT_COST, hash, verify}; +use bcrypt::{hash, verify}; use chrono::{DateTime, Utc}; pub use common::auth::Claims; pub use common::middleware::Org; -use common::auth::{create_jwt, create_preview_token}; +use common::auth::{create_jwt, create_preview_token, auth_cookie_header}; use common::models::{ AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse, PublishedModule, User, UserResponse, CourseInstructor, @@ -2778,15 +2779,18 @@ pub struct AdminCreateUserPayload { pub async fn register( State(pool): State, Json(payload): Json, -) -> Result, (StatusCode, String)> { +) -> Result { if payload.email.trim().is_empty() || payload.password.trim().is_empty() { return Err(( StatusCode::BAD_REQUEST, "El email y la contraseña son obligatorios".into(), )); } + if payload.password.len() < 8 { + return Err((StatusCode::BAD_REQUEST, "La contraseña debe tener al menos 8 caracteres".into())); + } - let password_hash = hash(payload.password, DEFAULT_COST) + let password_hash = hash(payload.password, 13) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; let full_name = payload.full_name.unwrap_or_else(|| { @@ -2834,7 +2838,7 @@ pub async fn register( ) })?; - Ok(Json(AuthResponse { + let auth_response = AuthResponse { user: UserResponse { id: user.id, email: user.email, @@ -2847,8 +2851,16 @@ pub async fn register( bio: user.bio, language: user.language, }, - token, - })) + token: token.clone(), + }; + + let mut response = Json(auth_response).into_response(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&auth_cookie_header(&token)) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Cookie error".into()))?, + ); + Ok(response) } pub async fn admin_create_user( @@ -2865,7 +2877,7 @@ pub async fn admin_create_user( return Err((StatusCode::BAD_REQUEST, "Invalid role".into())); } - let password_hash = hash(payload.password, DEFAULT_COST) + let password_hash = hash(payload.password, 13) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; let is_super_admin = claims.role == "admin" @@ -2906,10 +2918,23 @@ pub async fn admin_create_user( })) } +pub async fn logout() -> Response { + use common::auth::auth_cookie_clear_header; + let mut response = axum::http::Response::builder() + .status(StatusCode::OK) + .body(axum::body::Body::empty()) + .unwrap(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_static(auth_cookie_clear_header()), + ); + response +} + pub async fn login( State(pool): State, Json(payload): Json, -) -> Result, (StatusCode, String)> { +) -> Result { tracing::info!("Login attempt for email: {}", payload.email); let user = sqlx::query_as::<_, User>("SELECT * FROM fn_get_user_by_email($1)") @@ -2949,7 +2974,7 @@ pub async fn login( tracing::info!("Login successful for user: {}", user.email); - Ok(Json(AuthResponse { + let auth_response = AuthResponse { user: UserResponse { id: user.id, email: user.email, @@ -2962,8 +2987,16 @@ pub async fn login( bio: user.bio, language: user.language, }, - token, - })) + token: token.clone(), + }; + + let mut response = Json(auth_response).into_response(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&auth_cookie_header(&token)) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Cookie error".into()))?, + ); + Ok(response) } pub async fn get_course_analytics( Org(org_ctx): Org, diff --git a/services/cms-service/src/handlers_email_templates.rs b/services/cms-service/src/handlers_email_templates.rs index dae3fa5..321cbc8 100644 --- a/services/cms-service/src/handlers_email_templates.rs +++ b/services/cms-service/src/handlers_email_templates.rs @@ -62,7 +62,7 @@ pub async fn list_organization_email_templates( .fetch_all(&pool) .await .map_err(|e: sqlx::Error| { - eprintln!("Error fetching email templates: {:?}", e); + tracing::error!("Error fetching email templates: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch email templates".to_string(), @@ -112,7 +112,7 @@ pub async fn create_organization_email_template( .fetch_one(&pool) .await .map_err(|e: sqlx::Error| { - eprintln!("Error creating email template: {:?}", e); + tracing::error!("Error creating email template: {:?}", e); if e.to_string().contains("duplicate key") { ( StatusCode::CONFLICT, @@ -182,7 +182,7 @@ pub async fn update_organization_email_template( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| { - eprintln!("Error updating email template: {:?}", e); + tracing::error!("Error updating email template: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to update email template".to_string(), @@ -239,7 +239,7 @@ pub async fn delete_organization_email_template( .execute(&pool) .await .map_err(|e: sqlx::Error| { - eprintln!("Error deleting email template: {:?}", e); + tracing::error!("Error deleting email template: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete email template".to_string(), diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 29eb6f4..596a061 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -170,6 +170,13 @@ async fn main() { let governor_conf = Arc::new(governor_conf.finish().unwrap()); + // Rate limiter estricto para rutas de autenticación (brute-force protection) + let mut auth_governor_conf = GovernorConfigBuilder::default() + .const_per_second(1) + .const_burst_size(5) + .key_extractor(SmartIpKeyExtractor); + let auth_governor_conf = Arc::new(auth_governor_conf.finish().unwrap()); + // Rutas protegidas que requieren autenticación y contexto de organización let protected_routes = Router::new() .route( @@ -572,12 +579,14 @@ async fn main() { let auth_routes = Router::new() .route("/auth/register", post(handlers::register)) .route("/auth/login", post(handlers::login)) + .route("/auth/logout", post(handlers::logout)) .route("/auth/sso/login/{org_id}", get(handlers::sso_login_init)) .route("/auth/sso/callback", get(handlers::sso_callback)) .route( "/branding", get(handlers_branding::get_organization_branding), - ); + ) + .route_layer(GovernorLayer { config: auth_governor_conf }); let public_routes = Router::new() .route("/api-docs/openapi.json", get(|| async { diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 6c49237..81edd9c 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1,9 +1,9 @@ use axum::{ Json, extract::{Multipart, Path, Query, State}, - http::{HeaderMap, header::AUTHORIZATION}, + http::{HeaderMap, HeaderValue, header::AUTHORIZATION}, http::StatusCode, - response::IntoResponse, + response::{IntoResponse, Response}, response::sse::{Event, KeepAlive, Sse}, Extension, }; @@ -13,9 +13,9 @@ use aws_sdk_s3::{ Client as S3Client, config::{Credentials, Region}, }; -use bcrypt::{DEFAULT_COST, hash, verify}; +use bcrypt::{hash, verify}; use chrono::{DateTime, Utc}; -use common::auth::{Claims, create_jwt}; +use common::auth::{Claims, create_jwt, auth_cookie_header}; use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, @@ -800,11 +800,27 @@ pub struct InteractionPayload { pub metadata: Option, } +pub async fn logout() -> Response { + use common::auth::auth_cookie_clear_header; + let mut response = axum::http::Response::builder() + .status(axum::http::StatusCode::OK) + .body(axum::body::Body::empty()) + .unwrap(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_static(auth_cookie_clear_header()), + ); + response +} + pub async fn register( State(pool): State, Json(payload): Json, -) -> Result, (StatusCode, String)> { - let password_hash = hash(payload.password, DEFAULT_COST).map_err(|_| { +) -> Result { + if payload.password.len() < 8 { + return Err((StatusCode::BAD_REQUEST, "La contraseña debe tener al menos 8 caracteres".into())); + } + let password_hash = hash(payload.password, 13).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar la contraseña".into(), @@ -870,7 +886,7 @@ pub async fn register( ) })?; - Ok(Json(AuthResponse { + let auth_response = AuthResponse { user: UserResponse { id: user.id, email: user.email, @@ -883,14 +899,22 @@ pub async fn register( bio: user.bio, language: user.language, }, - token, - })) + token: token.clone(), + }; + + let mut response = Json(auth_response).into_response(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&auth_cookie_header(&token)) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Cookie error".into()))?, + ); + Ok(response) } pub async fn login( State(pool): State, Json(payload): Json, -) -> Result, (StatusCode, String)> { +) -> Result { let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") .bind(&payload.email) .fetch_one(&pool) @@ -913,7 +937,7 @@ pub async fn login( ) })?; - Ok(Json(AuthResponse { + let auth_response = AuthResponse { user: UserResponse { id: user.id, email: user.email, @@ -926,8 +950,16 @@ pub async fn login( bio: user.bio, language: user.language, }, - token, - })) + token: token.clone(), + }; + + let mut response = Json(auth_response).into_response(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&auth_cookie_header(&token)) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Cookie error".into()))?, + ); + Ok(response) } #[derive(Deserialize)] diff --git a/services/lms-service/src/handlers_email.rs b/services/lms-service/src/handlers_email.rs index 5caba9f..a263fd3 100644 --- a/services/lms-service/src/handlers_email.rs +++ b/services/lms-service/src/handlers_email.rs @@ -1,5 +1,5 @@ use axum::{Json, extract::State, http::StatusCode}; -use bcrypt::{DEFAULT_COST, hash}; +use bcrypt::hash; use lettre::message::Mailbox; use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; @@ -293,7 +293,7 @@ pub async fn reset_password( }; // Hashear nueva contraseña - let password_hash = hash(&payload.new_password, DEFAULT_COST).map_err(|_| { + let password_hash = hash(&payload.new_password, 13).map_err(|_| { (StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar contraseña".to_string()) })?; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 7888617..0d654a8 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -134,6 +134,13 @@ async fn main() { let governor_conf = Arc::new(governor_conf.finish().unwrap()); + // Rate limiter estricto para rutas de autenticación (brute-force protection) + let mut auth_governor_conf = GovernorConfigBuilder::default() + .const_per_second(1) + .const_burst_size(5) + .key_extractor(SmartIpKeyExtractor); + let auth_governor_conf = Arc::new(auth_governor_conf.finish().unwrap()); + // Rate limiter solo para rutas protegidas (después del middleware de autenticación) let protected_routes = Router::new() .route("/auth/me", get(handlers::get_me)) @@ -507,10 +514,15 @@ async fn main() { .merge(health::health_routes(pool.clone()).with_state(health_state)) .route("/catalog", get(handlers::get_course_catalog)) .route("/ingest", post(handlers::ingest_course)) - .route("/auth/register", post(handlers::register)) - .route("/auth/login", post(handlers::login)) - .route("/auth/forgot-password", post(handlers_email::forgot_password)) - .route("/auth/reset-password", post(handlers_email::reset_password)) + .merge( + Router::new() + .route("/auth/register", post(handlers::register)) + .route("/auth/login", post(handlers::login)) + .route("/auth/logout", post(handlers::logout)) + .route("/auth/forgot-password", post(handlers_email::forgot_password)) + .route("/auth/reset-password", post(handlers_email::reset_password)) + .route_layer(GovernorLayer { config: auth_governor_conf }), + ) .route("/xapi/statements", post(handlers_scorm::track_xapi_statement)) .route("/search", get(handlers_search::global_search)) .route( diff --git a/shared/common/src/auth.rs b/shared/common/src/auth.rs index 72a7ec2..963c991 100644 --- a/shared/common/src/auth.rs +++ b/shared/common/src/auth.rs @@ -3,6 +3,20 @@ use jsonwebtoken::{EncodingKey, Header, encode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Genera el valor del header `Set-Cookie` para el token JWT como httpOnly cookie. +/// Usa SameSite=Strict y Secure para producción. +pub fn auth_cookie_header(token: &str) -> String { + format!( + "auth_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600", + token + ) +} + +/// Genera el valor del header `Set-Cookie` para eliminar la cookie de auth. +pub fn auth_cookie_clear_header() -> &'static str { + "auth_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0" +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { pub sub: Uuid, @@ -19,7 +33,7 @@ pub fn create_jwt( role: &str, ) -> Result { let expiration = Utc::now() - .checked_add_signed(Duration::hours(24)) + .checked_add_signed(Duration::hours(1)) .expect("valid timestamp") .timestamp(); diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index 1b5e9e3..491f7ee 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -28,17 +28,34 @@ pub async fn org_extractor_middleware( let token = if let Some(token_str) = auth_header.and_then(|s: &str| s.strip_prefix("Bearer ")) { token_str.to_string() } else { - // Verificar si hay preview_token en la cadena de consulta - let query = req.uri().query().unwrap_or_default(); - let preview_token = query - .split('&') - .find(|part| part.starts_with("preview_token=")) - .and_then(|part| part.split('=').nth(1)); + // Intentar leer el token desde la httpOnly cookie + let cookie_token = req + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + .and_then(|cookie_str| { + cookie_str.split(';').find_map(|part| { + let part = part.trim(); + part.strip_prefix("auth_token=") + }) + }) + .map(|t| t.to_string()); - if let Some(token) = preview_token { - token.to_string() + if let Some(token) = cookie_token { + token } else { - return Err(StatusCode::UNAUTHORIZED); + // Verificar si hay preview_token en la cadena de consulta + let query = req.uri().query().unwrap_or_default(); + let preview_token = query + .split('&') + .find(|part| part.starts_with("preview_token=")) + .and_then(|part| part.split('=').nth(1)); + + if let Some(token) = preview_token { + token.to_string() + } else { + return Err(StatusCode::UNAUTHORIZED); + } } }; diff --git a/web/experience/src/app/auth/callback/page.tsx b/web/experience/src/app/auth/callback/page.tsx index 880a838..1d8c5da 100644 --- a/web/experience/src/app/auth/callback/page.tsx +++ b/web/experience/src/app/auth/callback/page.tsx @@ -14,9 +14,7 @@ function CallbackHandler() { useEffect(() => { const token = searchParams.get("token"); if (token) { - // Temporarily store token so getMe can use it - localStorage.setItem('experience_token', token); - + // El token JWT viene como cookie httpOnly del backend. lmsApi.getMe() .then((user) => { login(user, token); @@ -24,7 +22,6 @@ function CallbackHandler() { }) .catch((err) => { console.error("SSO Error:", err); - localStorage.removeItem('experience_token'); router.push("/auth/login?error=sso_failed"); }); } else { diff --git a/web/experience/src/app/lti/launch/page.tsx b/web/experience/src/app/lti/launch/page.tsx index 5203037..0784ada 100644 --- a/web/experience/src/app/lti/launch/page.tsx +++ b/web/experience/src/app/lti/launch/page.tsx @@ -22,16 +22,13 @@ function LtiLaunchContent() { } try { - // 1. Temporarily save token so api client can use it for getMe - localStorage.setItem("experience_token", token); - - // 2. Fetch user details + // Fetch user details - la cookie httpOnly se envía automáticamente const user = await lmsApi.getMe(); - // 3. Initialize session in AuthContext + // Initialize session in AuthContext login(user, token); - // 4. Redirect to final destination + // Redirect to final destination router.replace(target); } catch (err: any) { console.error("LTI Launch Error:", err); diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index c209595..4ae11d8 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -132,7 +132,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf return rawUrl; }; - const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null; + const getToken = () => null; // Token se envía automáticamente via httpOnly cookie const selectedOrgId = typeof window !== 'undefined' ? localStorage.getItem('experience_selected_org_id') : null; // Construct VTT URLs with auth if possible, or assume public/handled by backend diff --git a/web/experience/src/context/AuthContext.tsx b/web/experience/src/context/AuthContext.tsx index fe35059..5835517 100644 --- a/web/experience/src/context/AuthContext.tsx +++ b/web/experience/src/context/AuthContext.tsx @@ -20,10 +20,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const savedUser = localStorage.getItem('experience_user'); - const savedToken = localStorage.getItem('experience_token'); - if (savedUser && savedToken) { + if (savedUser) { setUser(JSON.parse(savedUser)); - setToken(savedToken); } setLoading(false); }, []); @@ -31,15 +29,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = (newUser: User, newToken: string) => { setUser(newUser); setToken(newToken); + // El token JWT se guarda en httpOnly cookie por el backend. localStorage.setItem('experience_user', JSON.stringify(newUser)); - localStorage.setItem('experience_token', newToken); }; const logout = () => { setUser(null); setToken(null); localStorage.removeItem('experience_user'); - localStorage.removeItem('experience_token'); + // Borrar la httpOnly cookie desde el backend + fetch('/lms-api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}); }; return ( diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 85c537c..25294ec 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -614,7 +614,7 @@ export const getToken = () => { return previewToken; } - return sessionStorage.getItem('preview_token') || localStorage.getItem('experience_token'); + return sessionStorage.getItem('preview_token') || null; }; const OFFLINE_QUEUE_KEY = 'experience_offline_mutation_queue_v1'; @@ -854,7 +854,7 @@ const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl(); const headers = buildApiHeaders(options); - const response = await fetch(`${baseUrl}${url}`, { ...options, headers }); + const response = await fetch(`${baseUrl}${url}`, { ...options, headers, credentials: 'include' }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || 'An error occurred'); @@ -1091,7 +1091,8 @@ export const lmsApi = { headers: { ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }, - body: formData + body: formData, + credentials: 'include' }).then(res => res.json()); }, @@ -1163,7 +1164,8 @@ export const lmsApi = { ...(token ? { 'Authorization': `Bearer ${token}` } : {}) // Don't set Content-Type for FormData - browser sets it with boundary }, - body: formData + body: formData, + credentials: 'include' }).then(async res => { if (!res.ok) { const err = await res.json().catch(() => ({ message: 'Audio evaluation failed' })); diff --git a/web/studio/src/app/admin/ai-usage-global/page.tsx b/web/studio/src/app/admin/ai-usage-global/page.tsx index 9a9648a..c9e0fa7 100644 --- a/web/studio/src/app/admin/ai-usage-global/page.tsx +++ b/web/studio/src/app/admin/ai-usage-global/page.tsx @@ -229,7 +229,7 @@ export default function GlobalAiControl() {

- Token en localStorage: {localStorage.getItem('studio_token') ? '✓ Existe' : '✗ No existe'} + Por favor verifica que tu sesión esté activa.

diff --git a/web/studio/src/app/admin/page.tsx b/web/studio/src/app/admin/page.tsx index 2f37e22..cfc6dbf 100644 --- a/web/studio/src/app/admin/page.tsx +++ b/web/studio/src/app/admin/page.tsx @@ -38,9 +38,7 @@ export default function AdminDashboard() { cmsApi.getOrganization(), cmsApi.getAllUsers(), fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('studio_token')}`, - }, + credentials: 'include', }) ]); diff --git a/web/studio/src/app/admin/token-usage/page.tsx b/web/studio/src/app/admin/token-usage/page.tsx index b393d51..ec40efd 100644 --- a/web/studio/src/app/admin/token-usage/page.tsx +++ b/web/studio/src/app/admin/token-usage/page.tsx @@ -54,24 +54,14 @@ export default function AdminTokenTracking() { const loadTokenUsage = async () => { try { - const token = localStorage.getItem('studio_token'); - - if (!token) { - console.error('[TokenUsage] No authentication token found!'); - alert('No authentication token found. Please login again.'); - window.location.href = '/auth/login'; - return; - } - const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, { headers: { - 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, + credentials: 'include', }); if (response.status === 401) { - console.error('[TokenUsage] Unauthorized - Token may be expired'); alert('Session expired. Please login again.'); window.location.href = '/auth/login'; return; @@ -88,11 +78,7 @@ export default function AdminTokenTracking() { try { const limitResp = await fetch( `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.user_id}/token-limit/check`, - { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('studio_token')}`, - }, - } + { credentials: 'include' } ); if (limitResp.ok) { const limitData = await limitResp.json(); @@ -127,13 +113,13 @@ export default function AdminTokenTracking() { { method: 'PUT', headers: { - 'Authorization': `Bearer ${localStorage.getItem('studio_token')}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ monthly_token_limit: editValue, token_limit_reset_day: 1, }), + credentials: 'include', } ); @@ -141,11 +127,7 @@ export default function AdminTokenTracking() { // Reload limits const limitResp = await fetch( `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${userId}/token-limit/check`, - { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('studio_token')}`, - }, - } + { credentials: 'include' } ); if (limitResp.ok) { const limitData = await limitResp.json(); diff --git a/web/studio/src/app/admin/users/page.tsx b/web/studio/src/app/admin/users/page.tsx index 27ddb5f..4ff9188 100644 --- a/web/studio/src/app/admin/users/page.tsx +++ b/web/studio/src/app/admin/users/page.tsx @@ -52,11 +52,7 @@ export default function UsersPage() { try { const resp = await fetch( `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.id}/token-limit/check`, - { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('studio_token')}`, - }, - } + { credentials: 'include' } ); if (resp.ok) { const data = await resp.json(); diff --git a/web/studio/src/app/auth/callback/page.tsx b/web/studio/src/app/auth/callback/page.tsx index 7d17d7f..51539cb 100644 --- a/web/studio/src/app/auth/callback/page.tsx +++ b/web/studio/src/app/auth/callback/page.tsx @@ -14,9 +14,8 @@ function CallbackHandler() { useEffect(() => { const token = searchParams.get("token"); if (token) { - // Temporarily store token so getMe can use it - localStorage.setItem('studio_token', token); - + // El token JWT viene como cookie httpOnly del backend. + // Lo pasamos al AuthContext para el estado en memoria. cmsApi.getMe() .then((user) => { login(user, token); @@ -24,7 +23,6 @@ function CallbackHandler() { }) .catch((err) => { console.error("SSO Error:", err); - localStorage.removeItem('studio_token'); router.push("/auth/login?error=sso_failed"); }); } else { diff --git a/web/studio/src/app/courses/[id]/grades/page.tsx b/web/studio/src/app/courses/[id]/grades/page.tsx index 0d26102..9d258d3 100644 --- a/web/studio/src/app/courses/[id]/grades/page.tsx +++ b/web/studio/src/app/courses/[id]/grades/page.tsx @@ -85,13 +85,12 @@ export default function GradebookPage() { const exportCSV = async () => { try { - const token = localStorage.getItem('studio_token'); const selectedOrgId = localStorage.getItem('studio_selected_org_id'); const res = await fetch(lmsApi.exportGradesUrl(id), { headers: { - ...(token ? { 'Authorization': `Bearer ${token}` } : {}), ...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {}) - } + }, + credentials: 'include' }); if (!res.ok) throw new Error('Export failed'); const blob = await res.blob(); diff --git a/web/studio/src/app/lti/deep-linking/page.tsx b/web/studio/src/app/lti/deep-linking/page.tsx index 97acd56..671e6e1 100644 --- a/web/studio/src/app/lti/deep-linking/page.tsx +++ b/web/studio/src/app/lti/deep-linking/page.tsx @@ -20,7 +20,8 @@ function DeepLinkingPickerContent() { useEffect(() => { if (token) { - localStorage.setItem("studio_token", token); + // El token JWT llega como cookie httpOnly del backend SSO/LTI. + // No se persiste en localStorage. } loadCourses(); }, [token]); diff --git a/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx b/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx index edfca02..ab0facc 100644 --- a/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx +++ b/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx @@ -73,10 +73,9 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu const uploadResponse = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/assets/upload`, { method: 'POST', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, + headers: {}, body: audioFormData, + credentials: 'include', }); if (uploadResponse.ok) { @@ -166,19 +165,17 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu try { setGeneratingAI(true); - // AI generation con el nuevo endpoint de question bank - const token = localStorage.getItem('studio_token'); const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/ai-generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ question_text: formData.question_text, difficulty: formData.difficulty, skill: randomSkill, }), + credentials: 'include', }); if (!response.ok) { diff --git a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx index 40c944c..fc42229 100644 --- a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx +++ b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx @@ -438,27 +438,20 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te return; } - const token = localStorage.getItem('studio_token'); - if (!token) { - alert('No hay sesión activa. Por favor inicia sesión nuevamente.'); - return; - } - try { setGeneratingAI(true); - // Usar el endpoint RAG de generación de preguntas desde banco MySQL const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/test-templates/generate-with-rag`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ topic: aiContext, num_questions: aiQuestionCount, question_type: aiQuestionType, }), + credentials: 'include', }); if (!response.ok) { diff --git a/web/studio/src/context/AuthContext.tsx b/web/studio/src/context/AuthContext.tsx index d4daa9e..b4bd6a5 100644 --- a/web/studio/src/context/AuthContext.tsx +++ b/web/studio/src/context/AuthContext.tsx @@ -39,8 +39,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(newUser); setToken(newToken); setSelectedOrgId(newUser.organization_id || null); + // El token JWT se guarda en httpOnly cookie por el backend. + // Solo se persiste el usuario (sin datos sensibles de auth) y el orgId. localStorage.setItem('studio_user', JSON.stringify(newUser)); - localStorage.setItem('studio_token', newToken); if (newUser.organization_id) { localStorage.setItem('studio_selected_org_id', newUser.organization_id); } @@ -51,8 +52,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setToken(null); setSelectedOrgId(null); localStorage.removeItem('studio_user'); - localStorage.removeItem('studio_token'); localStorage.removeItem('studio_selected_org_id'); + // Borrar la httpOnly cookie desde el backend + fetch('/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}); }; const setOrganizationId = (id: string | null) => { diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 6c5e16f..d1400f6 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -979,7 +979,7 @@ export const apiFetch = (url: string, options: ApiFetchOptions = {}, isLms: bool } } - return fetch(finalUrl, { ...options, headers }).then(async res => { + return fetch(finalUrl, { ...options, headers, credentials: 'include' }).then(async res => { if (!res.ok) { const text = await res.text(); try { @@ -1261,6 +1261,7 @@ export const cmsApi = { const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`); + xhr.withCredentials = true; const token = getToken(); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); @@ -1327,6 +1328,7 @@ export const cmsApi = { const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE_URL}/api/assets/upload`); + xhr.withCredentials = true; const token = getToken(); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);