use crate::authn::service::AuthnService;
use crate::authn::{
error::AuthnError,
event::{AuthEventBuilder, AuthEventType},
factor::{FactorConfig, FactorKind},
store::{FactorStore, IdentityStore},
types::AuthnScope,
};
impl<I, F> AuthnService<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
#[tracing::instrument(skip(self, ttl), fields(tenant = %tenant_identifier))]
pub async fn begin_password_reset(
&self,
identifier: &str,
tenant_identifier: &str,
ttl: std::time::Duration,
) -> Result<Option<String>, AuthnError<I::Error>> {
let tenant = self
.identity
.find_tenant(tenant_identifier)
.await
.map_err(AuthnError::Store)?;
let tenant = match tenant {
Some(t) if t.status.is_active() => t,
_ => return Ok(None),
};
let user = self
.identity
.find_user(identifier, &tenant.id)
.await
.map_err(AuthnError::Store)?;
let user = match user {
Some(u) if u.validate().is_ok() => Some(u),
_ => None,
};
let mut token_bytes = [0u8; 32];
self.rng.fill_bytes(&mut token_bytes);
use base64::Engine as _;
let plaintext = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
let hash = {
use sha2::Digest;
let digest = sha2::Sha256::digest(plaintext.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
};
let user = match user {
Some(u) => u,
None => {
return Ok(None);
}
};
let expires_at = self.clock.now() + chrono::Duration::from_std(ttl).unwrap_or_default();
self.identity
.store_reset_token(&user.id, &hash, expires_at)
.await
.map_err(AuthnError::Store)?;
self.emit_audit(
AuthEventBuilder::success(AuthEventType::PasswordResetRequested)
.attributed_to(&user.id, &user.tenant_id),
)
.await;
Ok(Some(plaintext))
}
pub async fn complete_password_reset_in_tenant(
&self,
user_id: &crate::authn::ids::UserId,
expected_tenant: &crate::authn::ids::TenantId,
token: &str,
new_password: &str,
) -> Result<bool, AuthnError<I::Error>> {
let user = self
.identity
.get_user(user_id)
.await
.map_err(AuthnError::Store)?
.ok_or(AuthnError::NoFlow)?;
if &user.tenant_id != expected_tenant {
tracing::warn!(
user_id = %user_id,
user_tenant = %user.tenant_id,
expected_tenant = %expected_tenant,
"cross-tenant password reset refused"
);
return Err(AuthnError::CrossTenant);
}
self.complete_password_reset(user_id, token, new_password)
.await
}
#[tracing::instrument(skip(self, token, new_password))]
pub async fn complete_password_reset(
&self,
user_id: &crate::authn::ids::UserId,
token: &str,
new_password: &str,
) -> Result<bool, AuthnError<I::Error>> {
if !self.verify_reset_token_hash(user_id, token).await? {
return Ok(false);
}
let user = self
.identity
.get_user(user_id)
.await
.map_err(AuthnError::Store)?
.ok_or(AuthnError::NoFlow)?;
let user_scope = AuthnScope::User {
tenant_id: user.tenant_id,
user_id: user.id,
};
if let Some(FactorConfig::Password(ref old_config)) = self
.factors
.load_factor(&user_scope, FactorKind::Password)
.await
.map_err(AuthnError::Store)?
{
self.identity
.record_password_hash(user_id, &old_config.hash)
.await
.map_err(AuthnError::Store)?;
}
let rules = self
.identity
.password_rules_for_tenant(&user.tenant_id)
.await
.map_err(AuthnError::Store)?;
if self
.verify_new_password_not_in_history(user_id, new_password, &rules)
.await?
{
return Ok(false); }
let new_password_hash = axess_factors::generate_password_hash(new_password);
self.identity
.record_password_hash(user_id, &new_password_hash)
.await
.map_err(AuthnError::Store)?;
let new_config = FactorConfig::Password(crate::authn::factor::PasswordConfig {
hash: crate::authn::factor::ZeroizedString::new(new_password_hash),
rules,
});
self.factors
.save_factor(&user_scope, new_config)
.await
.map_err(AuthnError::Store)?;
self.identity
.reset_failed_attempts(user_id)
.await
.map_err(AuthnError::Store)?;
if let Some(reg) = &self.registry {
reg.invalidate_user(user_id).await;
tracing::info!(
user_id = %user_id,
"password reset: invalidated all sessions for user"
);
}
self.emit_audit(
AuthEventBuilder::success(AuthEventType::PasswordReset)
.attributed_to(&user.id, &user.tenant_id),
)
.await;
Ok(true)
}
async fn verify_reset_token_hash(
&self,
user_id: &crate::authn::ids::UserId,
token: &str,
) -> Result<bool, AuthnError<I::Error>> {
use base64::Engine as _;
let hash = {
use sha2::Digest;
let digest = sha2::Sha256::digest(token.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
};
self.identity
.verify_reset_token(user_id, &hash)
.await
.map_err(AuthnError::Store)
}
async fn verify_new_password_not_in_history(
&self,
user_id: &crate::authn::ids::UserId,
new_password: &str,
rules: &crate::authn::factor::PasswordRules,
) -> Result<bool, AuthnError<I::Error>> {
if rules.history_count == 0 {
return Ok(false);
}
let history = self
.identity
.password_history(user_id, rules.history_count)
.await
.map_err(AuthnError::Store)?;
let mut reused = false;
for old_hash in &history {
if axess_factors::verify_password(new_password, old_hash).is_ok() {
reused = true;
}
}
Ok(reused)
}
}