pub mod email_link;
pub mod invite;
pub mod setup;
pub mod sign_in;
pub mod step_up;
use crate::error::InvalidRequestReason;
use rand_core::CryptoRng;
use time::OffsetDateTime;
use crate::Error;
use crate::account::{Account, AccountKey, NewAccount};
use crate::audit::{AuditEvent, AuditKind};
use crate::config::Config;
use crate::http::{IpTag, SetCookie};
use crate::ids::{SessionId, UserId};
use crate::mail::{Mail, MailKind, SignInDetails};
use crate::password::PasswordHash;
use crate::recovery::{self, RecoveryCode, RecoveryCodeHash};
use crate::session::{AppMethod, Session, SignInMethod, Stage};
use crate::token::{self, SecretToken, TokenHash};
use crate::totp::{self, TotpSecret, TotpStep};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LinkKind {
Setup,
Invite,
SignIn,
}
impl LinkKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
LinkKind::Setup => "setup",
LinkKind::Invite => "invite",
LinkKind::SignIn => "sign_in",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct LinkToken {
pub kind: LinkKind,
pub user_id: UserId,
pub created_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
impl LinkToken {
#[must_use]
pub const fn new(
kind: LinkKind,
user_id: UserId,
created_at: OffsetDateTime,
expires_at: OffsetDateTime,
) -> Self {
Self {
kind,
user_id,
created_at,
expires_at,
}
}
}
impl LinkToken {
#[must_use]
pub fn is_redeemable(&self, kind: LinkKind, now: OffsetDateTime) -> bool {
self.kind == kind && self.expires_at > now
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Client {
pub ip_tag: Option<IpTag>,
pub user_agent: Option<String>,
pub device_cookie: Option<SecretToken>,
pub device_known: bool,
}
impl Client {
#[must_use]
pub fn new(ip_tag: Option<IpTag>, user_agent: Option<String>) -> Self {
Self {
ip_tag,
user_agent,
device_cookie: None,
device_known: false,
}
}
#[must_use]
pub fn with_device_cookie(mut self, cookie: Option<SecretToken>, known: bool) -> Self {
self.device_known = known && cookie.is_some();
self.device_cookie = cookie;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TotpMaterial {
pub secret: TotpSecret,
pub last_used_step: Option<TotpStep>,
}
impl TotpMaterial {
#[must_use]
pub fn new(secret: TotpSecret, last_used_step: Option<TotpStep>) -> Self {
Self {
secret,
last_used_step,
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct CodeMaterial {
pub totp: Option<TotpMaterial>,
pub recovery_codes: Vec<RecoveryCodeHash>,
}
impl CodeMaterial {
#[must_use]
pub fn new(totp: Option<TotpMaterial>, recovery_codes: Vec<RecoveryCodeHash>) -> Self {
Self {
totp,
recovery_codes,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeMatch {
Totp(TotpStep),
RecoveryCode(RecoveryCodeHash),
}
#[must_use]
pub fn check_code(code: &str, material: &CodeMaterial, now: OffsetDateTime) -> Option<CodeMatch> {
if totp::looks_like_code(code) {
let totp = material.totp.as_ref()?;
return totp::verify(&totp.secret, code, totp.last_used_step, now)
.ok()
.map(CodeMatch::Totp);
}
recovery::verify(code, &material.recovery_codes).map(CodeMatch::RecoveryCode)
}
#[derive(Debug, Clone)]
#[must_use = "effects must be carried out"]
#[non_exhaustive]
pub enum Effect {
StoreSession {
session: Session,
replaces: Option<TokenHash>,
},
DeleteSession {
token_hash: TokenHash,
},
SetCookie(SetCookie),
RecordFailure {
user_id: UserId,
at: OffsetDateTime,
ip_tag: Option<IpTag>,
},
ClearFailures {
user_id: UserId,
},
RecordStepUpFailure {
token_hash: TokenHash,
},
ClearStepUpFailures {
token_hash: TokenHash,
},
RecordWrongPassword {
key: AccountKey,
},
UseTotpStep {
user_id: UserId,
step: TotpStep,
},
UseRecoveryCode {
user_id: UserId,
hash: RecoveryCodeHash,
},
StoreRecoveryCodes {
user_id: UserId,
hashes: Vec<RecoveryCodeHash>,
},
StoreLinkToken {
token_hash: TokenHash,
link: LinkToken,
},
SendMail(Mail),
Audit(AuditEvent),
RememberDevice {
user_id: UserId,
device_hash: TokenHash,
seen_at: OffsetDateTime,
},
CreateAccount(NewAccount),
StorePassword {
user_id: UserId,
hash: PasswordHash,
},
ActivateAccount {
user_id: UserId,
},
MarkStepUp {
token_hash: TokenHash,
at: OffsetDateTime,
method: SignInMethod,
},
}
#[derive(Debug)]
#[must_use = "the effects of a transition must be carried out"]
#[non_exhaustive]
pub struct Transition<S> {
pub state: S,
pub effects: Vec<Effect>,
}
impl<S> Transition<S> {
pub fn new(state: S, effects: Vec<Effect>) -> Self {
Self { state, effects }
}
}
#[derive(Debug, thiserror::Error)]
#[error("{error}")]
#[must_use = "the effects of a refusal must still be carried out"]
#[non_exhaustive]
pub struct Refusal {
#[source]
pub error: Error,
pub effects: Vec<Effect>,
}
impl From<Error> for Refusal {
fn from(error: Error) -> Self {
Self {
error,
effects: Vec::new(),
}
}
}
impl Refusal {
pub fn new(error: Error, effects: Vec<Effect>) -> Self {
Self { error, effects }
}
}
const UNEXPECTED: Error = Error::InvalidRequest(InvalidRequestReason::UnexpectedFlowEvent);
fn audit(
kind: AuditKind,
user_id: Option<UserId>,
client: &Client,
now: OffsetDateTime,
) -> AuditEvent {
AuditEvent::new(kind, user_id, now, client.ip_tag.clone())
}
#[allow(clippy::too_many_arguments)]
fn start_session<R: CryptoRng + ?Sized>(
config: &Config,
user_id: UserId,
stage: Stage,
method: Option<SignInMethod>,
client: &Client,
replaces: Option<TokenHash>,
now: OffsetDateTime,
rng: &mut R,
) -> (Session, Vec<Effect>) {
let ttl = match stage {
Stage::Full => config.session_ttl(),
Stage::SecondFactor => config.pending_ttl(),
Stage::Setup => config.setup_ttl(),
};
let issued = token::issue(rng);
let session = Session {
id: SessionId::random(rng),
token_hash: issued.hash,
user_id,
stage,
csrf_token: token::issue(rng).token,
method,
created_at: now,
expires_at: now.saturating_add(ttl),
authenticated_at: (stage == Stage::Full).then_some(now),
ip_tag: client.ip_tag.clone(),
user_agent: client.user_agent.clone(),
step_up_failures: 0,
};
let effects = vec![
Effect::StoreSession {
session: session.clone(),
replaces,
},
Effect::SetCookie(SetCookie::new(
config.cookie_name().clone(),
&issued.token,
ttl,
)),
];
(session, effects)
}
fn complete_sign_in<R: CryptoRng + ?Sized>(
config: &Config,
account: &Account,
method: &SignInMethod,
client: &Client,
replaces: Option<TokenHash>,
now: OffsetDateTime,
rng: &mut R,
) -> (Session, Vec<Effect>) {
let mut effects = vec![Effect::ClearFailures {
user_id: account.id,
}];
let (session, started) = start_session(
config,
account.id,
Stage::Full,
Some(method.clone()),
client,
replaces,
now,
rng,
);
effects.extend(started);
effects.push(Effect::Audit(
audit(AuditKind::SignIn, Some(account.id), client, now).with_detail(method.as_str()),
));
effects.extend(recognise_device(config, account, method, client, now, rng));
(session, effects)
}
fn recognise_device<R: CryptoRng + ?Sized>(
config: &Config,
account: &Account,
method: &SignInMethod,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> Vec<Effect> {
let presented = client
.device_cookie
.as_ref()
.and_then(|cookie| cookie.hash().map(|hash| (cookie.clone(), hash)));
let (cookie, hash, known) = if let Some((cookie, hash)) = presented {
(cookie, hash, client.device_known)
} else {
let issued = token::issue(rng);
(issued.token, issued.hash, false)
};
let mut effects = vec![Effect::RememberDevice {
user_id: account.id,
device_hash: hash,
seen_at: now,
}];
if !known && *method != SignInMethod::Setup {
effects.push(Effect::Audit(
audit(AuditKind::NewDevice, Some(account.id), client, now).with_detail(method.as_str()),
));
effects.push(Effect::SendMail(Mail {
kind: MailKind::NewDevice,
to: account.email.clone(),
link: config.sessions_url().to_owned(),
expires_at: None,
sign_in: Some(SignInDetails {
at: now,
method: method.clone(),
user_agent: client.user_agent.clone(),
}),
}));
}
effects.push(Effect::SetCookie(SetCookie::new(
config.device_cookie_name().clone(),
&cookie,
config.device_ttl(),
)));
effects
}
fn failure(account: &Account, detail: &str, client: &Client, now: OffsetDateTime) -> Vec<Effect> {
vec![
Effect::Audit(
audit(AuditKind::SignInFailed, Some(account.id), client, now).with_detail(detail),
),
Effect::RecordFailure {
user_id: account.id,
at: now,
ip_tag: client.ip_tag.clone(),
},
]
}
fn send_link<R: CryptoRng + ?Sized>(
config: &Config,
account: &Account,
kind: LinkKind,
last_sent_at: Option<OffsetDateTime>,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> Vec<Effect> {
if kind != LinkKind::Invite
&& last_sent_at.is_some_and(|last| last.saturating_add(config.resend_cooldown()) > now)
{
return Vec::new();
}
let (ttl, mut link, mail_kind, audit_kind) = match kind {
LinkKind::Setup => (
config.link_ttl(),
config.setup_target().clone(),
MailKind::Setup,
AuditKind::SetupLinkSent,
),
LinkKind::SignIn => (
config.link_ttl(),
config.sign_in_link_target().clone(),
MailKind::SignInLink,
AuditKind::SignInLinkSent,
),
LinkKind::Invite => (
config.invite_ttl(),
config.invite_target().clone(),
MailKind::Invite,
AuditKind::InviteSent,
),
};
let issued = token::issue(rng);
let expires_at = now.saturating_add(ttl);
link.query_pairs_mut()
.append_pair("token", issued.token.expose_secret());
vec![
Effect::StoreLinkToken {
token_hash: issued.hash,
link: LinkToken {
kind,
user_id: account.id,
created_at: now,
expires_at,
},
},
Effect::SendMail(Mail {
kind: mail_kind,
to: account.email.clone(),
link: link.into(),
expires_at: Some(expires_at),
sign_in: None,
}),
Effect::Audit(audit(audit_kind, Some(account.id), client, now)),
]
}
fn use_code(
account: &Account,
matched: CodeMatch,
client: &Client,
now: OffsetDateTime,
) -> Vec<Effect> {
match matched {
CodeMatch::Totp(step) => vec![Effect::UseTotpStep {
user_id: account.id,
step,
}],
CodeMatch::RecoveryCode(hash) => vec![
Effect::UseRecoveryCode {
user_id: account.id,
hash,
},
Effect::Audit(audit(
AuditKind::RecoveryCodeUsed,
Some(account.id),
client,
now,
)),
],
}
}
pub fn issue_session<R: CryptoRng + ?Sized>(
config: &Config,
account: &Account,
method: AppMethod,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> Result<Transition<Session>, Error> {
if !account.active || !account.factors.are_sufficient(config.password_only()) {
return Err(Error::Forbidden);
}
account.lockout.check(now)?;
let (session, effects) = complete_sign_in(
config,
account,
&SignInMethod::App(method),
client,
None,
now,
rng,
);
Ok(Transition {
state: session,
effects,
})
}
#[must_use = "effects must be carried out"]
pub fn sign_out(
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::SignOut,
Some(session.user_id),
client,
now,
)),
]
}
#[must_use = "effects must be carried out"]
pub fn password_changed(
user_id: UserId,
hash: PasswordHash,
client: &Client,
now: OffsetDateTime,
) -> Vec<Effect> {
vec![
Effect::StorePassword { user_id, hash },
Effect::Audit(audit(AuditKind::PasswordSet, Some(user_id), client, now)),
]
}
pub fn regenerate_recovery_codes<R: CryptoRng + ?Sized>(
user_id: UserId,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> Transition<Vec<RecoveryCode>> {
let issued = recovery::generate(rng);
Transition {
state: issued.codes,
effects: vec![
Effect::StoreRecoveryCodes {
user_id,
hashes: issued.hashes,
},
Effect::Audit(audit(
AuditKind::RecoveryCodesIssued,
Some(user_id),
client,
now,
)),
],
}
}