//! Cookie-based HTML auth pages (login, registration, email verification) for //! all users. There is no role column — an "admin" is simply the user whose //! email matches `settings.admin_email` (see [`guard::is_admin`]). On login, //! admins are redirected to the admin dashboard and everyone else to the //! storefront; both share the same `auth_token` cookie that the admin handlers //! already validate. This is the unified replacement for the former //! admin-only `/admin/login`. The JSON `/api/auth` flow in `auth.rs` is //! separate and untouched. use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use serde_json::json; use crate::{ controllers::auth as auth_controller, controllers::i18n::current_lang, mailers::auth::AuthMailer, models::users::{self, LoginParams, RegisterParams}, shared::guard, }; /// Where a freshly-authenticated `user` should land. fn home_for(ctx: &AppContext, user: &users::Model) -> &'static str { if guard::is_admin(ctx, user) { "/admin/dashboard" } else { "/" } } fn login_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result { format::view( v, "auth/login.html", json!({ "error": error, "logged_in_admin": false, "lang": current_lang(jar), }), ) } fn register_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result { format::view( v, "auth/register.html", json!({ "error": error, "logged_in_admin": false, "lang": current_lang(jar), }), ) } #[debug_handler] async fn login_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { if let Some(user) = guard::current_user(&ctx, &jar).await { return format::redirect(home_for(&ctx, &user)); } login_view(&v, &jar, None) } #[debug_handler] async fn login( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(params): Form, ) -> Result { let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { return login_view(&v, &jar, Some("invalid")); }; if !user.verify_password(¶ms.password) { return login_view(&v, &jar, Some("invalid")); } // Registration requires email verification before the account can sign in. if user.email_verified_at.is_none() { return login_view(&v, &jar, Some("unverified")); } let jwt_secret = ctx.config.get_jwt_config()?; let token = user .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::render() .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? .redirect(home_for(&ctx, &user)) } #[debug_handler] async fn register_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { if let Some(user) = guard::current_user(&ctx, &jar).await { return format::redirect(home_for(&ctx, &user)); } register_view(&v, &jar, None) } #[debug_handler] async fn register( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(params): Form, ) -> Result { let user = match users::Model::create_with_password(&ctx.db, ¶ms).await { Ok(user) => user, Err(ModelError::EntityAlreadyExists {}) => { return register_view(&v, &jar, Some("exists")); } Err(err) => { // Most commonly a validation failure (name too short / invalid email). tracing::info!( message = err.to_string(), user_email = ¶ms.email, "could not register user", ); return register_view(&v, &jar, Some("invalid")); } }; let user = user .into_active_model() .set_email_verification_sent(&ctx.db) .await?; // The account already exists; a failed email send shouldn't 500 the page — // log it and let the user fall back to resend-verification. if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await { tracing::error!( error = err.to_string(), user_email = &user.email, "failed to send verification email", ); } format::view( &v, "auth/verify_sent.html", json!({ "email": user.email, "logged_in_admin": false, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn verify( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Path(token): Path, ) -> Result { let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else { return verified_view(&v, &jar, false); }; if user.email_verified_at.is_none() { user.into_active_model().verified(&ctx.db).await?; } verified_view(&v, &jar, true) } fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result { format::view( v, "auth/verified.html", json!({ "ok": ok, "logged_in_admin": false, "lang": current_lang(jar), }), ) } /// Resend the email-verification link. Throttled per account (see /// [`users::Model::verification_resend_wait_secs`]) so it can't be used to spam /// an inbox, and always returns the same neutral message so it can't be used to /// probe which addresses are registered. #[derive(Debug, serde::Deserialize)] struct ResendVerificationForm { email: String, } fn resend_verification_view(v: &TeraView, jar: &CookieJar, done: bool) -> Result { format::view( v, "auth/resend_verification.html", json!({ "done": done, "logged_in_admin": false, "lang": current_lang(jar), }), ) } #[debug_handler] async fn resend_verification_page( jar: CookieJar, ViewEngine(v): ViewEngine, ) -> Result { resend_verification_view(&v, &jar, false) } #[debug_handler] async fn resend_verification( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { // Resend only for a real, still-unverified account that is past its cooldown. // Anything else (unknown email, already verified, too soon) silently does // nothing — the response is identical either way. if let Ok(user) = users::Model::find_by_email(&ctx.db, form.email.trim()).await { if user.email_verified_at.is_none() && user.verification_resend_wait_secs() == 0 { match user.into_active_model().set_email_verification_sent(&ctx.db).await { Ok(user) => { if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await { tracing::error!(error = %err, "failed to resend verification email"); } } Err(err) => tracing::error!(error = %err, "failed to refresh verification token"), } } else { tracing::info!("verification resend skipped (already verified or within cooldown)"); } } resend_verification_view(&v, &jar, true) } /// Set-password form for accounts created during checkout (and any account that /// has a valid reset token). Reuses the password-reset token machinery. #[derive(Debug, serde::Deserialize)] struct SetPasswordForm { token: String, password: String, password_confirm: String, } fn set_password_view( v: &TeraView, jar: &CookieJar, token: &str, valid: bool, error: Option<&str>, ) -> Result { format::view( v, "auth/set_password.html", json!({ "token": token, "valid": valid, "error": error, "logged_in_admin": false, "lang": current_lang(jar), }), ) } #[debug_handler] async fn set_password_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Path(token): Path, ) -> Result { let valid = users::Model::find_by_reset_token(&ctx.db, &token).await.is_ok(); set_password_view(&v, &jar, &token, valid, None) } #[debug_handler] async fn set_password( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, Form(form): Form, ) -> Result { let Ok(user) = users::Model::find_by_reset_token(&ctx.db, &form.token).await else { return set_password_view(&v, &jar, &form.token, false, None); }; if form.password != form.password_confirm { return set_password_view(&v, &jar, &form.token, true, Some("mismatch")); } if form.password.len() < 8 { return set_password_view(&v, &jar, &form.token, true, Some("weak")); } // Setting the password through an emailed link also proves email ownership, // so the account is marked verified here. let user = user.into_active_model().reset_password(&ctx.db, &form.password).await?; if user.email_verified_at.is_none() { user.into_active_model().verified(&ctx.db).await?; } format::redirect("/login") } #[debug_handler] async fn logout() -> Result { format::render() .cookies(&[auth_controller::clear_auth_cookie()])? .redirect("/login") } /// Backwards-compatible entry point: `/admin` sends admins to their dashboard /// and everyone else to the unified login. #[debug_handler] async fn admin_entry(jar: CookieJar, State(ctx): State) -> Result { if let Some(user) = guard::current_user(&ctx, &jar).await { if guard::is_admin(&ctx, &user) { return format::redirect("/admin/dashboard"); } } format::redirect("/login") } pub fn routes() -> Routes { Routes::new() .add("/login", get(login_page)) .add("/login", post(login)) .add("/register", get(register_page)) .add("/register", post(register)) .add("/verify/{token}", get(verify)) .add("/resend-verification", get(resend_verification_page)) .add("/resend-verification", post(resend_verification)) .add("/set-password/{token}", get(set_password_page)) .add("/set-password", post(set_password)) .add("/logout", post(logout)) .add("/admin", get(admin_entry)) }