use std::fmt::Display;
use crate::{
app::{App, AppTypes},
errors::Error,
hashing,
mail::{Challenge, Notification, issue_challenge},
maybe_auth::Auth,
secret::{PasswordHash, Secret},
};
pub trait UserID<T> {
fn id(&self) -> T;
fn set_id(&mut self, new_id: T);
fn identifier(&self) -> &str;
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow, sqlx::Type))]
#[cfg_attr(feature = "diesel", derive(diesel::prelude::QueryableByName))]
pub struct UserState {
#[cfg_attr(feature = "diesel", diesel(sql_type = diesel::sql_types::Bool))]
pub is_suspended: bool,
#[cfg_attr(feature = "diesel", diesel(sql_type = diesel::sql_types::Bool))]
pub require_email_verification: bool,
#[cfg_attr(feature = "diesel", diesel(sql_type = diesel::sql_types::Bool))]
pub require_password_change: bool,
}
impl UserState {
pub fn is_ready(self) -> bool {
!self.is_suspended && !self.require_email_verification && !self.require_password_change
}
pub(crate) fn require_ready<ID: Display>(&self, id: ID) -> Result<(), Error> {
if self.is_suspended {
log::info!("User #{id} is suspended");
Err(Error::UserIsSuspended)
} else if self.require_email_verification {
log::info!("User #{id} requires email verification");
Err(Error::EmailNotVerified)
} else if self.require_password_change {
log::info!("User #{id} requires password change");
Err(Error::RequirePasswordChange)
} else {
Ok(())
}
}
}
impl Display for UserState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut any = false;
if self.is_suspended {
f.write_str("suspended")?;
any = true;
}
if self.require_email_verification {
if any { f.write_str(", ")?; }
f.write_str("unverified")?;
any = true;
}
if self.require_password_change {
if any { f.write_str(", ")?; }
f.write_str("must change password")?;
any = true;
}
if !any {
f.write_str("ready")?;
}
Ok(())
}
}
#[cfg_attr(feature = "diesel", derive(diesel::prelude::QueryableByName))]
pub struct UserData<A: AppTypes> {
#[cfg_attr(feature = "diesel", diesel(embed))]
pub user: A::User,
#[cfg_attr(feature = "diesel", diesel(deserialize_as = Option<String>), diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>))]
pub password_hash: PasswordHash,
#[cfg_attr(feature = "diesel", diesel(embed))]
pub state: UserState,
}
impl <A: App> UserData<A> {
pub fn has_password(&self) -> bool {
self.password_hash.exists()
}
}
pub async fn register_new_user<A: App>(
app: &mut A,
user: A::User,
password: Secret,
) -> Result<RegistrationOutcome<A>, A::Error> {
let len = password.expose().len();
let hash = hashing::generate_password_hash(&password)?;
register(app, user, Some(len), None, hash)
.await
}
pub async fn register_new_user_without_password<A: App>(
app: &mut A,
user: A::User,
) -> Result<RegistrationOutcome<A>, A::Error> {
register(app, user, None, None, PasswordHash::NONE)
.await
}
pub async fn register_new_user_with_temporary_password<A: App>(
app: &mut A,
user: A::User,
) -> Result<RegistrationOutcome<A>, A::Error> {
let (password, hash) = hashing::generate_password_and_hash()?;
register(app, user, None, Some(password), hash)
.await
}
pub enum PasswordChangeOutcome {
Success,
IncorrectPassword,
NewPasswordTooShort,
PasswordsNotDifferent,
}
pub async fn change_password<A: App>(
app: &mut A,
auth: Auth<A>,
old_password: Option<Secret>,
new_password: Secret,
) -> Result<PasswordChangeOutcome, A::Error> {
let user = auth.user;
if matches!(&old_password, Some(old) if old.0 == new_password.0) {
return Ok(PasswordChangeOutcome::PasswordsNotDifferent);
}
if new_password.0.len() < app.minimum_password_length() {
return Ok(PasswordChangeOutcome::NewPasswordTooShort);
}
let data = app
.get_user_data_by_id(user.id())
.await
.map_err(Into::into)?
.ok_or(Error::UserDataQueryFailed {user_id: user.id().into()})?;
if data.password_hash.exists() {
if let Some(old_password) = old_password {
let result = hashing::check_password(&data.password_hash, &old_password)?;
if !result {
return Ok(PasswordChangeOutcome::IncorrectPassword);
}
} else {
return Ok(PasswordChangeOutcome::IncorrectPassword);
}
}
let new_hash = hashing::generate_password_hash(&new_password)?;
app.update_password(&user, new_hash, false)
.await
.map_err(Into::into)?;
app.send_notification(&user, Notification::PasswordChanged)
.await
.map_err(Into::into)?;
Ok(PasswordChangeOutcome::Success)
}
pub async fn request_password_reset<A: App>(app: &mut A, user: &A::User) -> Result<(), A::Error> {
issue_challenge(app, user, Challenge::ResetPassword)
.await
}
pub enum RegistrationOutcome<A: App> {
Success(A::User),
IdentifierAlreadyExists,
PasswordTooShort,
}
async fn register<A: App>(
app: &mut A,
mut user: A::User,
chosen_password_length: Option<usize>,
temporary_password: Option<Secret>,
password_hash: PasswordHash,
) -> Result<RegistrationOutcome<A>, A::Error> {
let result = app.user_identifier_exists(user.identifier())
.await
.map_err(Into::into)?;
if result {
return Ok(RegistrationOutcome::IdentifierAlreadyExists);
}
if matches!(chosen_password_length, Some(n) if n < app.minimum_password_length()) {
return Ok(RegistrationOutcome::PasswordTooShort);
}
let user_data = UserData {
user: user.clone(),
password_hash,
state: UserState {
is_suspended: false,
require_password_change: temporary_password.is_some(),
require_email_verification: temporary_password.is_none(),
},
};
let user_id = app.insert_user(user_data)
.await
.map_err(Into::into)?;
user.set_id(user_id);
let result = match temporary_password {
Some(temporary_password) => {
let notification = Notification::UserRegistered {temporary_password};
app.send_notification(&user, notification)
.await
.map_err(Into::into)
},
None => {
issue_challenge(app, &user, Challenge::VerifyNewUser)
.await
.map_err(Into::into)
}
};
if let Err(e) = result {
app.delete_user(user_id)
.await
.map_err(Into::into)?;
return Err(e);
}
Ok(RegistrationOutcome::Success(user))
}