pagetop/extensions/pagetop-user/src/handlers/auth.rs

349 lines
12 KiB
Rust

//! Handlers HTTP para autenticación y gestión de cuenta.
use serde::Deserialize;
use pagetop::prelude::*;
use crate::auth;
use crate::component::{LoginForm, PasswordResetConfirmForm, PasswordResetForm, RegisterForm};
use crate::config::SETTINGS;
use crate::error::AuthError;
use crate::handlers::admin::map_auth_error;
use crate::password;
use crate::session;
use crate::token::{TokenKind, consume_token, create_token};
use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_PATH};
// **< Tipos de formularios >***********************************************************************
#[derive(Deserialize)]
pub struct LoginFormData {
// Acepta también `ident`, el nombre de campo usado en el modo de login estricto
// (ver `[user.login]`).
#[serde(alias = "ident")]
username: String,
// Acepta también `token`, el nombre de campo usado en el modo de login estricto.
#[serde(alias = "token")]
password: String,
#[serde(default)]
remember: bool,
}
#[derive(Deserialize)]
pub struct RegisterFormData {
username: String,
email: String,
password: String,
confirm_password: String,
}
#[derive(Deserialize)]
pub struct PasswordResetFormData {
email: String,
}
#[derive(Deserialize)]
pub struct PasswordResetConfirmFormData {
password: String,
confirm_password: String,
}
// **< login_get >**********************************************************************************
/// GET /user/login - Formulario de inicio de sesión.
pub async fn login_get(
request: HttpRequest,
web::Query(waypoint): web::Query<Waypoint>,
) -> Response {
// Un usuario ya autenticado no debe volver a ver el formulario de login: se le redirige al
// waypoint transportado si lo hay (p. ej. llegó aquí desde un enlace obsoleto o una pestaña
// duplicada), o a su perfil en caso contrario.
if request
.extension::<CurrentUser>()
.is_some_and(CurrentUser::is_authenticated)
{
let cx = Context::new(request.clone());
return Redirect::see_other(waypoint.or(cx.route(PROFILE_PATH)));
}
Page::new(request)
.with_title(Lc::t("title-login", &LOCALES_USER))
.with_child(LoginForm::new().with_waypoint(waypoint))
.render()
.await
.into_response()
}
// **< login_post >*********************************************************************************
/// POST /user/login - Procesa las credenciales y abre la sesión.
pub async fn login_post(
request: HttpRequest,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<LoginFormData>,
) -> Response {
let cx = Context::new(request.clone());
let next = waypoint.or(cx.route("/"));
let result = auth::login(&form.username, &form.password, form.remember).await;
match result {
Ok(sid) => {
let cookie = session::build_cookie(&sid, form.remember);
redirect_with_cookie(next, &cookie)
}
Err(err) => {
let error_key = match &err {
AuthError::AccountBlocked => "error-account-blocked",
AuthError::AccountPending => "error-account-pending",
AuthError::AccountLocked => "error-account-locked",
_ => "error-invalid-credentials",
};
Page::new(request)
.with_title(Lc::t("title-login", &LOCALES_USER))
.with_child(
LoginForm::new()
.with_error(Lc::t(error_key, &LOCALES_USER))
.with_waypoint(waypoint),
)
.render()
.await
.into_response()
}
}
}
// **< logout_post >********************************************************************************
/// POST /user/logout - Cierra la sesión y redirige al login.
pub async fn logout_post(request: HttpRequest) -> Response {
let cx = Context::new(request.clone());
if let Some(sid) = session::extract_sid(Some(request.headers())) {
auth::logout(&sid).await.ok();
}
let expiry = session::expiry_cookie();
redirect_with_cookie(cx.route(LOGIN_PATH), &expiry)
}
// **< register_get >*******************************************************************************
/// GET /user/register - Formulario de registro de cuenta.
pub async fn register_get(request: HttpRequest) -> Result<Markup, ErrorPage> {
if !SETTINGS.allow_registration {
return Err(ErrorPage::NotFound(Some(request)));
}
Page::new(request)
.with_title(Lc::t("title-register", &LOCALES_USER))
.with_child(RegisterForm::new())
.render()
.await
}
// **< register_post >******************************************************************************
/// POST /user/register - Registra un nuevo usuario.
pub async fn register_post(
request: HttpRequest,
web::Form(form): web::Form<RegisterFormData>,
) -> Response {
if !SETTINGS.allow_registration {
return ErrorPage::NotFound(Some(request)).into_response();
}
let cx = Context::new(request.clone());
let result = auth::register(
&form.username,
&form.email,
&form.password,
&form.confirm_password,
)
.await;
match result {
Ok(_user_id) => Redirect::see_other(cx.route(LOGIN_PATH)),
Err(err) => {
let error_lc = match &err {
AuthError::PasswordTooShort(n) => {
Lc::t("error-password-too-short", &LOCALES_USER).with_arg("n", n.to_string())
}
AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER),
AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER),
AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER),
_ => Lc::t("error-internal", &LOCALES_USER),
};
Page::new(request)
.with_title(Lc::t("title-register", &LOCALES_USER))
.with_child(RegisterForm::new().with_error(error_lc))
.render()
.await
.into_response()
}
}
}
// **< password_reset_get >*************************************************************************
/// GET /user/password/reset - Formulario de solicitud de restablecimiento.
pub async fn password_reset_get(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_title(Lc::t("title-password-reset", &LOCALES_USER))
.with_child(PasswordResetForm::new())
.render()
.await
}
// **< password_reset_post >************************************************************************
/// POST /user/password/reset - Inicia el flujo de restablecimiento de contraseña.
pub async fn password_reset_post(
request: HttpRequest,
web::Form(form): web::Form<PasswordResetFormData>,
) -> Result<Markup, ErrorPage> {
// Respondemos igual exista o no el email para no revelar qué emails están registrados.
{
use crate::entity::user;
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
if let Ok(Some(user_model)) = user::Entity::find()
.filter(user::Column::Email.eq(&form.email))
.one(dbconn())
.await
{
create_token(user_model.id, TokenKind::PasswordReset)
.await
.ok();
// TODO: enviar email con el token al usuario.
}
}
Page::new(request)
.with_title(Lc::t("title-password-reset", &LOCALES_USER))
.with_child(Html::with(|cx| {
html! { p { (Lc::t("msg-password-reset-sent", &LOCALES_USER).using(cx)) } }
}))
.render()
.await
}
// **< password_reset_confirm_get >*****************************************************************
/// GET /user/password/reset/{uid}/{token} - Formulario para introducir la nueva contraseña.
pub async fn password_reset_confirm_get(
request: HttpRequest,
web::Path((uid, token)): web::Path<(i32, String)>,
) -> Result<Markup, ErrorPage> {
let valid = {
use crate::entity::user_token;
use crate::token::hash_token;
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
let hash = hash_token(&token);
let now = Utc::now().naive_utc();
user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(uid))
.filter(user_token::Column::ConsumedAt.is_null())
.one(dbconn())
.await
.ok()
.flatten()
.is_some_and(|r| r.expires_at > now)
};
if !valid {
return Err(ErrorPage::NotFound(Some(request)));
}
Page::new(request)
.with_title(Lc::t("title-new-password", &LOCALES_USER))
.with_child(PasswordResetConfirmForm::new())
.render()
.await
}
// **< password_reset_confirm_post >****************************************************************
/// POST /user/password/reset/{uid}/{token} - Aplica la nueva contraseña.
pub async fn password_reset_confirm_post(
request: HttpRequest,
web::Path((_uid, token)): web::Path<(i32, String)>,
web::Form(form): web::Form<PasswordResetConfirmFormData>,
) -> Response {
let cx = Context::new(request.clone());
if let Err(err) = password::passwords_match(&form.password, &form.confirm_password) {
return Page::new(request)
.with_title(Lc::t("title-new-password", &LOCALES_USER))
.with_child(PasswordResetConfirmForm::new().with_error(map_auth_error(&err)))
.render()
.await
.into_response();
}
let result = async {
use crate::entity::user;
use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn};
let user_id = consume_token(&token, TokenKind::PasswordReset).await?;
password::validate_strength(&form.password)?;
let hash = password::hash_password(&form.password)?;
let now = Utc::now().naive_utc();
user::ActiveModel {
id: Set(user_id),
password_hash: Set(hash),
updated_at: Set(now),
..Default::default()
}
.update(dbconn())
.await?;
session::destroy_user_sessions(user_id)
.await
.map_err(AuthError::Database)?;
Ok::<(), AuthError>(())
}
.await;
match result {
Ok(()) => Redirect::see_other(cx.route(LOGIN_PATH)),
Err(_) => Page::new(request)
.with_title(Lc::t("title-new-password", &LOCALES_USER))
.with_child(
PasswordResetConfirmForm::new()
.with_error(Lc::t("error-token-invalid", &LOCALES_USER)),
)
.render()
.await
.into_response(),
}
}
// **< verify_email_get >***************************************************************************
/// GET /user/verify/{uid}/{token} - Confirma la dirección de email.
pub async fn verify_email_get(
request: HttpRequest,
web::Path((_uid, token)): web::Path<(i32, String)>,
) -> Response {
let cx = Context::new(request.clone());
let result = async {
use crate::account::UserStatus;
use crate::entity::user;
use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn};
let user_id = consume_token(&token, TokenKind::EmailVerification).await?;
let now = Utc::now().naive_utc();
user::ActiveModel {
id: Set(user_id),
email_verified_at: Set(Some(now)),
status: Set(UserStatus::Active.as_i16()),
updated_at: Set(now),
..Default::default()
}
.update(dbconn())
.await?;
Ok::<(), AuthError>(())
}
.await;
match result {
Ok(()) => Redirect::see_other(cx.route(LOGIN_PATH)),
Err(_) => ErrorPage::NotFound(Some(request)).into_response(),
}
}
// **< helpers privados >***************************************************************************
fn redirect_with_cookie(to: impl Into<RoutePath>, cookie: &str) -> Response {
(
web::http::StatusCode::SEE_OTHER,
[
(web::http::header::LOCATION, to.into().to_string()),
(web::http::header::SET_COOKIE, cookie.to_owned()),
],
)
.into_response()
}