use super::AuthnService;
use super::outcomes::FactorOutcome;
use super::verification::{apply_email_otp_failure, apply_hotp_failure};
use crate::authn::{
error::AuthnError,
event::{AuthEventBuilder, AuthEventType},
factor::{FactorConfig, FactorKind},
ids::{TenantId, UserId},
store::{FactorStore, IdentityStore},
types::{AuthnScope, EntityState},
};
use crate::session::extractor::AuthSession;
#[derive(Debug)]
pub(super) enum AccountStatusEnforcement {
Ok,
Locked {
until: Option<chrono::DateTime<chrono::Utc>>,
},
NotActive(EntityState),
}
impl<I, F> AuthnService<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
pub(super) async fn enforce_account_status(
&self,
user_id: &UserId,
tenant_id: &TenantId,
next_kind: Option<FactorKind>,
session: &AuthSession,
) -> Result<AccountStatusEnforcement, AuthnError<I::Error>> {
let status = self
.identity
.account_status(user_id)
.await
.map_err(AuthnError::Store)?;
if status.is_locked() {
let until = if let EntityState::Suspended(detail) = &status {
detail.until
} else {
None
};
self.record_locked_attempt_audit(user_id, tenant_id, next_kind, session)
.await;
return Ok(AccountStatusEnforcement::Locked { until });
}
if !status.allows_login() {
return Ok(AccountStatusEnforcement::NotActive(status));
}
Ok(AccountStatusEnforcement::Ok)
}
async fn record_locked_attempt_audit(
&self,
user_id: &UserId,
tenant_id: &TenantId,
next_kind: Option<FactorKind>,
session: &AuthSession,
) {
let mut builder = AuthEventBuilder::failure(AuthEventType::FactorVerified)
.attributed_to(user_id, tenant_id)
.with_session(session.session_id().await)
.with_error("locked");
if let Some(k) = next_kind {
builder = builder.with_factor(k);
}
self.emit_audit(builder).await;
}
pub(super) async fn persist_fail_with_update(
&self,
user_scope: &AuthnScope,
current_kind: FactorKind,
updated_config: &FactorConfig,
) -> Result<(), AuthnError<I::Error>> {
const MAX_FAIL_UPDATE_RETRIES: usize = 8;
let initial_user_scope = self
.factors
.load_factor(user_scope, current_kind.clone())
.await
.map_err(AuthnError::Store)?;
let mut applied = false;
let (mut prior_config, mut next_config) = if let Some(existing) = initial_user_scope {
let recomputed = match &existing {
FactorConfig::EmailOtp(otp) => FactorConfig::EmailOtp(apply_email_otp_failure(otp)),
FactorConfig::Hotp(otp) => FactorConfig::Hotp(apply_hotp_failure(otp)),
_ => updated_config.clone(),
};
(existing, recomputed)
} else {
self.factors
.save_factor(user_scope, updated_config.clone())
.await
.map_err(AuthnError::Store)?;
applied = true;
(updated_config.clone(), updated_config.clone())
};
for _ in 0..MAX_FAIL_UPDATE_RETRIES {
if applied {
break;
}
let swapped = self
.factors
.compare_and_save_factor(user_scope, &prior_config, next_config.clone())
.await
.map_err(AuthnError::Store)?;
if swapped {
applied = true;
break;
}
let reloaded = self
.factors
.load_factor(user_scope, current_kind.clone())
.await
.map_err(AuthnError::Store)?;
let Some(reloaded_config) = reloaded else {
if let Err(e) = self
.factors
.save_factor(user_scope, updated_config.clone())
.await
{
tracing::warn!(
error = %e,
"fallback save_factor on missing user-scope row \
also failed; failure counter not persisted"
);
}
applied = true;
break;
};
match &reloaded_config {
FactorConfig::EmailOtp(otp) => {
next_config = FactorConfig::EmailOtp(apply_email_otp_failure(otp));
prior_config = reloaded_config;
}
FactorConfig::Hotp(otp) => {
next_config = FactorConfig::Hotp(apply_hotp_failure(otp));
prior_config = reloaded_config;
}
_ => {
break;
}
}
}
if !applied {
tracing::warn!("factor: failed to atomically increment failure counter after retries");
}
Ok(())
}
pub(super) async fn record_factor_failure(
&self,
user_id: &UserId,
tenant_id: &TenantId,
current_kind: &FactorKind,
session: &AuthSession,
) -> Result<FactorOutcome, AuthnError<I::Error>> {
self.metrics.factor_failure();
if let Err(e) = self
.identity
.record_event(
AuthEventBuilder::failure(AuthEventType::FactorVerified)
.attributed_to(user_id, tenant_id)
.with_factor(current_kind.clone())
.build_at(self.clock.now()),
)
.await
{
tracing::error!(
user_id = %user_id,
error = %e,
"failed to record FactorVerified Failure audit event; \
SOC dashboards will be missing this attempt; proceeding with counter update"
);
}
let count = match self.identity.record_failed_attempt(user_id).await {
Ok(n) => n,
Err(e) => {
self.metrics.factor_counter_store_outage();
tracing::warn!(
user_id = %user_id,
error = %e,
outage = "factor_counter_store",
"record_failed_attempt errored; returning InvalidCredential \
without lockout-counter update; monitor counter-store health"
);
session.record_attempt_at(self.clock.now()).await;
return Ok(FactorOutcome::InvalidCredential);
}
};
session.record_attempt_at(self.clock.now()).await;
let policy = self.identity.lockout_policy_for_tenant(tenant_id);
if count >= policy.max_attempts {
self.metrics.account_locked();
let until = policy.duration.and_then(|d| {
chrono::Duration::from_std(d)
.ok()
.map(|d| self.clock.now() + d)
});
return Ok(FactorOutcome::Locked { until });
}
Ok(FactorOutcome::InvalidCredential)
}
pub(super) async fn persist_pass_with_update(
&self,
user_scope: &AuthnScope,
current_kind: FactorKind,
updated_config: FactorConfig,
) -> Result<bool, AuthnError<I::Error>> {
let existing_user_scope = self
.factors
.load_factor(user_scope, current_kind)
.await
.map_err(AuthnError::Store)?;
match existing_user_scope {
Some(prior) => self
.factors
.compare_and_save_factor(user_scope, &prior, updated_config)
.await
.map_err(AuthnError::Store),
None => {
self.factors
.save_factor(user_scope, updated_config)
.await
.map_err(AuthnError::Store)?;
Ok(true)
}
}
}
}
#[cfg(test)]
mod tests;