use crate::error::InvalidRequestReason;
use rand_core::CryptoRng;
use time::OffsetDateTime;
use super::{
Client, Effect, LinkKind, LinkToken, Refusal, Transition, UNEXPECTED, audit, complete_sign_in,
send_link, start_session,
};
use crate::Error;
use crate::account::{Account, Role};
use crate::audit::AuditKind;
use crate::config::Config;
use crate::password::PasswordHash;
use crate::recovery::{self, RecoveryCode};
use crate::session::{Access, Session, SignInMethod, Stage};
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Setup {
Start,
LinkRequested,
InSetup {
account: Account,
session: Session,
},
Complete {
account: Account,
session: Session,
recovery_codes: Vec<RecoveryCode>,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SetupEvent {
RequestLink {
owner: Option<Account>,
last_sent_at: Option<OffsetDateTime>,
client: Client,
},
Redeem {
kind: LinkKind,
link: Option<LinkToken>,
account: Option<Account>,
client: Client,
},
PasswordSet {
hash: PasswordHash,
client: Client,
},
Finish {
client: Client,
},
}
impl Setup {
#[must_use]
pub fn resume(session: Session, account: Account) -> Self {
Setup::InSetup { account, session }
}
#[allow(clippy::too_many_lines)] pub fn advance<R: CryptoRng + ?Sized>(
self,
config: &Config,
event: SetupEvent,
now: OffsetDateTime,
rng: &mut R,
) -> Result<Transition<Setup>, Refusal> {
match (self, event) {
(
Setup::Start,
SetupEvent::RequestLink {
owner,
last_sent_at,
client,
},
) => {
let effects = owner
.filter(|owner| owner.role == Role::Owner && !owner.active)
.map(|owner| {
send_link(
config,
&owner,
LinkKind::Setup,
last_sent_at,
&client,
now,
rng,
)
})
.unwrap_or_default();
Ok(Transition {
state: Setup::LinkRequested,
effects,
})
}
(
Setup::Start,
SetupEvent::Redeem {
kind,
link,
account,
client,
},
) => {
if kind == LinkKind::SignIn {
return Err(UNEXPECTED.into());
}
let valid = |account: &Account| {
!account.active
&& link.is_some_and(|link| {
link.is_redeemable(kind, now) && link.user_id == account.id
})
};
let Some(account) = account.filter(valid) else {
return Err(Error::SignInFailed.into());
};
let (session, effects) = start_session(
config,
account.id,
Stage::Setup,
None,
&client,
None,
now,
rng,
);
Ok(Transition {
state: Setup::InSetup { account, session },
effects,
})
}
(
Setup::InSetup {
mut account,
session,
},
SetupEvent::PasswordSet { hash, client },
) => {
in_setup(config, &account, &session, now)?;
account.factors.password = true;
let effects = super::password_changed(account.id, hash, &client, now);
Ok(Transition {
state: Setup::InSetup { account, session },
effects,
})
}
(
Setup::InSetup {
mut account,
session,
},
SetupEvent::Finish { client },
) => {
in_setup(config, &account, &session, now)?;
if !account.factors.password {
return Err(
Error::InvalidRequest(InvalidRequestReason::PasswordRequired).into(),
);
}
if !account.factors.are_sufficient(config.password_only()) {
return Err(
Error::InvalidRequest(InvalidRequestReason::SecondFactorRequired).into(),
);
}
let issued = recovery::generate(config.recovery_code_count(), rng);
let mut effects = vec![
Effect::ActivateAccount {
user_id: account.id,
},
Effect::StoreRecoveryCodes {
user_id: account.id,
hashes: issued.hashes,
},
Effect::Audit(audit(
AuditKind::RecoveryCodesIssued,
Some(account.id),
&client,
now,
)),
Effect::Audit(audit(
AuditKind::SetupCompleted,
Some(account.id),
&client,
now,
)),
];
account.active = true;
let (session, done) = complete_sign_in(
config,
&account,
&SignInMethod::Setup,
&client,
Some(session.token_hash),
now,
rng,
);
effects.extend(done);
Ok(Transition {
state: Setup::Complete {
account,
session,
recovery_codes: issued.codes,
},
effects,
})
}
_ => Err(UNEXPECTED.into()),
}
}
}
fn in_setup(
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::Stage(Stage::Setup),
config.step_up_window(),
now,
)
}