use time::OffsetDateTime;
use super::{
Client, CodeMaterial, Effect, Refusal, Transition, UNEXPECTED, audit, check_code, failure,
use_code,
};
use crate::Error;
use crate::account::{Account, AccountKey};
use crate::audit::AuditKind;
use crate::config::Config;
use crate::http::SetCookie;
use crate::limits::FailureWindow;
use crate::session::{Access, Session, SignInMethod};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum StepUpFailures {
#[default]
AccountLockout,
PerSession {
limit: u32,
},
}
impl StepUpFailures {
#[must_use]
pub const fn ends_session(&self, failures: u32) -> bool {
match *self {
StepUpFailures::AccountLockout => false,
StepUpFailures::PerSession { limit } => failures >= limit,
}
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StepUp {
Start {
account: Account,
session: Session,
},
VerifyPassword {
account: Account,
session: Session,
},
Done {
session: Session,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StepUpEvent {
Password {
wrong_passwords: FailureWindow,
},
PasswordChecked {
matched: bool,
code: String,
material: CodeMaterial,
client: Client,
},
Passkey {
verified: bool,
client: Client,
},
}
impl StepUp {
#[must_use]
pub fn new(account: Account, session: Session) -> Self {
StepUp::Start { account, session }
}
pub fn advance(
self,
config: &Config,
event: StepUpEvent,
now: OffsetDateTime,
) -> Result<Transition<StepUp>, Refusal> {
match (self, event) {
(StepUp::Start { account, session }, StepUpEvent::Password { wrong_passwords }) => {
signed_in(config, &account, &session, now)?;
match config.step_up_failures() {
StepUpFailures::AccountLockout => {
account.lockout.check(now)?;
let limits = config.rate_limits();
if let Some(secs) = wrong_passwords.blocked(
limits.wrong_passwords_per_account,
limits.wrong_password_window,
now,
) {
return Err(Error::too_many(secs).into());
}
}
StepUpFailures::PerSession { .. } => {
spent(config, &session, &Client::default(), now)?;
}
}
Ok(Transition {
state: StepUp::VerifyPassword { account, session },
effects: Vec::new(),
})
}
(
StepUp::VerifyPassword { account, session },
StepUpEvent::PasswordChecked {
matched,
code,
material,
client,
},
) => {
signed_in(config, &account, &session, now)?;
if !matched {
return Err(wrong_guess(
config,
&account,
&session,
Guess::Password,
&client,
now,
));
}
if account.factors.password_suffices(config.password_only()) {
let effects = vec![Effect::ClearFailures {
user_id: account.id,
}];
return Ok(stepped_up(config, &account, session, &client, now, effects));
}
let Some(matched) = check_code(config, &code, &material, now) else {
return Err(wrong_guess(
config,
&account,
&session,
Guess::Code,
&client,
now,
));
};
let mut effects = use_code(&account, matched, &client, now);
effects.push(Effect::ClearFailures {
user_id: account.id,
});
Ok(stepped_up(config, &account, session, &client, now, effects))
}
(StepUp::Start { account, session }, StepUpEvent::Passkey { verified, client }) => {
signed_in(config, &account, &session, now)?;
spent(config, &session, &client, now)?;
if !verified {
return Err(Error::SignInFailed.into());
}
Ok(stepped_up(
config,
&account,
session,
&client,
now,
Vec::new(),
))
}
_ => Err(UNEXPECTED.into()),
}
}
}
fn signed_in(
config: &Config,
account: &Account,
session: &Session,
now: OffsetDateTime,
) -> Result<(), Error> {
if session.user_id != account.id {
return Err(Error::Unauthorized);
}
session.authorize(
account.active,
Access::SignedIn,
config.step_up_window(),
now,
)
}
fn spent(
config: &Config,
session: &Session,
client: &Client,
now: OffsetDateTime,
) -> Result<(), Refusal> {
if config
.step_up_failures()
.ends_session(session.step_up_failures)
{
return Err(Refusal::new(
Error::SessionEnded,
end_session(config, session, client, now),
));
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Guess {
Password,
Code,
}
fn wrong_guess(
config: &Config,
account: &Account,
session: &Session,
guess: Guess,
client: &Client,
now: OffsetDateTime,
) -> Refusal {
let detail = match guess {
Guess::Password => "step_up_password",
Guess::Code => "step_up_code",
};
match config.step_up_failures() {
StepUpFailures::AccountLockout => {
let mut effects = Vec::new();
if guess == Guess::Password {
effects.push(Effect::RecordWrongPassword {
key: AccountKey::from(&account.email),
});
}
effects.extend(failure(account, detail, client, now));
Refusal::new(Error::SignInFailed, effects)
}
policy @ StepUpFailures::PerSession { .. } => {
let mut effects = vec![
Effect::Audit(
audit(AuditKind::SignInFailed, Some(account.id), client, now)
.with_detail(detail),
),
Effect::RecordStepUpFailure {
token_hash: session.token_hash,
},
];
if policy.ends_session(session.step_up_failures.saturating_add(1)) {
effects.extend(end_session(config, session, client, now));
return Refusal::new(Error::SessionEnded, effects);
}
Refusal::new(Error::SignInFailed, effects)
}
}
}
fn end_session(
config: &Config,
session: &Session,
client: &Client,
now: OffsetDateTime,
) -> Vec<Effect> {
vec![
Effect::DeleteSession {
token_hash: session.token_hash,
},
Effect::SetCookie(SetCookie::removal(config.cookie_name().clone())),
Effect::Audit(
audit(
AuditKind::SessionRevoked,
Some(session.user_id),
client,
now,
)
.with_detail("step_up_failures"),
),
]
}
fn stepped_up(
config: &Config,
account: &Account,
mut session: Session,
client: &Client,
now: OffsetDateTime,
mut effects: Vec<Effect>,
) -> Transition<StepUp> {
session.authenticated_at = Some(now);
session.method = Some(SignInMethod::StepUp);
if let StepUpFailures::PerSession { .. } = config.step_up_failures() {
session.step_up_failures = 0;
effects.push(Effect::ClearStepUpFailures {
token_hash: session.token_hash,
});
}
effects.push(Effect::MarkStepUp {
token_hash: session.token_hash,
at: now,
method: SignInMethod::StepUp,
});
effects.push(Effect::Audit(audit(
AuditKind::StepUp,
Some(account.id),
client,
now,
)));
Transition {
state: StepUp::Done { session },
effects,
}
}