mod manager;
mod model;
pub(crate) mod passkey;
mod password;
pub(crate) mod scope;
pub(crate) mod signin;
#[cfg(any(feature = "postgres", feature = "oracle"))]
mod sql;
mod store;
mod tokens;
mod ui;
pub use manager::{RoleManager, UserManager};
pub use model::{AUTHENTICATOR_KEY, Claim, INTERNAL_PROVIDER, Passkey, RECOVERY_CODES, Role, User, UserLogin};
pub use passkey::PasskeyOptions;
pub use password::{
BreachedPasswords, MAX_PASSWORD_BYTES, PasswordDenyList, PasswordHasher, PasswordOptions, Verification, dotnet,
};
pub use signin::{SignInManager, SignInResult, TwoFactorKind};
#[cfg(any(feature = "postgres", feature = "oracle"))]
pub use sql::{SqlAccountStore, create_tables};
pub use store::{AccountStore, MemoryAccountStore};
pub use tokens::Purpose;
pub use ui::{AccountsUi, ROLES_WRITE, USERS_READ, USERS_WRITE, feature, ui};
use crate::ctx::{BoxFuture, Ctx, Service};
use crate::i18n::Text;
use crate::model::{FieldError, Rule, ValidationErrors};
use crate::platform::Secret;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LockoutOptions {
pub allowed_for_new_users: bool,
pub max_failed_access_attempts: i32,
pub default_lockout: Duration,
}
impl Default for LockoutOptions {
fn default() -> Self {
LockoutOptions {
allowed_for_new_users: true,
max_failed_access_attempts: 5,
default_lockout: Duration::from_secs(300),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserOptions {
pub allowed_user_name_characters: String,
pub require_unique_email: bool,
}
impl Default for UserOptions {
fn default() -> Self {
UserOptions {
allowed_user_name_characters: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+".into(),
require_unique_email: false,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SignInOptions {
pub require_confirmed_email: bool,
pub require_confirmed_phone_number: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TokenOptions {
pub token_lifespan: Duration,
pub authenticator_issuer: String,
}
impl Default for TokenOptions {
fn default() -> Self {
TokenOptions { token_lifespan: Duration::from_secs(24 * 3600), authenticator_issuer: "Zeyn".into() }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SessionOptions {
pub expire: Duration,
pub validation_interval: Duration,
pub remember_two_factor: Duration,
pub bearer_expiration: Duration,
pub refresh_expiration: Duration,
}
impl Default for SessionOptions {
fn default() -> Self {
SessionOptions {
expire: Duration::from_secs(14 * 24 * 3600),
validation_interval: Duration::from_secs(30 * 60),
remember_two_factor: Duration::from_secs(14 * 24 * 3600),
bearer_expiration: Duration::from_secs(3600),
refresh_expiration: Duration::from_secs(14 * 24 * 3600),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AccountOptions {
pub password: PasswordOptions,
pub lockout: LockoutOptions,
pub user: UserOptions,
pub sign_in: SignInOptions,
pub tokens: TokenOptions,
pub session: SessionOptions,
pub hasher: PasswordHasher,
pub passkeys: PasskeyOptions,
pub ip_attempts: (u32, Duration),
pub account_attempts: (u32, Duration),
pub registration: bool,
}
impl Default for AccountOptions {
fn default() -> Self {
AccountOptions {
password: PasswordOptions::default(),
lockout: LockoutOptions::default(),
user: UserOptions::default(),
sign_in: SignInOptions::default(),
tokens: TokenOptions::default(),
session: SessionOptions::default(),
hasher: PasswordHasher::default(),
passkeys: PasskeyOptions::default(),
ip_attempts: (30, Duration::from_secs(300)),
account_attempts: (10, Duration::from_secs(300)),
registration: true,
}
}
}
pub fn normalize(name: &str) -> String {
use unicode_normalization::UnicodeNormalization;
name.trim().nfc().collect::<String>().to_uppercase().nfc().collect()
}
pub(crate) fn random_bytes<const N: usize>() -> [u8; N] {
crate::auth::random::<N>()
}
pub(crate) fn new_id() -> String {
let b = random_bytes::<16>();
let h = tokens::hex(&b);
format!("{}-{}-4{}-{}-{}", &h[0..8], &h[8..12], &h[13..16], &h[16..20], &h[20..32])
}
pub(crate) fn new_stamp() -> String {
crate::auth::totp::base32(&random_bytes::<20>())
}
pub(crate) fn now_secs() -> i64 {
crate::auth::now_secs()
}
pub(crate) fn invalid(field: &'static str, code: &str) -> crate::Error {
invalid_n(field, code, None)
}
pub(crate) fn invalid_n(field: &'static str, code: &str, n: Option<usize>) -> crate::Error {
let mut text = Text::key(format!("zeyn.accounts.error.{code}"));
if let Some(n) = n {
text = text.arg("n", n.to_string());
}
crate::Error::Validation(ValidationErrors { errors: vec![FieldError { field, rule: Rule::Custom, message: text }] })
}
pub(crate) fn denied(code: &str) -> crate::Error {
crate::Error::Denied(Text::key(format!("zeyn.accounts.error.{code}")))
}
#[derive(Clone, Debug, Default)]
pub struct RolePermissions(BTreeMap<String, BTreeSet<String>>);
impl RolePermissions {
pub fn role<I: IntoIterator<Item = S>, S: Into<String>>(mut self, role: &str, permissions: I) -> Self {
self.0.entry(normalize(role)).or_default().extend(permissions.into_iter().map(Into::into));
self
}
pub(crate) fn of(&self, roles: &[String]) -> impl Iterator<Item = &String> {
roles.iter().filter_map(|r| self.0.get(&normalize(r))).flatten()
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum AccountMessage {
ConfirmEmail { link: String },
ResetPassword { link: String },
ChangeEmail { link: String },
AlreadyRegistered { link: String },
TwoFactorCode { code: String },
PhoneCode { code: String },
}
impl std::fmt::Debug for AccountMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
AccountMessage::ConfirmEmail { .. } => "ConfirmEmail([redacted])",
AccountMessage::ResetPassword { .. } => "ResetPassword([redacted])",
AccountMessage::ChangeEmail { .. } => "ChangeEmail([redacted])",
AccountMessage::AlreadyRegistered { .. } => "AlreadyRegistered([redacted])",
AccountMessage::TwoFactorCode { .. } => "TwoFactorCode([redacted])",
AccountMessage::PhoneCode { .. } => "PhoneCode([redacted])",
})
}
}
pub trait AccountSender: Send + Sync + 'static {
fn send(&self, ctx: &Ctx, to: &str, message: AccountMessage) -> BoxFuture<crate::Result<()>>;
}
pub struct NoSender;
impl AccountSender for NoSender {
fn send(&self, _ctx: &Ctx, _to: &str, message: AccountMessage) -> BoxFuture<crate::Result<()>> {
tracing::warn!(?message, "accounts: no sender is set, the message was dropped");
Box::pin(async { Ok(()) })
}
}
#[derive(Clone, Default)]
pub struct MemorySender(Arc<Mutex<Vec<(String, AccountMessage)>>>);
impl MemorySender {
pub fn messages(&self) -> Vec<(String, AccountMessage)> {
self.0.lock().map(|m| m.clone()).unwrap_or_default()
}
pub fn last(&self, to: &str) -> Option<String> {
self.messages().into_iter().rev().find(|(t, _)| t == to).map(|(_, m)| match m {
AccountMessage::ConfirmEmail { link }
| AccountMessage::ResetPassword { link }
| AccountMessage::ChangeEmail { link }
| AccountMessage::AlreadyRegistered { link } => link,
AccountMessage::TwoFactorCode { code } | AccountMessage::PhoneCode { code } => code,
})
}
}
impl AccountSender for MemorySender {
fn send(&self, _ctx: &Ctx, to: &str, message: AccountMessage) -> BoxFuture<crate::Result<()>> {
if let Ok(mut m) = self.0.lock() {
if m.len() >= 100 {
m.remove(0);
}
m.push((to.to_string(), message));
}
Box::pin(async { Ok(()) })
}
}
#[cfg(feature = "email")]
pub struct MailSender(pub crate::Mailer);
#[cfg(feature = "email")]
impl AccountSender for MailSender {
fn send(&self, ctx: &Ctx, to: &str, message: AccountMessage) -> BoxFuture<crate::Result<()>> {
let (template, name, value) = match message {
AccountMessage::ConfirmEmail { link } => ("confirm", "link", link),
AccountMessage::ResetPassword { link } => ("reset", "link", link),
AccountMessage::ChangeEmail { link } => ("change", "link", link),
AccountMessage::AlreadyRegistered { link } => ("exists", "link", link),
AccountMessage::TwoFactorCode { code } => ("code", "code", code),
AccountMessage::PhoneCode { .. } => {
tracing::warn!("accounts: MailSender does not send text messages");
return Box::pin(async { Ok(()) });
}
};
let mail = crate::Mail::to(to).template(format!("zeyn.accounts.mail.{template}")).arg(name, value);
let sent = self.0.send(ctx, mail);
Box::pin(async move { sent })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountEventKind {
SignedIn,
SignInFailed,
LockedOut,
SignedOut,
Registered,
EmailConfirmed,
PasswordChanged,
PasswordReset,
TwoFactorEnabled,
TwoFactorDisabled,
RecoveryCodesRenewed,
RecoveryCodeUsed,
PasskeyAdded,
PasskeyRemoved,
LoginLinked,
LoginUnlinked,
StampChanged,
Deleted,
RateLimited,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct AccountEvent {
pub at: i64,
pub kind: AccountEventKind,
pub user: Option<String>,
pub ip: Option<String>,
}
pub trait AccountEvents: Send + Sync + 'static {
fn record(&self, event: AccountEvent);
}
#[derive(Clone, Default)]
pub struct MemoryAccountEvents(Arc<Mutex<Vec<AccountEvent>>>);
impl MemoryAccountEvents {
pub fn events(&self) -> Vec<AccountEvent> {
self.0.lock().map(|e| e.clone()).unwrap_or_default()
}
}
impl AccountEvents for MemoryAccountEvents {
fn record(&self, event: AccountEvent) {
if let Ok(mut e) = self.0.lock() {
if e.len() >= 1000 {
e.remove(0);
}
e.push(event);
}
}
}
pub(crate) fn pct(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
pub(crate) struct Core {
#[cfg(feature = "oidc")]
pub externals: Vec<(String, Arc<crate::auth::OidcAuth>)>,
pub store: Arc<dyn AccountStore>,
pub options: AccountOptions,
pub keys: tokens::Keys,
pub sealer: Option<crate::auth::seal::Sealer>,
pub sender: Arc<dyn AccountSender>,
pub events: Option<Arc<dyn AccountEvents>>,
pub breached: Option<Arc<dyn BreachedPasswords>>,
pub roles: RolePermissions,
pub everyone: Vec<String>,
pub public_url: Option<String>,
pub dummy_hash: String,
pub in_flight: crate::auth::totp::InFlight,
}
#[derive(Clone)]
pub struct Accounts(pub(crate) Arc<Core>);
impl Service for Accounts {}
impl Accounts {
pub fn new(store: impl AccountStore, key: Secret<String>) -> Accounts {
let options = AccountOptions::default();
let dummy = format!("dummy-{}", tokens::hex(&random_bytes::<16>()));
let dummy_hash = options.hasher.hash(&dummy).unwrap_or_default();
Accounts(Arc::new(Core {
#[cfg(feature = "oidc")]
externals: Vec::new(),
store: Arc::new(store),
keys: tokens::Keys::new(key.expose().as_bytes()),
sealer: crate::auth::seal::Sealer::new(&key),
options,
sender: Arc::new(NoSender),
events: None,
breached: None,
roles: RolePermissions::default(),
everyone: Vec::new(),
in_flight: Default::default(),
public_url: None,
dummy_hash,
}))
}
fn change(self, f: impl FnOnce(&mut Core)) -> Accounts {
let mut core = Arc::try_unwrap(self.0).unwrap_or_else(|_| panic!("configure Accounts before cloning it"));
f(&mut core);
Accounts(Arc::new(core))
}
pub fn options(self, f: impl FnOnce(&mut AccountOptions)) -> Accounts {
self.change(|c| {
f(&mut c.options);
c.dummy_hash = c.options.hasher.hash("zeyn-dummy-password").unwrap_or_default();
})
}
pub fn sender(self, sender: impl AccountSender) -> Accounts {
self.change(|c| c.sender = Arc::new(sender))
}
pub fn events(self, events: impl AccountEvents) -> Accounts {
self.change(|c| c.events = Some(Arc::new(events)))
}
pub fn breached_passwords(self, check: impl BreachedPasswords) -> Accounts {
self.change(|c| c.breached = Some(Arc::new(check)))
}
pub fn role<I: IntoIterator<Item = S>, S: Into<String>>(self, role: &str, permissions: I) -> Accounts {
self.change(|c| c.roles = std::mem::take(&mut c.roles).role(role, permissions))
}
pub fn signed_in_permissions<I: IntoIterator<Item = S>, S: Into<String>>(self, permissions: I) -> Accounts {
let list: Vec<String> = permissions.into_iter().map(Into::into).collect();
self.change(|c| c.everyone.extend(list))
}
pub fn public_url(self, url: &str) -> Accounts {
self.change(|c| c.public_url = Some(url.trim_end_matches('/').to_string()))
}
#[cfg(feature = "oidc")]
pub fn external(self, name: &str, mut provider: crate::auth::OidcAuth) -> Accounts {
provider.finish();
let name =
name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').take(32).collect::<String>();
self.change(|c| c.externals.push((name, Arc::new(provider))))
}
#[cfg(feature = "oidc")]
pub(crate) fn external_provider(&self, name: &str) -> Option<Arc<crate::auth::OidcAuth>> {
self.0.externals.iter().find(|(n, _)| n == name).map(|(_, p)| p.clone())
}
pub fn external_names(&self) -> Vec<String> {
#[cfg(feature = "oidc")]
return self.0.externals.iter().map(|(n, _)| n.clone()).collect();
#[cfg(not(feature = "oidc"))]
Vec::new()
}
pub fn store(&self) -> &dyn AccountStore {
&*self.0.store
}
pub fn settings(&self) -> &AccountOptions {
&self.0.options
}
pub fn users(&self) -> UserManager {
UserManager(self.clone())
}
pub fn roles(&self) -> RoleManager {
RoleManager(self.clone())
}
pub fn sign_in(&self) -> SignInManager {
SignInManager(self.clone())
}
pub(crate) fn problems(&self) -> Vec<String> {
let mut problems = Vec::new();
if self.0.sealer.is_none() {
problems.push(format!("Accounts: the key must be at least {} bytes", crate::auth::seal::MIN_SECRET));
}
if self.0.public_url.as_deref().is_some_and(|u| !(u.starts_with("https://") || u.starts_with("http://"))) {
problems
.push("Accounts: the public URL must start with https:// (or http:// for local development)".into());
}
problems
}
pub(crate) fn event(&self, kind: AccountEventKind, user: Option<&str>) {
let event = AccountEvent {
at: now_secs(),
kind,
user: user.map(str::to_string),
ip: scope::ip().map(|i| i.to_string()),
};
match &self.0.events {
Some(sink) => sink.record(event),
None => {
tracing::info!(target: "zeyn::accounts", kind = ?event.kind, user = event.user.as_deref().unwrap_or(""), ip = event.ip.as_deref().unwrap_or(""), "account event")
}
}
}
pub(crate) fn link(&self, path: &str) -> String {
format!("{}{}{path}", self.0.public_url.as_deref().unwrap_or(""), scope::prefix())
}
}