feat: implement httpOnly cookie for JWT authentication and update related API calls
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -96,6 +96,8 @@ DEFAULT_SECONDARY_COLOR="#8B5CF6"
|
|||||||
# false = Production (certificados reales)
|
# false = Production (certificados reales)
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
LETSENCRYPT_STAGING=true
|
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)
|
# Email para notificaciones de Let's Encrypt (renovación, errores)
|
||||||
ACME_EMAIL=admin@example.com
|
ACME_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ add_header X-Content-Type-Options "nosniff" always;
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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/ {
|
location /lms-api/ {
|
||||||
rewrite ^/lms-api/(.*)$ /$1 break;
|
rewrite ^/lms-api/(.*)$ /$1 break;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ add_header X-Content-Type-Options "nosniff" always;
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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).
|
# Allow large ZIP uploads (RAG bulk import can exceed 2GB).
|
||||||
client_max_body_size 4096m;
|
client_max_body_size 4096m;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ pub mod tasks;
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::{StatusCode, HeaderValue},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use aws_config::BehaviorVersion;
|
use aws_config::BehaviorVersion;
|
||||||
use aws_config::meta::region::RegionProviderChain;
|
use aws_config::meta::region::RegionProviderChain;
|
||||||
@@ -13,11 +14,11 @@ use aws_sdk_s3::{
|
|||||||
Client as S3Client,
|
Client as S3Client,
|
||||||
config::{Credentials, Region},
|
config::{Credentials, Region},
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{hash, verify};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
pub use common::auth::Claims;
|
pub use common::auth::Claims;
|
||||||
pub use common::middleware::Org;
|
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::{
|
use common::models::{
|
||||||
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
||||||
PublishedModule, User, UserResponse, CourseInstructor,
|
PublishedModule, User, UserResponse, CourseInstructor,
|
||||||
@@ -2778,15 +2779,18 @@ pub struct AdminCreateUserPayload {
|
|||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<AuthPayload>,
|
Json(payload): Json<AuthPayload>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
if payload.email.trim().is_empty() || payload.password.trim().is_empty() {
|
if payload.email.trim().is_empty() || payload.password.trim().is_empty() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"El email y la contraseña son obligatorios".into(),
|
"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()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||||
|
|
||||||
let full_name = payload.full_name.unwrap_or_else(|| {
|
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 {
|
user: UserResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -2847,8 +2851,16 @@ pub async fn register(
|
|||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
language: user.language,
|
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(
|
pub async fn admin_create_user(
|
||||||
@@ -2865,7 +2877,7 @@ pub async fn admin_create_user(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "Invalid role".into()));
|
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()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||||
|
|
||||||
let is_super_admin = claims.role == "admin"
|
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(
|
pub async fn login(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<AuthPayload>,
|
Json(payload): Json<AuthPayload>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
tracing::info!("Login attempt for email: {}", payload.email);
|
tracing::info!("Login attempt for email: {}", payload.email);
|
||||||
|
|
||||||
let user = sqlx::query_as::<_, User>("SELECT * FROM fn_get_user_by_email($1)")
|
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);
|
tracing::info!("Login successful for user: {}", user.email);
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
let auth_response = AuthResponse {
|
||||||
user: UserResponse {
|
user: UserResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -2962,8 +2987,16 @@ pub async fn login(
|
|||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
language: user.language,
|
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(
|
pub async fn get_course_analytics(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ pub async fn list_organization_email_templates(
|
|||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| {
|
.map_err(|e: sqlx::Error| {
|
||||||
eprintln!("Error fetching email templates: {:?}", e);
|
tracing::error!("Error fetching email templates: {:?}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to fetch email templates".to_string(),
|
"Failed to fetch email templates".to_string(),
|
||||||
@@ -112,7 +112,7 @@ pub async fn create_organization_email_template(
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| {
|
.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") {
|
if e.to_string().contains("duplicate key") {
|
||||||
(
|
(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
@@ -182,7 +182,7 @@ pub async fn update_organization_email_template(
|
|||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| {
|
.map_err(|e: sqlx::Error| {
|
||||||
eprintln!("Error updating email template: {:?}", e);
|
tracing::error!("Error updating email template: {:?}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to update email template".to_string(),
|
"Failed to update email template".to_string(),
|
||||||
@@ -239,7 +239,7 @@ pub async fn delete_organization_email_template(
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| {
|
.map_err(|e: sqlx::Error| {
|
||||||
eprintln!("Error deleting email template: {:?}", e);
|
tracing::error!("Error deleting email template: {:?}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to delete email template".to_string(),
|
"Failed to delete email template".to_string(),
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ async fn main() {
|
|||||||
|
|
||||||
let governor_conf = Arc::new(governor_conf.finish().unwrap());
|
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
|
// Rutas protegidas que requieren autenticación y contexto de organización
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
@@ -572,12 +579,14 @@ async fn main() {
|
|||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
.route("/auth/register", post(handlers::register))
|
.route("/auth/register", post(handlers::register))
|
||||||
.route("/auth/login", post(handlers::login))
|
.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/login/{org_id}", get(handlers::sso_login_init))
|
||||||
.route("/auth/sso/callback", get(handlers::sso_callback))
|
.route("/auth/sso/callback", get(handlers::sso_callback))
|
||||||
.route(
|
.route(
|
||||||
"/branding",
|
"/branding",
|
||||||
get(handlers_branding::get_organization_branding),
|
get(handlers_branding::get_organization_branding),
|
||||||
);
|
)
|
||||||
|
.route_layer(GovernorLayer { config: auth_governor_conf });
|
||||||
|
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/api-docs/openapi.json", get(|| async {
|
.route("/api-docs/openapi.json", get(|| async {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::{HeaderMap, header::AUTHORIZATION},
|
http::{HeaderMap, HeaderValue, header::AUTHORIZATION},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::{IntoResponse, Response},
|
||||||
response::sse::{Event, KeepAlive, Sse},
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
@@ -13,9 +13,9 @@ use aws_sdk_s3::{
|
|||||||
Client as S3Client,
|
Client as S3Client,
|
||||||
config::{Credentials, Region},
|
config::{Credentials, Region},
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{hash, verify};
|
||||||
use chrono::{DateTime, Utc};
|
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::middleware::Org;
|
||||||
use common::models::{
|
use common::models::{
|
||||||
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
||||||
@@ -800,11 +800,27 @@ pub struct InteractionPayload {
|
|||||||
pub metadata: Option<serde_json::Value>,
|
pub metadata: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
pub async fn register(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<AuthPayload>,
|
Json(payload): Json<AuthPayload>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let password_hash = hash(payload.password, DEFAULT_COST).map_err(|_| {
|
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,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Error al procesar la contraseña".into(),
|
"Error al procesar la contraseña".into(),
|
||||||
@@ -870,7 +886,7 @@ pub async fn register(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
let auth_response = AuthResponse {
|
||||||
user: UserResponse {
|
user: UserResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -883,14 +899,22 @@ pub async fn register(
|
|||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
language: user.language,
|
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(
|
pub async fn login(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<AuthPayload>,
|
Json(payload): Json<AuthPayload>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||||
.bind(&payload.email)
|
.bind(&payload.email)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -913,7 +937,7 @@ pub async fn login(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
let auth_response = AuthResponse {
|
||||||
user: UserResponse {
|
user: UserResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -926,8 +950,16 @@ pub async fn login(
|
|||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
language: user.language,
|
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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::{Json, extract::State, http::StatusCode};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use bcrypt::{DEFAULT_COST, hash};
|
use bcrypt::hash;
|
||||||
use lettre::message::Mailbox;
|
use lettre::message::Mailbox;
|
||||||
use lettre::transport::smtp::authentication::Credentials;
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
@@ -293,7 +293,7 @@ pub async fn reset_password(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Hashear nueva contraseña
|
// 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())
|
(StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar contraseña".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ async fn main() {
|
|||||||
|
|
||||||
let governor_conf = Arc::new(governor_conf.finish().unwrap());
|
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)
|
// Rate limiter solo para rutas protegidas (después del middleware de autenticación)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.route("/auth/me", get(handlers::get_me))
|
.route("/auth/me", get(handlers::get_me))
|
||||||
@@ -507,10 +514,15 @@ async fn main() {
|
|||||||
.merge(health::health_routes(pool.clone()).with_state(health_state))
|
.merge(health::health_routes(pool.clone()).with_state(health_state))
|
||||||
.route("/catalog", get(handlers::get_course_catalog))
|
.route("/catalog", get(handlers::get_course_catalog))
|
||||||
.route("/ingest", post(handlers::ingest_course))
|
.route("/ingest", post(handlers::ingest_course))
|
||||||
.route("/auth/register", post(handlers::register))
|
.merge(
|
||||||
.route("/auth/login", post(handlers::login))
|
Router::new()
|
||||||
.route("/auth/forgot-password", post(handlers_email::forgot_password))
|
.route("/auth/register", post(handlers::register))
|
||||||
.route("/auth/reset-password", post(handlers_email::reset_password))
|
.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("/xapi/statements", post(handlers_scorm::track_xapi_statement))
|
||||||
.route("/search", get(handlers_search::global_search))
|
.route("/search", get(handlers_search::global_search))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ use jsonwebtoken::{EncodingKey, Header, encode};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: Uuid,
|
pub sub: Uuid,
|
||||||
@@ -19,7 +33,7 @@ pub fn create_jwt(
|
|||||||
role: &str,
|
role: &str,
|
||||||
) -> Result<String, jsonwebtoken::errors::Error> {
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
let expiration = Utc::now()
|
let expiration = Utc::now()
|
||||||
.checked_add_signed(Duration::hours(24))
|
.checked_add_signed(Duration::hours(1))
|
||||||
.expect("valid timestamp")
|
.expect("valid timestamp")
|
||||||
.timestamp();
|
.timestamp();
|
||||||
|
|
||||||
|
|||||||
@@ -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 ")) {
|
let token = if let Some(token_str) = auth_header.and_then(|s: &str| s.strip_prefix("Bearer ")) {
|
||||||
token_str.to_string()
|
token_str.to_string()
|
||||||
} else {
|
} else {
|
||||||
// Verificar si hay preview_token en la cadena de consulta
|
// Intentar leer el token desde la httpOnly cookie
|
||||||
let query = req.uri().query().unwrap_or_default();
|
let cookie_token = req
|
||||||
let preview_token = query
|
.headers()
|
||||||
.split('&')
|
.get("cookie")
|
||||||
.find(|part| part.starts_with("preview_token="))
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|part| part.split('=').nth(1));
|
.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 {
|
if let Some(token) = cookie_token {
|
||||||
token.to_string()
|
token
|
||||||
} else {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ function CallbackHandler() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
if (token) {
|
if (token) {
|
||||||
// Temporarily store token so getMe can use it
|
// El token JWT viene como cookie httpOnly del backend.
|
||||||
localStorage.setItem('experience_token', token);
|
|
||||||
|
|
||||||
lmsApi.getMe()
|
lmsApi.getMe()
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
login(user, token);
|
login(user, token);
|
||||||
@@ -24,7 +22,6 @@ function CallbackHandler() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("SSO Error:", err);
|
console.error("SSO Error:", err);
|
||||||
localStorage.removeItem('experience_token');
|
|
||||||
router.push("/auth/login?error=sso_failed");
|
router.push("/auth/login?error=sso_failed");
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,16 +22,13 @@ function LtiLaunchContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Temporarily save token so api client can use it for getMe
|
// Fetch user details - la cookie httpOnly se envía automáticamente
|
||||||
localStorage.setItem("experience_token", token);
|
|
||||||
|
|
||||||
// 2. Fetch user details
|
|
||||||
const user = await lmsApi.getMe();
|
const user = await lmsApi.getMe();
|
||||||
|
|
||||||
// 3. Initialize session in AuthContext
|
// Initialize session in AuthContext
|
||||||
login(user, token);
|
login(user, token);
|
||||||
|
|
||||||
// 4. Redirect to final destination
|
// Redirect to final destination
|
||||||
router.replace(target);
|
router.replace(target);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("LTI Launch Error:", err);
|
console.error("LTI Launch Error:", err);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
return rawUrl;
|
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;
|
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
|
// Construct VTT URLs with auth if possible, or assume public/handled by backend
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedUser = localStorage.getItem('experience_user');
|
const savedUser = localStorage.getItem('experience_user');
|
||||||
const savedToken = localStorage.getItem('experience_token');
|
if (savedUser) {
|
||||||
if (savedUser && savedToken) {
|
|
||||||
setUser(JSON.parse(savedUser));
|
setUser(JSON.parse(savedUser));
|
||||||
setToken(savedToken);
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -31,15 +29,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const login = (newUser: User, newToken: string) => {
|
const login = (newUser: User, newToken: string) => {
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
// El token JWT se guarda en httpOnly cookie por el backend.
|
||||||
localStorage.setItem('experience_user', JSON.stringify(newUser));
|
localStorage.setItem('experience_user', JSON.stringify(newUser));
|
||||||
localStorage.setItem('experience_token', newToken);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
localStorage.removeItem('experience_user');
|
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 (
|
return (
|
||||||
|
|||||||
@@ -614,7 +614,7 @@ export const getToken = () => {
|
|||||||
return previewToken;
|
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';
|
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 baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
|
||||||
const headers = buildApiHeaders(options);
|
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) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ message: response.statusText }));
|
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||||
throw new Error(error.message || 'An error occurred');
|
throw new Error(error.message || 'An error occurred');
|
||||||
@@ -1091,7 +1091,8 @@ export const lmsApi = {
|
|||||||
headers: {
|
headers: {
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
}).then(res => res.json());
|
}).then(res => res.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1163,7 +1164,8 @@ export const lmsApi = {
|
|||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
// Don't set Content-Type for FormData - browser sets it with boundary
|
// Don't set Content-Type for FormData - browser sets it with boundary
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ message: 'Audio evaluation failed' }));
|
const err = await res.json().catch(() => ({ message: 'Audio evaluation failed' }));
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export default function GlobalAiControl() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
|
||||||
Token en localStorage: {localStorage.getItem('studio_token') ? '✓ Existe' : '✗ No existe'}
|
Por favor verifica que tu sesión esté activa.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ export default function AdminDashboard() {
|
|||||||
cmsApi.getOrganization(),
|
cmsApi.getOrganization(),
|
||||||
cmsApi.getAllUsers(),
|
cmsApi.getAllUsers(),
|
||||||
fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, {
|
fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, {
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -54,24 +54,14 @@ export default function AdminTokenTracking() {
|
|||||||
|
|
||||||
const loadTokenUsage = async () => {
|
const loadTokenUsage = async () => {
|
||||||
try {
|
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`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.error('[TokenUsage] Unauthorized - Token may be expired');
|
|
||||||
alert('Session expired. Please login again.');
|
alert('Session expired. Please login again.');
|
||||||
window.location.href = '/auth/login';
|
window.location.href = '/auth/login';
|
||||||
return;
|
return;
|
||||||
@@ -88,11 +78,7 @@ export default function AdminTokenTracking() {
|
|||||||
try {
|
try {
|
||||||
const limitResp = await fetch(
|
const limitResp = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.user_id}/token-limit/check`,
|
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.user_id}/token-limit/check`,
|
||||||
{
|
{ credentials: 'include' }
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (limitResp.ok) {
|
if (limitResp.ok) {
|
||||||
const limitData = await limitResp.json();
|
const limitData = await limitResp.json();
|
||||||
@@ -127,13 +113,13 @@ export default function AdminTokenTracking() {
|
|||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
monthly_token_limit: editValue,
|
monthly_token_limit: editValue,
|
||||||
token_limit_reset_day: 1,
|
token_limit_reset_day: 1,
|
||||||
}),
|
}),
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -141,11 +127,7 @@ export default function AdminTokenTracking() {
|
|||||||
// Reload limits
|
// Reload limits
|
||||||
const limitResp = await fetch(
|
const limitResp = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${userId}/token-limit/check`,
|
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${userId}/token-limit/check`,
|
||||||
{
|
{ credentials: 'include' }
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (limitResp.ok) {
|
if (limitResp.ok) {
|
||||||
const limitData = await limitResp.json();
|
const limitData = await limitResp.json();
|
||||||
|
|||||||
@@ -52,11 +52,7 @@ export default function UsersPage() {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.id}/token-limit/check`,
|
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.id}/token-limit/check`,
|
||||||
{
|
{ credentials: 'include' }
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ function CallbackHandler() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
if (token) {
|
if (token) {
|
||||||
// Temporarily store token so getMe can use it
|
// El token JWT viene como cookie httpOnly del backend.
|
||||||
localStorage.setItem('studio_token', token);
|
// Lo pasamos al AuthContext para el estado en memoria.
|
||||||
|
|
||||||
cmsApi.getMe()
|
cmsApi.getMe()
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
login(user, token);
|
login(user, token);
|
||||||
@@ -24,7 +23,6 @@ function CallbackHandler() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("SSO Error:", err);
|
console.error("SSO Error:", err);
|
||||||
localStorage.removeItem('studio_token');
|
|
||||||
router.push("/auth/login?error=sso_failed");
|
router.push("/auth/login?error=sso_failed");
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -85,13 +85,12 @@ export default function GradebookPage() {
|
|||||||
|
|
||||||
const exportCSV = async () => {
|
const exportCSV = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('studio_token');
|
|
||||||
const selectedOrgId = localStorage.getItem('studio_selected_org_id');
|
const selectedOrgId = localStorage.getItem('studio_selected_org_id');
|
||||||
const res = await fetch(lmsApi.exportGradesUrl(id), {
|
const res = await fetch(lmsApi.exportGradesUrl(id), {
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
|
||||||
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
||||||
}
|
},
|
||||||
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Export failed');
|
if (!res.ok) throw new Error('Export failed');
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ function DeepLinkingPickerContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem("studio_token", token);
|
// El token JWT llega como cookie httpOnly del backend SSO/LTI.
|
||||||
|
// No se persiste en localStorage.
|
||||||
}
|
}
|
||||||
loadCourses();
|
loadCourses();
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|||||||
@@ -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`, {
|
const uploadResponse = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/assets/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {},
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
|
||||||
},
|
|
||||||
body: audioFormData,
|
body: audioFormData,
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uploadResponse.ok) {
|
if (uploadResponse.ok) {
|
||||||
@@ -166,19 +165,17 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
|
|||||||
try {
|
try {
|
||||||
setGeneratingAI(true);
|
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`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/ai-generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
question_text: formData.question_text,
|
question_text: formData.question_text,
|
||||||
difficulty: formData.difficulty,
|
difficulty: formData.difficulty,
|
||||||
skill: randomSkill,
|
skill: randomSkill,
|
||||||
}),
|
}),
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -438,27 +438,20 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('studio_token');
|
|
||||||
if (!token) {
|
|
||||||
alert('No hay sesión activa. Por favor inicia sesión nuevamente.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setGeneratingAI(true);
|
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`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/test-templates/generate-with-rag`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: aiContext,
|
topic: aiContext,
|
||||||
num_questions: aiQuestionCount,
|
num_questions: aiQuestionCount,
|
||||||
question_type: aiQuestionType,
|
question_type: aiQuestionType,
|
||||||
}),
|
}),
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
setSelectedOrgId(newUser.organization_id || null);
|
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_user', JSON.stringify(newUser));
|
||||||
localStorage.setItem('studio_token', newToken);
|
|
||||||
if (newUser.organization_id) {
|
if (newUser.organization_id) {
|
||||||
localStorage.setItem('studio_selected_org_id', 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);
|
setToken(null);
|
||||||
setSelectedOrgId(null);
|
setSelectedOrgId(null);
|
||||||
localStorage.removeItem('studio_user');
|
localStorage.removeItem('studio_user');
|
||||||
localStorage.removeItem('studio_token');
|
|
||||||
localStorage.removeItem('studio_selected_org_id');
|
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) => {
|
const setOrganizationId = (id: string | null) => {
|
||||||
|
|||||||
@@ -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) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
try {
|
try {
|
||||||
@@ -1261,6 +1261,7 @@ export const cmsApi = {
|
|||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
@@ -1327,6 +1328,7 @@ export const cmsApi = {
|
|||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);
|
xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user