use rustlavel::prelude::*;
use crate::models::login_attempt::LoginAttempt;
use crate::models::user::User;
use crate::support::{lockout, page, tokens};
pub const PENDING_KEY: &str = "_mfa_pending";
pub struct LoginController;
impl LoginController {
pub async fn show(req: Request) -> Result<Response> {
if req.identity().is_some() {
return Ok(Response::redirect("/dashboard"));
}
let context = page::shell(&req, "").await.with(
"registration_open",
Json::from(crate::controllers::auth::register_controller::registration_open(&req).await),
)
.with(
"magic_link",
Json::from(crate::controllers::auth::magic_link_controller::enabled(&req).await),
);
req.view("auth/login", &page::old(context, &[("email", None)]))
}
pub async fn store(mut req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let email = req.input("email").unwrap_or_default().trim().to_lowercase();
let password = req.input("password").unwrap_or_default();
let now = tokens::now();
if lockout::address_is_blocked(&req).await {
LoginAttempt::record(&db, &email, None, false, Some("address_blocked"), &req).await?;
return Self::refuse(req, &email, "Too many failed attempts from this network. Try again later.").await;
}
let user = User::first(&db, User::by_email(&email)).await?;
let stored = user.as_ref().and_then(|u| u.password_hash.clone());
let matches = match &stored {
Some(hash) => rustlavel::auth::verify_password(&password, hash),
None => {
rustlavel::auth::verify_password(&password, DUMMY_HASH);
false
}
};
let Some(mut user) = user else {
LoginAttempt::record(&db, &email, None, false, Some("unknown_email"), &req).await?;
lockout::record_address_failure(&req).await;
return Self::refuse(req, &email, WRONG).await;
};
if let Err(reason) = user.can_sign_in(&now) {
LoginAttempt::record(&db, &email, Some(user.id), false, Some(reason), &req).await?;
if reason == "locked" {
let until = user.locked_until.clone().unwrap_or_default();
let context = page::shell(&req, "").await
.with("locked", Json::from(true))
.with("locked_for", Json::from(lockout::remaining(&until, &now)));
return req.view("auth/login", &page::old(context, &[("email", Some(email))]));
}
let message = match reason {
"not_activated" => "This account has not been activated yet. Check your email for the invitation.",
_ => "This account has been deactivated. Ask an administrator.",
};
return Self::refuse(req, &email, message).await;
}
if !matches {
LoginAttempt::record(&db, &email, Some(user.id), false, Some("bad_password"), &req).await?;
lockout::record_address_failure(&req).await;
if lockout::record_failure(&db, &mut user, &req, &now).await? {
let until = user.locked_until.clone().unwrap_or_default();
let context = page::shell(&req, "").await
.with("locked", Json::from(true))
.with("locked_for", Json::from(lockout::remaining(&until, &now)));
return req.view("auth/login", &page::old(context, &[("email", Some(email))]));
}
return Self::refuse(req, &email, WRONG).await;
}
if crate::controllers::auth::mfa_controller::has_factor(&db, user.id).await? {
let session = req.session();
session.regenerate();
session.put(PENDING_KEY, Json::from(user.id));
return Ok(Response::see_other("/mfa"));
}
Self::complete(&req, &db, &mut user, &now).await?;
if let Some(enrol) = Self::enrolment_owed(&req, &db, user.id).await? {
return Ok(Response::see_other(enrol));
}
Ok(Response::see_other(intended(&req)))
}
pub async fn enrolment_owed(req: &Request, db: &Database, user_id: i64) -> Result<Option<String>> {
if !mfa_required(req).await {
return Ok(None);
}
if crate::controllers::auth::mfa_controller::has_factor(db, user_id).await? {
return Ok(None);
}
page::flash(
req,
"warning",
"This site requires two-factor authentication. Set up an authenticator app \
or a passkey to finish securing your account.",
);
Ok(Some("/settings/security".to_string()))
}
pub async fn complete(req: &Request, db: &Database, user: &mut User, now: &str) -> Result<()> {
Guard::new(req.session().clone()).login(user);
lockout::record_success(db, user, req, now).await?;
LoginAttempt::record(db, &user.email.clone(), Some(user.id), true, None, req).await?;
crate::support::audit::remember(req, &user.name);
if let Some(audit) = crate::support::audit::of(req, "logged_in") {
let address = req.ip().unwrap_or_else(|| "an unknown address".into());
audit
.by(user.id, user.name.clone())
.on("User", user.id)
.describe(format!("{} logged in from {address}", user.name))
.record()
.await;
}
Ok(())
}
pub async fn destroy(req: Request) -> Result<Response> {
if let Some(audit) = crate::support::audit::of(&req, "logged_out") {
let name = req
.try_session()
.and_then(|session| session.get_string(crate::support::audit::NAME_KEY))
.unwrap_or_else(|| "Somebody".into());
audit.describe(format!("{name} logged out")).record().await;
}
Guard::new(req.session().clone()).logout();
Ok(Response::see_other("/login"))
}
async fn refuse(req: Request, email: &str, message: &str) -> Result<Response> {
let context = page::shell(&req, "").await
.with("error_summary", Json::from(message))
.with(
"registration_open",
Json::from(crate::controllers::auth::register_controller::registration_open(&req).await),
)
.with(
"magic_link",
Json::from(crate::controllers::auth::magic_link_controller::enabled(&req).await),
);
req.view("auth/login", &page::old(context, &[("email", Some(email.to_string()))]))
}
}
const WRONG: &str = "Those details do not match an account.";
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$Zm9yIHRpbWluZyBvbmx5IG5ldmVyIG1hdGNoZXM";
async fn mfa_required(req: &Request) -> bool {
match req.state::<crate::support::settings::Settings>() {
Some(settings) => settings.bool("auth.require_mfa").await,
None => false,
}
}
fn intended(req: &Request) -> String {
req.session()
.forget("_intended")
.and_then(|value| value.as_str().map(str::to_string))
.filter(|path| path.starts_with('/') && !path.starts_with("//"))
.unwrap_or_else(|| "/dashboard".to_string())
}