use time::{Duration, OffsetDateTime};
use crate::Error;
use crate::ids::UserId;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct Email(String);
impl Email {
pub fn parse(raw: &str) -> Result<Self, Error> {
let email = raw.trim().to_lowercase();
let valid = email.split_once('@').is_some_and(|(local, domain)| {
!local.is_empty() && !domain.is_empty() && !domain.contains('@')
}) && email.len() <= 254
&& !email.chars().any(|c| c.is_whitespace() || c.is_control());
if valid {
Ok(Self(email))
} else {
Err(Error::InvalidRequest("email"))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Email {
type Error = Error;
fn try_from(raw: String) -> Result<Self, Error> {
Self::parse(&raw)
}
}
impl From<Email> for String {
fn from(email: Email) -> String {
email.0
}
}
impl std::fmt::Display for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct AccountKey(String);
impl AccountKey {
#[must_use]
pub fn from_login(raw: &str) -> Self {
Self(Email::parse(raw).map_or_else(|_| raw.trim().to_lowercase(), String::from))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&Email> for AccountKey {
fn from(email: &Email) -> Self {
Self(email.0.clone())
}
}
#[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 Role {
Owner,
Member,
}
impl Role {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::Member => "member",
}
}
}
impl std::str::FromStr for Role {
type Err = Error;
fn from_str(raw: &str) -> Result<Self, Error> {
match raw {
"owner" => Ok(Role::Owner),
"member" => Ok(Role::Member),
_ => Err(Error::InvalidRequest("role")),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Factors {
pub password: bool,
pub totp: bool,
pub passkeys: usize,
}
impl Factors {
#[must_use]
pub const fn is_complete(&self) -> bool {
self.password && (self.totp || self.passkeys > 0)
}
pub const fn may_remove_totp(&self) -> Result<(), Error> {
if !self.totp {
return Err(Error::NotFound);
}
if self.passkeys == 0 {
return Err(Error::Conflict("last_factor"));
}
Ok(())
}
pub const fn may_remove_passkey(&self) -> Result<(), Error> {
if !self.totp && self.passkeys <= 1 {
return Err(Error::Conflict("last_factor"));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LockoutPolicy {
pub threshold: u32,
pub base: Duration,
pub max: Duration,
}
impl Default for LockoutPolicy {
fn default() -> Self {
Self {
threshold: 5,
base: Duration::minutes(5),
max: Duration::hours(1),
}
}
}
impl LockoutPolicy {
#[must_use]
pub fn lock_for(&self, failures: u32) -> Option<Duration> {
if self.threshold == 0 || failures == 0 || !failures.is_multiple_of(self.threshold) {
return None;
}
let mut lock = self.base;
for _ in 1..failures / self.threshold {
if lock >= self.max {
break;
}
lock = lock.saturating_mul(2);
}
Some(lock.min(self.max))
}
#[must_use]
pub fn locked_until(
&self,
failures_after_increment: u32,
now: OffsetDateTime,
) -> Option<OffsetDateTime> {
self.lock_for(failures_after_increment)
.map(|lock| now.saturating_add(lock))
}
#[must_use]
pub fn lockout_event(
&self,
user_id: UserId,
failures_after_increment: u32,
now: OffsetDateTime,
ip_tag: Option<crate::http::IpTag>,
) -> Option<crate::audit::AuditEvent> {
self.lock_for(failures_after_increment).map(|lock| {
crate::audit::AuditEvent::new(
crate::audit::AuditKind::Lockout,
Some(user_id),
now,
ip_tag,
)
.with_detail(format!("{}s", lock.whole_seconds()))
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Lockout {
pub failures: u32,
pub locked_until: Option<OffsetDateTime>,
}
impl Lockout {
pub fn check(&self, now: OffsetDateTime) -> Result<(), Error> {
match self.locked_until {
Some(until) if until > now => Err(Error::too_many(
u64::try_from((until - now).whole_seconds()).unwrap_or(1),
)),
_ => Ok(()),
}
}
#[must_use]
pub fn after_failure(
self,
policy: &LockoutPolicy,
now: OffsetDateTime,
) -> (Self, Option<Duration>) {
let failures = self.failures.saturating_add(1);
let lock = policy.lock_for(failures);
let locked_until = policy.locked_until(failures, now).or(self.locked_until);
(
Self {
failures,
locked_until,
},
lock,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Account {
pub id: UserId,
pub email: Email,
pub display_name: String,
pub role: Role,
pub active: bool,
pub factors: Factors,
pub lockout: Lockout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NewAccount {
pub id: UserId,
pub email: Email,
pub display_name: String,
pub role: Role,
pub created_at: OffsetDateTime,
}
impl NewAccount {
pub fn owner<R: rand_core::CryptoRng + ?Sized>(
config: &crate::Config,
now: OffsetDateTime,
rng: &mut R,
) -> Self {
Self {
id: UserId::random(rng),
email: config.owner_email().clone(),
display_name: config.owner_name().to_owned(),
role: Role::Owner,
created_at: now,
}
}
#[must_use]
pub fn to_account(&self) -> Account {
Account {
id: self.id,
email: self.email.clone(),
display_name: self.display_name.clone(),
role: self.role,
active: false,
factors: Factors::default(),
lockout: Lockout::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(offset: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(1_800_000_000 + offset)
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
}
#[test]
fn lockout_doubles_on_every_threshold_and_caps() {
let policy = LockoutPolicy::default();
assert_eq!(policy.lock_for(4), None);
assert_eq!(policy.lock_for(5), Some(Duration::minutes(5)));
assert_eq!(policy.lock_for(6), None);
assert_eq!(policy.lock_for(10), Some(Duration::minutes(10)));
assert_eq!(policy.lock_for(15), Some(Duration::minutes(20)));
assert_eq!(policy.lock_for(20), Some(Duration::minutes(40)));
assert_eq!(policy.lock_for(25), Some(Duration::hours(1)));
assert_eq!(policy.lock_for(500), Some(Duration::hours(1)));
let off = LockoutPolicy {
threshold: 0,
..policy
};
assert_eq!(off.lock_for(5), None);
}
#[test]
fn five_failures_lock_and_the_lock_lifts() {
let policy = LockoutPolicy::default();
let mut state = Lockout::default();
for i in 0..4 {
let (next, lock) = state.after_failure(&policy, at(i));
assert_eq!(lock, None);
state = next;
}
let (state, lock) = state.after_failure(&policy, at(10));
assert_eq!(lock, Some(Duration::minutes(5)));
assert_eq!(
state.check(at(10)),
Err(Error::TooManyAttempts {
retry_after_secs: 300
})
);
assert!(state.check(at(10 + 300)).is_ok());
let mut state = state;
for i in 0..5 {
state = state.after_failure(&policy, at(400 + i)).0;
}
assert_eq!(state.locked_until, Some(at(404) + Duration::minutes(10)));
}
#[test]
fn emails_are_normalised() {
assert_eq!(
Email::parse(" Owner@Example.COM ").map(String::from),
Ok("owner@example.com".to_owned())
);
assert!(Email::parse("no-at-sign").is_err());
assert!(Email::parse("a@b@c").is_err());
assert!(Email::parse("a b@c").is_err());
assert!(Email::parse("@c").is_err());
assert_eq!(
AccountKey::from_login(" Not An Email "),
AccountKey("not an email".into())
);
}
#[test]
fn the_last_factor_cannot_be_removed() {
let totp_only = Factors {
password: true,
totp: true,
passkeys: 0,
};
assert_eq!(
totp_only.may_remove_totp(),
Err(Error::Conflict("last_factor"))
);
let one_passkey = Factors {
password: true,
totp: false,
passkeys: 1,
};
assert_eq!(
one_passkey.may_remove_passkey(),
Err(Error::Conflict("last_factor"))
);
assert_eq!(one_passkey.may_remove_totp(), Err(Error::NotFound));
let both = Factors {
password: true,
totp: true,
passkeys: 1,
};
assert!(both.may_remove_totp().is_ok());
assert!(both.may_remove_passkey().is_ok());
assert!(both.is_complete());
assert!(!Factors::default().is_complete());
}
}