feat: implement httpOnly cookie for JWT authentication and update related API calls
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
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<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
let password_hash = hash(payload.password, DEFAULT_COST).map_err(|_| {
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
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<PgPool>,
|
||||
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")
|
||||
.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)]
|
||||
|
||||
@@ -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())
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user