use rustlavel::prelude::*;
use rustlavel::validation::Errors;
use crate::models::user::User;
use crate::models::user_token::ACTIVATION;
use crate::support::{page, tokens};
pub struct RegisterController;
impl RegisterController {
pub async fn show(req: Request) -> Result<Response> {
if !registration_open(&req).await {
return Ok(Response::not_found());
}
let context = page::shell(&req, "").await;
req.view("auth/register", &page::old(context, &[("name", None), ("email", None)]))
}
pub async fn store(mut req: Request) -> Result<Response> {
if !registration_open(&req).await {
return Ok(Response::not_found());
}
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let name = req.input("name").unwrap_or_default();
let email = req.input("email").unwrap_or_default().trim().to_lowercase();
let errors = page::check(
&[("name", &name), ("email", &email)],
&[("name", "required|max:120"), ("email", "required|email|max:190")],
);
if !errors.is_empty() {
let context = page::errors(page::shell(&req, "").await, &errors);
return req.view(
"auth/register",
&page::old(context, &[("name", Some(name)), ("email", Some(email))]),
);
}
if User::first(&db, User::by_email(&email)).await?.is_none() {
let mut user = User {
name: name.trim().to_string(),
email: email.clone(),
is_active: true,
..Default::default()
};
user.insert(&db).await?;
let token = send_activation(&req, &db, &user, "Confirm your email").await?;
if !verify_email(&req).await {
return Ok(Response::see_other(format!("/activate/{token}")));
}
}
Self::sent(req, &email).await
}
async fn sent(req: Request, email: &str) -> Result<Response> {
let context = page::shell(&req, "").await
.with("email", Json::from(email))
.with("expires_in", Json::from("one hour"));
req.view("auth/sent", &context)
}
}
pub async fn send_activation(
req: &Request,
db: &Database,
user: &User,
subject: &str,
) -> Result<String> {
let token = tokens::issue(db, user.id, ACTIVATION, None).await?;
let url = format!("{}/activate/{token}", req.config().string("app.url", "http://localhost:8000"));
if req.state::<rustlavel::mail::Mailer>().is_none() {
warn!("no mailer is configured; the activation link for {} is {url}", user.email);
return Ok(token);
}
crate::support::mail::send(
req,
rustlavel::mail::Message::new()
.to(user.email.as_str())
.subject(subject)
.text(format!(
"Hello {},\n\nUse this link to set your password and finish setting up your \
account:\n\n{url}\n\nThe link works once and expires in an hour.\n\nIf you \
were not expecting this, you can ignore it.\n",
user.first_name()
)),
)
.await?;
Ok(token)
}
pub struct ActivationController;
impl ActivationController {
pub async fn show(req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let token = req.param("token").unwrap_or_default().to_string();
let now = tokens::now();
let Some(record) = crate::models::user_token::UserToken::first(
&db,
crate::models::user_token::UserToken::usable(ACTIVATION, &token, &now),
)
.await?
else {
return expired(req, "That activation link has expired or has already been used.", "/login", "Back to sign in").await;
};
let Some(user) = User::find(&db, record.user_id).await? else {
return expired(req, "That account no longer exists.", "/login", "Back to sign in").await;
};
let context = page::shell(&req, "").await
.with("name", Json::from(user.first_name()))
.with("token", Json::from(token))
.with("action", Json::from("/activate"))
.with("submit_label", Json::from("Set password and sign in"))
.with("min_length", Json::from(Policy::current(&req).await.minimum));
req.view("auth/activate", &context)
}
pub async fn store(mut req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let token = req.input("token").unwrap_or_default();
let password = req.input("password").unwrap_or_default();
let confirmation = req.input("password_confirmation").unwrap_or_default();
let policy = Policy::current(&req).await;
let mut errors = policy.errors(&password, &confirmation);
let keep = crate::support::passwords::keep(&req).await;
if errors.is_empty() && keep > 0 {
if let Some(record) = crate::models::user_token::UserToken::first(
&db,
crate::models::user_token::UserToken::usable(ACTIVATION, &token, &tokens::now()),
)
.await?
{
if crate::support::passwords::was_used_before(&db, record.user_id, &password, keep).await? {
errors.add("password", crate::support::passwords::reuse_message(keep));
}
}
}
if !errors.is_empty() {
let context = page::errors(page::shell(&req, "").await, &errors)
.with("token", Json::from(token))
.with("action", Json::from("/activate"))
.with("submit_label", Json::from("Set password and sign in"))
.with("min_length", Json::from(policy.minimum))
.with("name", Json::from("there"));
return req.view("auth/activate", &context);
}
let Some(record) = tokens::claim(&db, ACTIVATION, &token).await? else {
return expired(req, "That activation link has expired or has already been used.", "/login", "Back to sign in").await;
};
let Some(mut user) = User::find(&db, record.user_id).await? else {
return expired(req, "That account no longer exists.", "/login", "Back to sign in").await;
};
let now = tokens::now();
let hash = rustlavel::auth::hash_password(&password)?;
crate::support::passwords::remember_previous(&db, user.id, user.password_hash.as_deref(), keep).await?;
user.password_hash = Some(hash);
user.email_verified_at = Some(now.clone());
user.update(&db).await?;
use crate::controllers::auth::login_controller::LoginController;
LoginController::complete(&req, &db, &mut user, &now).await?;
if let Some(enrol) = LoginController::enrolment_owed(&req, &db, user.id).await? {
return Ok(Response::see_other(enrol));
}
page::flash(&req, "success", "Welcome. Your account is ready.");
Ok(Response::see_other("/dashboard"))
}
}
pub async fn registration_open(req: &Request) -> bool {
match req.state::<crate::support::settings::Settings>() {
Some(settings) => settings.bool("auth.registration.open").await,
None => req.config().bool("auth.registration.open", true),
}
}
pub async fn verify_email(req: &Request) -> bool {
match req.state::<crate::support::settings::Settings>() {
Some(settings) => settings.bool("auth.verify_email").await,
None => req.config().bool("auth.verify_email", true),
}
}
pub fn min_length(req: &Request) -> i64 {
req.config().int("auth.password.min_length", 12).clamp(8, 128)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Policy {
pub minimum: i64,
pub uppercase: bool,
pub lowercase: bool,
pub number: bool,
pub symbol: bool,
pub breached: bool,
}
impl Policy {
pub fn length_only(minimum: i64) -> Policy {
Policy {
minimum: minimum.clamp(8, 128),
uppercase: false,
lowercase: false,
number: false,
symbol: false,
breached: false,
}
}
pub async fn current(req: &Request) -> Policy {
let Some(settings) = req.state::<crate::support::settings::Settings>() else {
return Policy::length_only(min_length(req));
};
Policy {
minimum: settings.int("auth.password.min_length", 12).await.clamp(8, 128),
uppercase: settings.bool("auth.password.uppercase").await,
lowercase: settings.bool("auth.password.lowercase").await,
number: settings.bool("auth.password.number").await,
symbol: settings.bool("auth.password.symbol").await,
breached: settings.bool("auth.password.breached").await,
}
}
pub fn errors(&self, password: &str, confirmation: &str) -> Errors {
let mut errors = crate::support::page::check(
&[("password", password)],
&[("password", &format!("required|min:{}|max:200", self.minimum))],
);
if password.is_empty() {
return errors;
}
let mut missing = Vec::new();
if self.uppercase && !password.chars().any(char::is_uppercase) {
missing.push("an upper-case letter");
}
if self.lowercase && !password.chars().any(char::is_lowercase) {
missing.push("a lower-case letter");
}
if self.number && !password.chars().any(|c| c.is_ascii_digit()) {
missing.push("a number");
}
if self.symbol && !password.chars().any(|c| !c.is_alphanumeric()) {
missing.push("a special character");
}
if !missing.is_empty() {
errors.add("password", format!("The password needs {}.", and_list(&missing)));
}
if self.breached {
errors.add(
"password",
"Checking passwords against Have I Been Pwned is not implemented in this \
starter kit, so no password can be accepted while that setting is on. \
Turn it off under Settings → Security, or implement the range lookup.",
);
}
if password != confirmation {
errors.add("password_confirmation", "The two passwords do not match.");
}
errors
}
}
fn and_list(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => (*one).to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
pub fn password_errors(password: &str, confirmation: &str, minimum: i64) -> Errors {
Policy::length_only(minimum).errors(password, confirmation)
}
pub async fn expired(req: Request, reason: &str, retry_url: &str, retry_label: &str) -> Result<Response> {
let context = page::shell(&req, "").await
.with("reason", Json::from(reason))
.with("retry_url", Json::from(retry_url))
.with("retry_label", Json::from(retry_label));
req.view("auth/expired", &context)
}
#[cfg(test)]
mod tests {
use super::{Policy, and_list, password_errors};
fn strict() -> Policy {
Policy {
minimum: 12,
uppercase: true,
lowercase: true,
number: true,
symbol: true,
breached: false,
}
}
fn message(policy: &Policy, password: &str) -> String {
policy
.errors(password, password)
.all()
.values()
.flatten()
.cloned()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn length_alone_accepts_a_passphrase() {
let policy = Policy::length_only(12);
assert!(policy.errors("correct horse battery staple", "correct horse battery staple").is_empty());
assert!(policy.errors("Password1234!", "Password1234!").is_empty());
}
#[test]
fn the_minimum_comes_from_the_setting_rather_than_a_constant() {
let password = "abcdefghijk";
assert_eq!(password.len(), 11);
for minimum in [8, 10] {
assert!(
Policy::length_only(minimum).errors(password, password).is_empty(),
"a {minimum}-character minimum should have accepted eleven characters"
);
}
for minimum in [12, 16, 20] {
assert!(
!Policy::length_only(minimum).errors(password, password).is_empty(),
"a {minimum}-character minimum should have refused eleven characters"
);
}
assert_eq!(Policy::length_only(2).minimum, 8);
assert_eq!(Policy::length_only(9_000).minimum, 128);
}
#[test]
fn each_complexity_rule_refuses_on_its_own() {
let cases = [
(Policy { uppercase: true, ..Policy::length_only(12) }, "lower case only", "an upper-case letter"),
(Policy { lowercase: true, ..Policy::length_only(12) }, "UPPER CASE ONLY", "a lower-case letter"),
(Policy { number: true, ..Policy::length_only(12) }, "no digits here", "a number"),
(Policy { symbol: true, ..Policy::length_only(12) }, "onlyletters123", "a special character"),
];
for (policy, password, wanted) in cases {
let complaint = message(&policy, password);
assert!(
complaint.contains(wanted),
"{password:?} should have been refused for lacking {wanted}, got {complaint:?}"
);
assert!(Policy::length_only(12).errors(password, password).is_empty());
}
}
#[test]
fn every_rule_at_once_names_everything_missing() {
let complaint = message(&strict(), "aaaaaaaaaaaaaa");
assert!(complaint.contains("an upper-case letter"), "{complaint}");
assert!(complaint.contains("a number"), "{complaint}");
assert!(complaint.contains("a special character"), "{complaint}");
assert!(!complaint.contains("a lower-case letter"), "{complaint}");
assert!(strict().errors("Tr0ubador&horse", "Tr0ubador&horse").is_empty());
}
#[test]
fn a_symbol_is_anything_that_is_not_a_letter_or_a_digit() {
let policy = Policy { symbol: true, ..Policy::length_only(12) };
for password in ["hyphenated-word", "spaces are fine", "pound £sterling", "emoji 🔐 counts"] {
assert!(
policy.errors(password, password).is_empty(),
"{password:?} contains a non-alphanumeric character and should have passed"
);
}
}
#[test]
fn the_breach_check_refuses_rather_than_waves_through() {
let policy = Policy { breached: true, ..Policy::length_only(12) };
let complaint = message(&policy, "correct horse battery staple");
assert!(!complaint.is_empty(), "an unimplemented check must not pass silently");
assert!(complaint.contains("not implemented"), "{complaint}");
assert!(complaint.contains("Settings"), "the message should say where to turn it off");
}
#[test]
fn an_empty_password_is_told_one_thing() {
let errors = strict().errors("", "");
assert_eq!(errors.all().len(), 1);
}
#[test]
fn a_mismatched_confirmation_is_reported_against_its_own_field() {
let errors = Policy::length_only(12).errors("correct horse battery", "correct horse batter");
assert!(!errors.is_empty());
assert!(errors.all().contains_key("password_confirmation"));
}
#[test]
fn the_length_only_wrapper_still_behaves_as_it_did() {
assert!(password_errors("correct horse battery staple", "correct horse battery staple", 12).is_empty());
assert!(!password_errors("short", "short", 12).is_empty());
assert!(!password_errors("correct horse battery", "typo horse battery", 12).is_empty());
}
#[test]
fn a_list_of_missing_things_reads_as_a_sentence() {
assert_eq!(and_list(&["a number"]), "a number");
assert_eq!(and_list(&["a number", "a symbol"]), "a number and a symbol");
assert_eq!(
and_list(&["an upper-case letter", "a number", "a symbol"]),
"an upper-case letter, a number and a symbol"
);
}
}