use std::sync::{Arc, LazyLock};
use jiff::Timestamp;
use tokio::sync::Semaphore;
use topcoat::context::Cx;
use uuid::Uuid;
use super::{
Authenticator, EmailChange, EmailVerifier, PanelUser, PasswordResetter, Profile, ProfileEditor,
ProfileUpdate, Registrar, SignUp, infrastructure_failure, profile::CURRENT_PASSWORD_FIELD,
request_email_change,
};
use crate::FieldErrors;
const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$h3oXdPBVwhcgZ1OTO/PuzQ$zLrHLgIkwhqu4ZlLTfSyB8mPuL6mAtaswv/eXJ5ADO8";
#[derive(Debug, Clone, toasty::Model, crate::ModelColumns)]
pub struct AdminUser {
#[key]
#[auto]
pub id: Uuid,
#[unique]
pub email: String,
#[columns(skip)]
pub password_hash: String,
pub display_name: String,
pub active: bool,
pub email_verified_at: Option<Timestamp>,
pub created_at: Timestamp,
}
impl PanelUser for AdminUser {
fn user_id(&self) -> String {
self.id.to_string()
}
fn display_name(&self) -> &str {
&self.display_name
}
fn email(&self) -> Option<&str> {
Some(&self.email)
}
fn can_access_panel(&self, _panel: &str) -> bool {
self.active
}
}
pub(crate) async fn end_changed_credentials(
cx: &Cx,
ex: &mut dyn toasty::Executor,
before: &(dyn std::any::Any + Send + Sync),
after: &(dyn std::any::Any + Send + Sync),
) -> topcoat::Result<()> {
let (Some(before), Some(after)) = (
before.downcast_ref::<AdminUser>(),
after.downcast_ref::<AdminUser>(),
) else {
return Ok(());
};
if before.email == after.email && before.password_hash == after.password_hash {
return Ok(());
}
let Some(panel) = super::realm::current_realm(cx) else {
return Ok(());
};
let user_type = std::any::TypeId::of::<AdminUser>();
let panels = super::session::credential_panels(cx, user_type, panel);
if panels.is_empty() {
return Ok(());
}
let user_id = before.id.to_string();
if let Some(signed) = super::signed(cx).filter(|signed| {
signed.user.user_id() == user_id && panels.iter().any(|p| Arc::ptr_eq(p, &signed.realm))
}) {
let session = super::session::start(cx).await?;
return super::session::replace_sessions(cx, ex, signed, user_type, &session).await;
}
super::session::end_credentials(ex, &user_id, &panels, super::token::REVOCATION).await
}
#[derive(Debug, Default, Clone, Copy)]
pub struct PasswordAuth;
impl Authenticator for PasswordAuth {
type User = AdminUser;
async fn verify(
&self,
cx: &Cx,
login: &str,
password: &str,
) -> topcoat::Result<Option<AdminUser>> {
let mut db = crate::db::db(cx)?;
let user = AdminUser::filter(AdminUser::fields().email().eq(normalize_email(login)))
.first()
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
let hash = user.as_ref().map(|user| user.password_hash.as_str());
if !verify_password(password, hash).await {
return Ok(None);
}
Ok(user)
}
async fn find_by_id(&self, cx: &Cx, id: &str) -> topcoat::Result<Option<AdminUser>> {
let Ok(id) = Uuid::parse_str(id) else {
return Ok(None);
};
let mut db = crate::db::db(cx)?;
let user = AdminUser::filter(AdminUser::fields().id().eq(id))
.first()
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
Ok(user.filter(|user| user.active))
}
}
#[derive(Debug, Clone, Copy)]
pub struct PasswordSignUp {
active: bool,
}
impl PasswordSignUp {
#[must_use]
pub fn pending() -> Self {
Self { active: false }
}
#[must_use]
pub fn open() -> Self {
Self { active: true }
}
}
impl Registrar for PasswordSignUp {
type Input = SignUp;
type User = AdminUser;
async fn validate(
&self,
_cx: &Cx,
sign_up: &SignUp,
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<FieldErrors> {
let mut errors = sign_up.password_errors();
let taken = AdminUser::filter(
AdminUser::fields()
.email()
.eq(normalize_email(&sign_up.email)),
)
.first()
.exec(ex)
.await
.map_err(infrastructure_failure)?;
if taken.is_some() {
errors.add("email", email_taken());
}
Ok(errors)
}
async fn register(
&self,
_cx: &Cx,
sign_up: SignUp,
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<AdminUser> {
insert_admin(
ex,
&sign_up.email,
&sign_up.password,
&sign_up.name,
self.active,
None,
)
.await
}
}
impl ProfileEditor for PasswordAuth {
type Input = Profile;
type User = AdminUser;
fn fill(&self, _cx: &Cx, admin: &AdminUser) -> Profile {
Profile {
name: admin.display_name.clone(),
email: admin.email.clone(),
password: None,
password_confirmation: String::new(),
current_password: None,
}
}
async fn validate(
&self,
_cx: &Cx,
admin: &AdminUser,
profile: &Profile,
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<FieldErrors> {
let mut errors = profile.password_errors();
let new_email = normalize_email(&profile.email) != admin.email;
if new_email || profile.password.is_some() {
match &profile.current_password {
None => errors.add(
CURRENT_PASSWORD_FIELD,
crate::text!("tablo-profile-current-password-needed"),
),
Some(current) if !verify_password(current, Some(&admin.password_hash)).await => {
errors.add(
CURRENT_PASSWORD_FIELD,
crate::text!("tablo-profile-current-password-wrong"),
);
}
Some(_) => {}
}
}
if new_email && !errors.contains_key(CURRENT_PASSWORD_FIELD) {
let taken = AdminUser::filter(
AdminUser::fields()
.email()
.eq(normalize_email(&profile.email)),
)
.first()
.exec(ex)
.await
.map_err(infrastructure_failure)?;
if taken.is_some_and(|other| other.id != admin.id) {
errors.add("email", email_taken());
}
}
Ok(errors)
}
async fn save(
&self,
cx: &Cx,
admin: &AdminUser,
profile: Profile,
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<ProfileUpdate> {
let email = normalize_email(&profile.email);
let stored = email != admin.email
&& request_email_change(cx, &mut *ex, admin, &email).await? == EmailChange::Immediate;
let credentials = stored || profile.password.is_some();
let mut update = AdminUser::filter(AdminUser::fields().id().eq(admin.id))
.update()
.display_name(profile.name);
if stored {
update.set_email(email);
update.set_email_verified_at(None);
}
if let Some(password) = &profile.password {
update.set_password_hash(hash_password(password).await?);
}
update.exec(ex).await?;
Ok(if credentials {
ProfileUpdate::Credentials
} else {
ProfileUpdate::Details
})
}
}
impl PasswordResetter for PasswordAuth {
type User = AdminUser;
async fn find_by_email(&self, cx: &Cx, email: &str) -> topcoat::Result<Option<AdminUser>> {
let mut db = crate::db::db(cx)?;
AdminUser::filter(AdminUser::fields().email().eq(normalize_email(email)))
.first()
.exec(&mut db)
.await
.map_err(infrastructure_failure)
}
async fn reset_password(
&self,
_cx: &Cx,
user_id: &str,
password: &str,
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<()> {
let id = Uuid::parse_str(user_id).map_err(|_| topcoat::router::error::not_found())?;
AdminUser::filter(AdminUser::fields().id().eq(id))
.update()
.password_hash(hash_password(password).await?)
.exec(ex)
.await?;
Ok(())
}
}
impl EmailVerifier for PasswordAuth {
type User = AdminUser;
fn is_verified(&self, admin: &AdminUser) -> bool {
admin.email_verified_at.is_some()
}
async fn store_verified_email(
&self,
_cx: &Cx,
user_id: &str,
email: &str,
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<()> {
let id = Uuid::parse_str(user_id).map_err(|_| topcoat::router::error::not_found())?;
AdminUser::filter(AdminUser::fields().id().eq(id))
.update()
.email(normalize_email(email))
.email_verified_at(Some(Timestamp::now()))
.exec(ex)
.await?;
Ok(())
}
}
pub async fn create_admin(
ex: &mut dyn toasty::Executor,
email: &str,
password: &str,
display_name: &str,
) -> topcoat::Result<AdminUser> {
insert_admin(
ex,
email,
password,
display_name,
true,
Some(Timestamp::now()),
)
.await
}
async fn insert_admin(
ex: &mut dyn toasty::Executor,
email: &str,
password: &str,
display_name: &str,
active: bool,
verified_at: Option<Timestamp>,
) -> topcoat::Result<AdminUser> {
let admin = toasty::create!(AdminUser {
email: normalize_email(email),
password_hash: hash_password(password).await?,
display_name: display_name.to_string(),
active,
email_verified_at: verified_at,
created_at: Timestamp::now(),
})
.exec(ex)
.await?;
Ok(admin)
}
fn normalize_email(email: &str) -> String {
email.trim().to_lowercase()
}
pub async fn hash_password(password: &str) -> topcoat::Result<String> {
use argon2::password_hash::PasswordHasher;
let password = password.to_string();
off_executor(move || {
argon2::Argon2::default()
.hash_password(password.as_bytes())
.map(|hash| hash.to_string())
.map_err(topcoat::Error::from)
})
.await
}
#[must_use]
pub async fn verify_password(password: &str, hash: Option<&str>) -> bool {
use argon2::password_hash::{PasswordVerifier, phc::PasswordHash};
let known = hash.is_some();
let password = password.to_string();
let hash = hash.unwrap_or(DUMMY_PASSWORD_HASH).to_string();
let verified = off_executor(move || match PasswordHash::new(&hash) {
Ok(parsed) => argon2::Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(error) => {
tracing::error!(error = %error, "a stored password hash does not parse");
false
}
})
.await;
verified && known
}
static ARGON2_PERMITS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
Arc::new(Semaphore::new(cores))
});
async fn off_executor<T: Send + 'static>(work: impl FnOnce() -> T + Send + 'static) -> T {
permitted(&ARGON2_PERMITS, work).await
}
pub(super) async fn permitted<T: Send + 'static>(
permits: &Arc<Semaphore>,
work: impl FnOnce() -> T + Send + 'static,
) -> T {
let permit = Arc::clone(permits)
.acquire_owned()
.await
.expect("the Argon2 semaphore is never closed");
let work = move || {
let value = work();
drop(permit);
value
};
match tokio::task::spawn_blocking(work).await {
Ok(value) => value,
Err(error) => std::panic::resume_unwind(error.into_panic()),
}
}
fn email_taken() -> crate::Text {
crate::text!(
"tablo-field-taken",
label = crate::text!("tablo-auth-email")
)
}