use getrandom;
use crate::errors::{CoreError, CoreResult};
use crate::hibp::{self, HibpClient, HibpEnforcement};
use crate::events::{self, Context, SecurityEvent};
use crate::mail::{MailSender, OutgoingMail};
use crate::password;
use crate::time::SharedClock;
use base64ct::{Base64UrlUnpadded, Encoding};
use chrono::Duration;
use sha2::{Digest, Sha256};
use sui_id_shared::ids::{PasswordResetTokenId, UserId};
use sui_id_store::models::{CredentialRow, HibpMode, PasswordResetTokenRow};
use sui_id_store::repos::{credentials, password_reset_tokens, refresh_tokens, sessions, smtp_config, users};
use sui_id_store::Database;
pub const DEFAULT_TOKEN_TTL: Duration = Duration::minutes(30);
const MAX_OUTSTANDING_TOKENS_PER_USER: i64 = 3;
fn mint_random_token() -> (String, Vec<u8>) {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("system RNG unavailable");
let plaintext = Base64UrlUnpadded::encode_string(&bytes);
let hash = Sha256::digest(plaintext.as_bytes()).to_vec();
(plaintext, hash)
}
fn hash_token(plaintext: &str) -> Vec<u8> {
Sha256::digest(plaintext.as_bytes()).to_vec()
}
pub async fn request_reset(
db: &Database,
clock: &SharedClock,
mailer: &dyn MailSender,
email: &str,
requester_ip: Option<&str>,
) -> CoreResult<()> {
let normalized_email = sui_id_shared::normalize_email(email);
let now = clock.now();
let mut ctx = Context::default();
if let Some(ip) = requester_ip {
ctx = ctx.with_client_ip(ip);
}
let user_row = users::find_by_email_normalized(db, &normalized_email).await?;
let Some(user_row) = user_row else {
events::emit(
db,
clock,
&ctx,
SecurityEvent::PasswordResetRequested { user_id: None },
).await;
return Ok(());
};
if user_row.is_disabled || user_row.is_deleted {
events::emit(
db,
clock,
&ctx.clone().with_actor(user_row.id),
SecurityEvent::PasswordResetRequested {
user_id: Some(user_row.id),
},
).await;
return Ok(());
}
let outstanding =
password_reset_tokens::count_active_for_user(db, user_row.id, now).await?;
if outstanding >= MAX_OUTSTANDING_TOKENS_PER_USER {
events::emit(
db,
clock,
&ctx.clone().with_actor(user_row.id),
SecurityEvent::PasswordResetThrottled {
user_id: user_row.id,
outstanding,
},
).await;
return Ok(());
}
let (plaintext, hash) = mint_random_token();
let row = PasswordResetTokenRow {
id: PasswordResetTokenId::new(),
user_id: user_row.id,
token_hash: hash,
issued_at: now,
expires_at: now + DEFAULT_TOKEN_TTL,
consumed_at: None,
requester_ip: requester_ip.map(str::to_owned),
};
password_reset_tokens::insert(db, &row).await?;
let base_url = match smtp_config::get(db).await? {
Some(c) if c.enabled => c.base_url,
_ => {
events::emit(
db,
clock,
&ctx.clone().with_actor(user_row.id),
SecurityEvent::PasswordResetEmailFailed {
user_id: user_row.id,
reason: "smtp_unconfigured".into(),
},
).await;
return Ok(());
}
};
let link = format!(
"{}/reset-password?token={}",
base_url.trim_end_matches('/'),
plaintext
);
let default_locale = sui_id_store::repos::server_settings::get(db).await
.ok()
.and_then(|s| sui_id_i18n::Locale::parse(&s.default_lang))
.unwrap_or_default();
let recipient_locale = user_row
.preferred_lang
.as_deref()
.and_then(sui_id_i18n::Locale::parse)
.unwrap_or(default_locale);
let t = recipient_locale.strings();
let display = user_row
.display_name
.as_deref()
.unwrap_or(&user_row.username);
let greeting = if t.email_greeting_suffix.is_empty() {
display.to_string()
} else {
format!("{} {}", display, t.email_greeting_suffix)
};
let mail = OutgoingMail {
to: user_row.email.clone().unwrap_or_else(|| normalized_email.clone()),
subject: t.email_subject_password_reset.to_string(),
text_body: format!(
"{greeting}\n\
\n\
{intro}\n\
\n\
{link}\n\
\n\
{disregard}\n\
",
greeting = greeting,
intro = t.email_password_reset_intro,
link = link,
disregard = t.email_password_reset_disregard,
),
html_body: Some(format!(
"<p>{greeting_esc}</p>\
<p>{intro}</p>\
<p><a href=\"{link_esc}\">{link_label}</a></p>\
<p>{disregard}</p>",
greeting_esc = html_escape(&greeting),
intro = t.email_password_reset_intro,
link_esc = html_escape(&link),
link_label = t.email_password_reset_link_label,
disregard = t.email_password_reset_disregard,
)),
locale: None,
};
match mailer.send(mail).await {
Ok(_outcome) => {
events::emit(
db,
clock,
&ctx.clone().with_actor(user_row.id),
SecurityEvent::PasswordResetEmailSent {
user_id: user_row.id,
},
).await;
}
Err(e) => {
events::emit(
db,
clock,
&ctx.clone().with_actor(user_row.id),
SecurityEvent::PasswordResetEmailFailed {
user_id: user_row.id,
reason: e.to_string(),
},
).await;
}
}
Ok(())
}
pub async fn validate_token(
db: &Database,
clock: &SharedClock,
plaintext_token: &str,
) -> CoreResult<UserId> {
let hash = hash_token(plaintext_token);
let row = password_reset_tokens::find_by_hash(db, &hash).await?
.ok_or(CoreError::InvalidCredentials)?;
if row.consumed_at.is_some() {
return Err(CoreError::InvalidCredentials);
}
if row.expires_at < clock.now() {
return Err(CoreError::InvalidCredentials);
}
Ok(row.user_id)
}
pub async fn consume_and_reset_password(
db: &Database,
clock: &SharedClock,
mailer: &dyn MailSender,
hibp_client: Option<&dyn HibpClient>,
hibp_mode: HibpMode,
plaintext_token: &str,
new_password: &str,
requester_ip: Option<&str>,
) -> CoreResult<()> {
password::check_password_policy(new_password)?;
if matches!(
hibp::enforce_hibp(hibp_mode, hibp_client, new_password).await,
HibpEnforcement::Blocked { .. }
) {
return Err(CoreError::BadRequest(
"New password found in known data breaches. Please choose a different password.".into(),
));
}
let hash = hash_token(plaintext_token);
let row = password_reset_tokens::find_by_hash(db, &hash).await?
.ok_or(CoreError::InvalidCredentials)?;
let now = clock.now();
if row.consumed_at.is_some() || row.expires_at < now {
return Err(CoreError::InvalidCredentials);
}
let new_hash = password::hash_password(new_password)?;
let row_user_id = row.user_id;
let row_id = row.id;
let new_hash_owned = new_hash.clone();
db.with_tx(move |tx| {
credentials::upsert_within_tx(
tx,
&CredentialRow {
user_id: row_user_id,
password_hash: new_hash_owned,
must_change: false,
updated_at: now,
},
)?;
password_reset_tokens::mark_consumed_within_tx(tx, row_id, now)?;
sessions::revoke_all_for_user_within_tx(tx, row_user_id, now)?;
refresh_tokens::revoke_all_for_user_within_tx(tx, row_user_id, now)?;
Ok(())
}).await?;
let mut ctx = Context::default().with_actor(row.user_id);
if let Some(ip) = requester_ip {
ctx = ctx.with_client_ip(ip);
}
events::emit(
db,
clock,
&ctx,
SecurityEvent::PasswordResetCompleted {
user_id: row.user_id,
},
).await;
if let Ok(Some(user_row)) = users::find_by_id_opt(db, row.user_id).await {
if let Some(email) = user_row.email.as_deref() {
let default_locale_pw = sui_id_store::repos::server_settings::get(db).await
.ok()
.and_then(|s| sui_id_i18n::Locale::parse(&s.default_lang))
.unwrap_or_default();
let recipient_locale = user_row
.preferred_lang
.as_deref()
.and_then(sui_id_i18n::Locale::parse)
.unwrap_or(default_locale_pw);
let _ = notify_password_changed(
mailer,
email,
&user_row.display_name,
recipient_locale,
).await;
}
}
Ok(())
}
pub async fn notify_password_changed(
mailer: &dyn MailSender,
to_email: &str,
display_name: &Option<String>,
locale: sui_id_i18n::Locale,
) -> CoreResult<()> {
let t = locale.strings();
let display = display_name.as_deref().unwrap_or("");
let greeting = if t.email_greeting_suffix.is_empty() {
display.to_string()
} else {
format!("{} {}", display, t.email_greeting_suffix)
};
let mail = OutgoingMail {
to: to_email.to_owned(),
subject: t.email_subject_password_changed.to_string(),
text_body: format!(
"{greeting}\n\
\n\
{intro}\n\
{warning}\n\
",
greeting = greeting,
intro = t.email_password_changed_intro,
warning = t.email_password_changed_security_warning,
),
html_body: Some(format!(
"<p>{greeting_esc}</p>\
<p>{intro}</p>\
<p>{warning} <a href=\"/me/security\">{link_label}</a></p>",
greeting_esc = html_escape(&greeting),
intro = t.email_password_changed_intro,
warning = t.email_password_changed_security_warning,
link_label = t.email_password_changed_link_security,
)),
locale: None,
};
mailer.send(mail).await.map(|_| ())
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}