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(req.config().bool("auth.registration.open", true)),
);
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, &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?;
Ok(Response::see_other(intended(&req)))
}
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?;
Ok(())
}
pub async fn destroy(req: Request) -> Result<Response> {
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(req.config().bool("auth.registration.open", true)));
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";
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())
}