use serde::Deserialize;
use umbral::web::{Form, HeaderMap, IntoResponse, Query, Redirect, Response, Router, post};
use umbral_sessions::Messages;
fn safe_path(raw: &str) -> Option<String> {
if raw.starts_with('/')
&& !raw.starts_with("//")
&& !raw.contains('\\')
&& !raw.chars().any(|c| c.is_control())
{
Some(raw.to_string())
} else {
None
}
}
fn success_target(redirect: Option<&str>) -> String {
redirect
.and_then(safe_path)
.unwrap_or_else(|| "/".to_string())
}
fn error_target(headers: &HeaderMap, redirect: Option<&str>) -> String {
if let Some(referer_path) = same_site_referer_path(headers) {
return referer_path;
}
if let Some(safe) = redirect.and_then(safe_path) {
return safe;
}
"/".to_string()
}
fn same_site_referer_path(headers: &HeaderMap) -> Option<String> {
let referer = headers
.get(umbral::web::header::REFERER)
.and_then(|v| v.to_str().ok())?;
let host = headers.get("host").and_then(|v| v.to_str().ok())?.trim();
let after_scheme = referer
.strip_prefix("https://")
.or_else(|| referer.strip_prefix("http://"))?;
if !after_scheme.starts_with(host) {
return None;
}
let after_host = &after_scheme[host.len()..];
if !after_host.is_empty() && !after_host.starts_with(['/', '?', '#']) {
return None;
}
let path = if after_host.is_empty() {
"/"
} else {
after_host
};
safe_path(path)
}
#[derive(Deserialize)]
struct RedirectQ {
#[serde(default)]
redirect: Option<String>,
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
#[derive(Deserialize)]
struct SignupForm {
username: String,
email: String,
password: String,
}
#[derive(Deserialize)]
struct VerifyEmailForm {
email: String,
code: String,
}
#[derive(Deserialize)]
struct ResendForm {
email: String,
}
#[derive(Deserialize)]
struct ForgotForm {
email: String,
}
#[derive(Deserialize)]
struct ResetForm {
token: String,
new_password: String,
}
async fn do_login(
Query(q): Query<RedirectQ>,
headers: HeaderMap,
msgs: Messages,
Form(f): Form<LoginForm>,
) -> Response {
let ip = crate::auth_routes::client_ip(&headers);
if !crate::login_throttle_check(&ip, &f.username) {
msgs.error("Too many attempts; please try again later.")
.await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
let user: crate::AuthUser = match crate::authenticate(&f.username, &f.password).await {
Ok(u) => u,
Err(_) => {
msgs.error("Invalid username or password.").await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
};
if crate::verified_email_required() && user.email_verified_at.is_none() {
msgs.error("Please verify your email address before signing in.")
.await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
crate::login_throttle_clear(&ip, &f.username);
let mut resp = Redirect::to(&success_target(q.redirect.as_deref())).into_response();
if let Err(e) = crate::login_with_request(&headers, resp.headers_mut(), &user).await {
tracing::error!("umbral-auth form: login_with_request failed: {e}");
msgs.error("Session error; please try again.").await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
msgs.success("You have been signed in.").await;
resp
}
async fn do_logout(Query(q): Query<RedirectQ>, headers: HeaderMap, msgs: Messages) -> Response {
let mut resp = Redirect::to(&success_target(q.redirect.as_deref())).into_response();
if let Err(e) = crate::logout(&headers, resp.headers_mut()).await {
tracing::error!("umbral-auth form: logout session error: {e}");
}
msgs.success("You have been signed out.").await;
resp
}
async fn do_signup(
Query(q): Query<RedirectQ>,
headers: HeaderMap,
msgs: Messages,
Form(f): Form<SignupForm>,
) -> Response {
let ip = crate::auth_routes::client_ip(&headers);
if !crate::register_throttle_check(&ip) {
msgs.error("Too many registration attempts; please try again later.")
.await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
if f.username.is_empty() || f.email.is_empty() || f.password.is_empty() {
msgs.error("Username, email and password are required.")
.await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
if let Err(reasons) = crate::validate_password(
&f.password,
&crate::PasswordContext::new(Some(&f.username), Some(&f.email)),
) {
msgs.error(reasons.join(" ")).await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
match crate::create_user(&f.username, &f.email, &f.password).await {
Ok(user) => {
if crate::verified_email_required() {
if let Err(e) = crate::start_email_verification(&user).await {
tracing::warn!(
user_id = user.id,
"umbral-auth form: auto-send verification on signup failed: {e}"
);
}
}
msgs.success("Account created! You can now sign in.").await;
Redirect::to(&success_target(q.redirect.as_deref())).into_response()
}
Err(e) => {
let msg = format!("{e}");
if msg.to_lowercase().contains("unique") {
msgs.error("That username or email is already registered.")
.await;
} else {
msgs.error("Could not create account; please try again.")
.await;
}
Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response()
}
}
}
async fn do_verify_email(
Query(q): Query<RedirectQ>,
headers: HeaderMap,
msgs: Messages,
Form(f): Form<VerifyEmailForm>,
) -> Response {
let ip = crate::auth_routes::client_ip(&headers);
if !crate::email_action_throttle_check(&ip, &f.email) {
msgs.error("Too many requests; try again later.").await;
return Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response();
}
match crate::verify_email(&f.email, &f.code).await {
Ok(()) => {
msgs.success("Email verified! You can now sign in.").await;
Redirect::to(&success_target(q.redirect.as_deref())).into_response()
}
Err(_) => {
msgs.error("Verification failed. The code may be expired or incorrect.")
.await;
Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response()
}
}
}
async fn do_resend(headers: HeaderMap, msgs: Messages, Form(f): Form<ResendForm>) -> Response {
let ip = crate::auth_routes::client_ip(&headers);
if !crate::email_action_throttle_check(&ip, &f.email) {
msgs.error("Too many requests; try again later.").await;
return Redirect::to("/").into_response();
}
if let Ok(Some(u)) = crate::AuthUser::objects()
.filter(
crate::auth_user::EMAIL.eq(f.email.clone())
& crate::auth_user::EMAIL_VERIFIED_AT.is_null(),
)
.first()
.await
{
let _ = crate::start_email_verification(&u).await;
}
msgs.info("If that address is registered and unverified, a new code has been sent.")
.await;
Redirect::to("/").into_response()
}
async fn do_forgot(headers: HeaderMap, msgs: Messages, Form(f): Form<ForgotForm>) -> Response {
let ip = crate::auth_routes::client_ip(&headers);
if !crate::email_action_throttle_check(&ip, &f.email) {
msgs.error("Too many requests; try again later.").await;
return Redirect::to("/").into_response();
}
let base = crate::auth_routes::reset_url_base(&headers);
let _ = crate::start_password_reset(&f.email, &base).await;
msgs.info("If that address is registered, a reset link has been sent.")
.await;
Redirect::to("/").into_response()
}
async fn do_reset(
Query(q): Query<RedirectQ>,
headers: HeaderMap,
msgs: Messages,
Form(f): Form<ResetForm>,
) -> Response {
match crate::reset_password(&f.token, &f.new_password).await {
Ok(()) => {
msgs.success("Password updated. You can now sign in with your new password.")
.await;
Redirect::to(&success_target(q.redirect.as_deref())).into_response()
}
Err(_) => {
msgs.error("Could not reset password. The link may have expired.")
.await;
Redirect::to(&error_target(&headers, q.redirect.as_deref())).into_response()
}
}
}
pub(crate) fn build_router(prefix: &str) -> Router {
Router::new()
.route(&format!("{prefix}/login"), post(do_login))
.route(&format!("{prefix}/logout"), post(do_logout))
.route(&format!("{prefix}/signup"), post(do_signup))
.route(&format!("{prefix}/verify-email"), post(do_verify_email))
.route(&format!("{prefix}/resend"), post(do_resend))
.route(&format!("{prefix}/password-forgot"), post(do_forgot))
.route(&format!("{prefix}/password-reset"), post(do_reset))
}
pub(crate) fn declared_routes(prefix: &str) -> Vec<umbral::routes::RouteSpec> {
vec![
("POST", format!("{prefix}/login")).into(),
("POST", format!("{prefix}/logout")).into(),
("POST", format!("{prefix}/signup")).into(),
("POST", format!("{prefix}/verify-email")).into(),
("POST", format!("{prefix}/resend")).into(),
("POST", format!("{prefix}/password-forgot")).into(),
("POST", format!("{prefix}/password-reset")).into(),
]
}