feat: implement httpOnly cookie for JWT authentication and update related API calls

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-28 14:36:06 -04:00
parent 2eb887c486
commit 567fa66428
27 changed files with 207 additions and 123 deletions
+45 -13
View File
@@ -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)]
+2 -2
View File
@@ -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())
})?;
+16 -4
View File
@@ -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(