481 lines
15 KiB
Rust
481 lines
15 KiB
Rust
//! 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<Response> {
|
|
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<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(params): Form<LoginParams>,
|
|
) -> Result<Response> {
|
|
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()?;
|
|
|
|
// If the user opted into 2FA, the password is only the first factor: don't
|
|
// issue the real auth cookie yet. Hand out a short-lived, separate "pending"
|
|
// cookie and send them to the code-entry page. Everyone without 2FA logs in
|
|
// in a single step exactly as before.
|
|
if user.totp_enabled() {
|
|
let pending = user
|
|
.generate_jwt(&jwt_secret.secret, auth_controller::TOTP_PENDING_TTL_SECS)
|
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
|
return format::render()
|
|
.cookies(&[auth_controller::totp_pending_cookie(
|
|
&pending,
|
|
auth_controller::TOTP_PENDING_TTL_SECS,
|
|
)])?
|
|
.redirect("/login/totp");
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
/// Resolve the user behind a valid, unexpired `totp_pending` cookie. Returns
|
|
/// `None` (never errors) when the cookie is missing, malformed, or expired —
|
|
/// the caller bounces such requests back to `/login`.
|
|
async fn user_from_pending(ctx: &AppContext, jar: &CookieJar) -> Option<users::Model> {
|
|
let cookie = jar.get(auth_controller::TOTP_PENDING_COOKIE)?;
|
|
let jwt_config = ctx.config.get_jwt_config().ok()?;
|
|
let claims = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
|
|
.validate(cookie.value())
|
|
.ok()?;
|
|
let user = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await.ok()?;
|
|
// Defend against a stale pending cookie outliving a 2FA disable.
|
|
user.totp_enabled().then_some(user)
|
|
}
|
|
|
|
fn login_totp_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
|
|
format::view(
|
|
v,
|
|
"auth/login_totp.html",
|
|
json!({
|
|
"error": error,
|
|
"logged_in_admin": false,
|
|
"lang": current_lang(jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn login_totp_page(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
if user_from_pending(&ctx, &jar).await.is_none() {
|
|
return format::redirect("/login");
|
|
}
|
|
login_totp_view(&v, &jar, None)
|
|
}
|
|
|
|
/// Second login factor. Accepts either a 6-digit authenticator code or one of
|
|
/// the one-time backup codes (auto-detected by length). On success the pending
|
|
/// cookie is cleared and the real `auth_token` is issued.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct TotpLoginForm {
|
|
code: String,
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn login_totp(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<TotpLoginForm>,
|
|
) -> Result<Response> {
|
|
let Some(user) = user_from_pending(&ctx, &jar).await else {
|
|
return format::redirect("/login");
|
|
};
|
|
|
|
let code = form.code.trim();
|
|
let via_totp = user.verify_totp_code(code);
|
|
let via_backup = !via_totp && user.matches_backup_code(code);
|
|
|
|
if !via_totp && !via_backup {
|
|
return login_totp_view(&v, &jar, Some("invalid"));
|
|
}
|
|
|
|
// A used backup code must be burned so it can't be replayed.
|
|
if via_backup {
|
|
user.clone().into_active_model().consume_backup_code(&ctx.db, code).await?;
|
|
}
|
|
|
|
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),
|
|
auth_controller::clear_totp_pending_cookie(),
|
|
])?
|
|
.redirect(home_for(&ctx, &user))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn register_page(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
if let Some(user) = guard::current_user(&ctx, &jar).await {
|
|
return format::redirect(home_for(&ctx, &user));
|
|
}
|
|
register_view(&v, &jar, None)
|
|
}
|
|
|
|
/// Registration form. The name is no longer collected from the user — it is
|
|
/// derived from the email — and the password is entered twice to guard against
|
|
/// typos.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct RegisterForm {
|
|
email: String,
|
|
password: String,
|
|
password_confirm: String,
|
|
#[serde(default)]
|
|
account_type: Option<String>,
|
|
}
|
|
|
|
/// Derive a display name from an email address (its local part), falling back to
|
|
/// the full address when the local part is too short for the name validator.
|
|
fn name_from_email(email: &str) -> String {
|
|
let local = email.split('@').next().unwrap_or("").trim();
|
|
if local.chars().count() >= 2 {
|
|
local.to_string()
|
|
} else {
|
|
email.trim().to_string()
|
|
}
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn register(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<RegisterForm>,
|
|
) -> Result<Response> {
|
|
if form.password != form.password_confirm {
|
|
return register_view(&v, &jar, Some("mismatch"));
|
|
}
|
|
if form.password.len() < 8 {
|
|
return register_view(&v, &jar, Some("weak"));
|
|
}
|
|
|
|
let params = RegisterParams {
|
|
name: name_from_email(&form.email),
|
|
email: form.email,
|
|
password: form.password,
|
|
account_type: form.account_type,
|
|
};
|
|
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Path(token): Path<String>,
|
|
) -> Result<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<TeraView>,
|
|
) -> Result<Response> {
|
|
resend_verification_view(&v, &jar, false)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn resend_verification(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<ResendVerificationForm>,
|
|
) -> Result<Response> {
|
|
// 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<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Path(token): Path<String>,
|
|
) -> Result<Response> {
|
|
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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<SetPasswordForm>,
|
|
) -> Result<Response> {
|
|
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<Response> {
|
|
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<AppContext>) -> Result<Response> {
|
|
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("/login/totp", get(login_totp_page))
|
|
.add("/login/totp", post(login_totp))
|
|
.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))
|
|
}
|