use std::fmt::Write as _;
use std::time::Duration;
use chrono::{DateTime, Utc};
use rand::RngCore;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use laterite_core::{Actor, Db, Events};
use crate::error::AuthError;
use crate::events;
use crate::models::{AccessEvent, BackendUser};
use crate::password;
use crate::permission::PermissionSet;
use crate::store;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct AuthConfig {
#[serde(rename = "session_idle_timeout_secs", deserialize_with = "de_secs")]
pub session_idle_timeout: Duration,
#[serde(
rename = "session_absolute_timeout_secs",
alias = "session_ttl_secs",
deserialize_with = "de_secs"
)]
pub session_absolute_timeout: Duration,
#[serde(rename = "remember_duration_secs", deserialize_with = "de_secs")]
pub remember_duration: Duration,
pub max_failures: i64,
pub max_failures_per_address: i64,
#[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
pub failure_window: Duration,
pub password_policy: PasswordPolicy,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
session_idle_timeout: Duration::from_secs(60 * 60 * 2),
session_absolute_timeout: Duration::from_secs(60 * 60 * 12),
remember_duration: Duration::from_secs(60 * 60 * 24 * 14),
max_failures: 5,
max_failures_per_address: 20,
failure_window: Duration::from_secs(60 * 15),
password_policy: PasswordPolicy::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct PasswordPolicy {
pub min_length: usize,
}
impl Default for PasswordPolicy {
fn default() -> Self {
Self {
min_length: MIN_PASSWORD_LENGTH,
}
}
}
impl PasswordPolicy {
pub fn check(&self, password: &str) -> Result<(), AuthError> {
if password.chars().count() < self.min_length {
return Err(AuthError::Refused(format!(
"A password needs at least {} characters.",
self.min_length
)));
}
Ok(())
}
}
impl AuthConfig {
fn deadline(&self, created_at: DateTime<Utc>, last_seen: DateTime<Utc>) -> DateTime<Utc> {
let idle = last_seen + chrono_from_std(self.session_idle_timeout);
let ceiling = created_at + chrono_from_std(self.session_absolute_timeout);
idle.min(ceiling)
}
}
fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
Ok(Duration::from_secs(u64::deserialize(de)?))
}
#[cfg(test)]
mod auth_config_tests {
use super::{AuthConfig, MIN_PASSWORD_LENGTH};
use chrono::Utc;
use std::time::Duration;
#[test]
fn unset_keys_keep_defaults() {
let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
assert_eq!(cfg.max_failures, 3);
assert_eq!(cfg.session_idle_timeout, Duration::from_secs(60 * 60 * 2));
assert_eq!(
cfg.session_absolute_timeout,
Duration::from_secs(60 * 60 * 12)
);
assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
}
#[test]
fn seconds_map_to_durations() {
let cfg: AuthConfig = serde_json::from_str(
r#"{"session_idle_timeout_secs": 1800, "session_absolute_timeout_secs": 3600,
"max_failures": 7, "failure_window_secs": 120}"#,
)
.unwrap();
assert_eq!(cfg.session_idle_timeout, Duration::from_secs(1800));
assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
assert_eq!(cfg.max_failures, 7);
assert_eq!(cfg.failure_window, Duration::from_secs(120));
}
#[test]
fn the_idle_window_ends_a_quiet_session_first() {
let cfg = AuthConfig::default();
let login = Utc::now();
let seen = login + chrono::Duration::hours(1);
assert_eq!(cfg.deadline(login, seen), seen + chrono::Duration::hours(2));
}
#[test]
fn the_ceiling_caps_a_session_kept_busy() {
let cfg = AuthConfig::default();
let login = Utc::now();
let seen = login + chrono::Duration::hours(11);
assert_eq!(
cfg.deadline(login, seen),
login + chrono::Duration::hours(12)
);
}
#[test]
fn the_password_policy_reads_from_its_own_table() {
let cfg: AuthConfig =
serde_json::from_str(r#"{"password_policy": {"min_length": 12}}"#).unwrap();
assert_eq!(cfg.password_policy.min_length, 12);
assert_eq!(
AuthConfig::default().password_policy.min_length,
MIN_PASSWORD_LENGTH
);
assert!(cfg.password_policy.check("eleven-char").is_err());
assert!(cfg.password_policy.check("twelve-chars").is_ok());
}
#[test]
fn the_former_ttl_key_still_sets_the_ceiling() {
let cfg: AuthConfig = serde_json::from_str(r#"{"session_ttl_secs": 3600}"#).unwrap();
assert_eq!(cfg.session_absolute_timeout, Duration::from_secs(3600));
}
}
#[derive(Debug, Clone, Default)]
pub struct RequestContext {
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Debug, Clone)]
pub struct IssuedSession {
pub token: String,
pub expires_at: DateTime<Utc>,
pub user_id: i64,
}
#[derive(Debug, Clone)]
pub struct RememberCredential {
pub cookie: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct RecalledSession {
pub session: IssuedSession,
pub remember: RememberCredential,
}
#[derive(Debug, Clone)]
pub struct ActiveSession {
pub id: String,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub current: bool,
}
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user: BackendUser,
pub permissions: PermissionSet,
}
impl From<&AuthenticatedUser> for laterite_core::Actor {
fn from(user: &AuthenticatedUser) -> Self {
laterite_core::Actor::user(user.user.id, &user.user.username)
}
}
#[derive(Debug, Clone)]
pub struct ResolvedSession {
pub identity: AuthenticatedUser,
pub data: Option<String>,
pub expires_at: DateTime<Utc>,
}
impl AuthenticatedUser {
pub fn allows(&self, permission: &str) -> bool {
self.permissions.allows(permission)
}
pub fn require(&self, permission: &str) -> Result<(), AuthError> {
if self.allows(permission) {
Ok(())
} else {
Err(AuthError::PermissionDenied(permission.to_string()))
}
}
}
#[derive(Debug, Clone)]
pub struct NewOperator<'a> {
pub username: &'a str,
pub email: &'a str,
pub first_name: &'a str,
pub last_name: Option<&'a str>,
pub password: &'a str,
pub timezone: Option<&'a str>,
}
#[non_exhaustive]
pub struct AuditEntry<'a> {
pub actor_user_id: Option<i64>,
pub actor_username: &'a str,
pub action: &'a str,
pub target_type: Option<&'a str>,
pub target_id: Option<&'a str>,
pub target_label: Option<&'a str>,
pub detail: Option<&'a str>,
}
impl<'a> AuditEntry<'a> {
pub fn new(actor: &'a Actor, action: &'a str) -> Self {
Self {
actor_user_id: actor.user_id(),
actor_username: actor.label(),
action,
target_type: None,
target_id: None,
target_label: None,
detail: None,
}
}
pub fn target(mut self, kind: &'a str, id: &'a str, label: Option<&'a str>) -> Self {
self.target_type = Some(kind);
self.target_id = Some(id);
self.target_label = label;
self
}
pub fn detail(mut self, detail: &'a str) -> Self {
self.detail = Some(detail);
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Purged {
pub sessions: u64,
pub remember_tokens: u64,
}
pub const MIN_PASSWORD_LENGTH: usize = 8;
#[derive(Clone)]
pub struct AuthService {
db: Db,
config: AuthConfig,
events: Events,
}
impl AuthService {
pub fn new(db: Db, config: AuthConfig) -> Self {
let events = Events::new(db.clone());
Self { db, config, events }
}
pub fn with_events(mut self, events: Events) -> Self {
self.events = events;
self
}
pub fn events(&self) -> &Events {
&self.events
}
pub fn session_absolute_timeout(&self) -> Duration {
self.config.session_absolute_timeout
}
pub async fn authenticate(
&self,
username: &str,
password: &str,
ctx: &RequestContext,
) -> Result<IssuedSession, AuthError> {
let now = Utc::now();
let since = now - chrono_from_std(self.config.failure_window);
if self.locked_out(username, ctx, since).await? {
self.log(None, username, AccessEvent::LockedOut, ctx)
.await?;
return Err(AuthError::TooManyAttempts);
}
let user = match store::find_user_by_username(&self.db, username).await? {
Some(user) => user,
None => {
self.log(None, username, AccessEvent::LoginFailure, ctx)
.await?;
return Err(AuthError::InvalidCredentials);
}
};
if !password::verify_password(password, &user.password_hash)? {
self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
.await?;
return Err(AuthError::InvalidCredentials);
}
if !user.is_active {
self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
.await?;
return Err(AuthError::InactiveAccount);
}
let token = generate_token();
let expires_at = self.config.deadline(now, now);
store::insert_session(
&self.db,
&hash_token(&token),
user.id,
expires_at,
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
.await?;
Ok(IssuedSession {
token,
expires_at,
user_id: user.id,
})
}
pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
let token_hash = hash_token(token);
let now = Utc::now();
let session = store::find_valid_session(&self.db, &token_hash, now)
.await?
.ok_or(AuthError::SessionInvalid)?;
let user = store::find_active_user_by_id(&self.db, session.user_id)
.await?
.ok_or(AuthError::SessionInvalid)?;
let idle = chrono_from_std(self.config.session_idle_timeout);
let expires_at = self.config.deadline(session.created_at, now);
if now - session.last_seen_at > idle / 2 {
store::renew_session(&self.db, &token_hash, now, expires_at).await?;
}
let grants = store::load_role_permissions(&self.db, user.id)
.await?
.into_iter()
.flatten();
let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
let (mut allow, mut deny) = (Vec::new(), Vec::new());
for (code, decision) in overrides {
match decision.signum() {
1 => allow.push(code),
-1 => deny.push(code),
_ => {}
}
}
let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
Ok(ResolvedSession {
identity: AuthenticatedUser { user, permissions },
data: session.data,
expires_at,
})
}
pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
Ok(self.resolve_session(token).await?.identity)
}
pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
store::set_session_data(&self.db, &hash_token(token), data).await
}
pub async fn issue_remember(&self, user_id: i64) -> Result<RememberCredential, AuthError> {
let (selector, verifier) = (generate_token(), generate_token());
let expires_at = Utc::now() + chrono_from_std(self.config.remember_duration);
store::insert_remember_token(
&self.db,
&selector,
&hash_token(&verifier),
user_id,
expires_at,
)
.await?;
Ok(RememberCredential {
cookie: format!("{selector}:{verifier}"),
expires_at,
})
}
pub async fn consume_remember(
&self,
cookie: &str,
ctx: &RequestContext,
) -> Result<RecalledSession, AuthError> {
let (selector, verifier) = cookie.split_once(':').ok_or(AuthError::SessionInvalid)?;
let now = Utc::now();
let found = store::find_remember_token(&self.db, selector, now)
.await?
.ok_or(AuthError::SessionInvalid)?;
if !constant_time_eq(&hash_token(verifier), &found.verifier_hash) {
tracing::warn!(
user_id = found.user_id,
"a stay-signed-in cookie was presented with a stale secret; dropping every credential for this account"
);
store::delete_user_remember_tokens(&self.db, found.user_id).await?;
self.log(Some(found.user_id), "", AccessEvent::LoginFailure, ctx)
.await?;
return Err(AuthError::SessionInvalid);
}
let user = store::find_active_user_by_id(&self.db, found.user_id)
.await?
.ok_or(AuthError::SessionInvalid)?;
let replacement = generate_token();
let remember_expires = now + chrono_from_std(self.config.remember_duration);
store::rotate_remember_token(
&self.db,
selector,
&hash_token(&replacement),
remember_expires,
)
.await?;
let remember = RememberCredential {
cookie: format!("{selector}:{replacement}"),
expires_at: remember_expires,
};
let token = generate_token();
let expires_at = self.config.deadline(now, now);
store::insert_session(
&self.db,
&hash_token(&token),
user.id,
expires_at,
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
self.log(
Some(user.id),
&user.username,
AccessEvent::LoginSuccess,
ctx,
)
.await?;
Ok(RecalledSession {
session: IssuedSession {
token,
expires_at,
user_id: user.id,
},
remember,
})
}
pub async fn revoke_remember(&self, cookie: &str) -> Result<(), AuthError> {
if let Some((selector, _)) = cookie.split_once(':') {
store::delete_remember_token(&self.db, selector).await?;
}
Ok(())
}
pub async fn list_sessions(
&self,
user_id: i64,
current_token: &str,
) -> Result<Vec<ActiveSession>, AuthError> {
let current_hash = hash_token(current_token);
let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
Ok(rows
.into_iter()
.map(|s| ActiveSession {
id: public_session_id(&s.token_hash),
created_at: s.created_at,
last_seen_at: s.last_seen_at,
expires_at: s.expires_at,
ip_address: s.ip_address,
user_agent: s.user_agent,
current: s.token_hash == current_hash,
})
.collect())
}
pub async fn revoke_session(&self, user_id: i64, id: &str) -> Result<bool, AuthError> {
let rows = store::list_user_sessions(&self.db, user_id, Utc::now()).await?;
let Some(target) = rows
.into_iter()
.find(|s| constant_time_eq(&public_session_id(&s.token_hash), id))
else {
return Ok(false);
};
let done = store::revoke_user_session(
&self.db,
user_id,
&target.token_hash,
store::RevokeReason::SignedOutElsewhere,
)
.await?;
Ok(done > 0)
}
pub async fn sign_out_everywhere(
&self,
user_id: i64,
keep_token: &str,
) -> Result<u64, AuthError> {
let ended = store::revoke_user_sessions(
&self.db,
user_id,
Some(&hash_token(keep_token)),
store::RevokeReason::SignedOutElsewhere,
)
.await?;
store::revoke_user_remember_tokens(
&self.db,
user_id,
store::RevokeReason::SignedOutElsewhere,
)
.await?;
Ok(ended)
}
pub async fn create_operator(
&self,
new: NewOperator<'_>,
actor: &Actor,
) -> Result<i64, AuthError> {
self.config.password_policy.check(new.password)?;
let hash = password::hash_password(new.password)?;
let id = store::create_user(
&self.db,
new.username,
new.email,
new.first_name,
new.last_name,
&hash,
false,
)
.await?;
if new.timezone.is_some() {
store::set_user_timezone(&self.db, id, new.timezone).await?;
}
let target = id.to_string();
self.record_audit(AuditEntry::new(actor, "backend.user.create").target(
"backend_user",
&target,
Some(new.username),
))
.await?;
Ok(id)
}
pub async fn reset_operator_password(
&self,
user_id: i64,
actor: &Actor,
) -> Result<String, AuthError> {
let password = password::generate();
self.change_password(user_id, &password, None, actor)
.await?;
store::set_must_change_password(&self.db, user_id, true).await?;
Ok(password)
}
pub async fn is_locked_out(&self, username: &str) -> Result<bool, AuthError> {
let since = Utc::now() - chrono_from_std(self.config.failure_window);
store::any_address_locked(&self.db, username, since, self.config.max_failures).await
}
async fn locked_out(
&self,
username: &str,
ctx: &RequestContext,
since: DateTime<Utc>,
) -> Result<bool, AuthError> {
let address = ctx.ip_address.as_deref();
if store::count_recent_failures_by_address(&self.db, address, since).await?
>= self.config.max_failures_per_address
{
return Ok(true);
}
Ok(
store::count_recent_failures_from(&self.db, username, address, since).await?
>= self.config.max_failures,
)
}
pub async fn unlock(&self, username: &str) -> Result<u64, AuthError> {
store::clear_failed_attempts(&self.db, username).await
}
pub async fn purge_expired(&self) -> Result<Purged, AuthError> {
let now = Utc::now();
Ok(Purged {
sessions: store::delete_expired_sessions(&self.db, now).await?,
remember_tokens: store::delete_expired_remember_tokens(&self.db, now).await?,
})
}
pub async fn require_password_change(&self, user_id: i64) -> Result<(), AuthError> {
if store::set_must_change_password(&self.db, user_id, true).await? == 0 {
return Err(AuthError::SessionInvalid);
}
Ok(())
}
pub async fn must_change_password(&self, user_id: i64) -> Result<bool, AuthError> {
store::must_change_password(&self.db, user_id).await
}
pub fn password_policy(&self) -> &PasswordPolicy {
&self.config.password_policy
}
pub async fn change_password(
&self,
user_id: i64,
new_password: &str,
keep_token: Option<&str>,
actor: &Actor,
) -> Result<(), AuthError> {
self.config.password_policy.check(new_password)?;
let hash = password::hash_password(new_password)?;
let keep = keep_token.map(hash_token);
let done = store::change_password(
&self.db,
user_id,
&hash,
keep.as_deref(),
actor.user_id(),
actor.label(),
)
.await?;
if done == 0 {
return Err(AuthError::SessionInvalid);
}
self.events
.emit(&events::PasswordChanged::new(user_id, actor.user_id()))
.await;
Ok(())
}
pub async fn change_own_password(
&self,
user_id: i64,
current_password: &str,
new_password: &str,
keep_token: &str,
ctx: &RequestContext,
) -> Result<(), AuthError> {
let user = store::find_user_by_id(&self.db, user_id)
.await?
.ok_or(AuthError::SessionInvalid)?;
let since = Utc::now() - chrono_from_std(self.config.failure_window);
if self.locked_out(&user.username, ctx, since).await? {
self.log(Some(user.id), &user.username, AccessEvent::LockedOut, ctx)
.await?;
return Err(AuthError::TooManyAttempts);
}
if !password::verify_password(current_password, &user.password_hash)? {
self.log(
Some(user.id),
&user.username,
AccessEvent::LoginFailure,
ctx,
)
.await?;
return Err(AuthError::InvalidCredentials);
}
self.change_password(
user.id,
new_password,
Some(keep_token),
&Actor::user(user.id, user.username.as_str()),
)
.await?;
self.log(
Some(user.id),
&user.username,
AccessEvent::PasswordChanged,
ctx,
)
.await
}
pub async fn recent_access(
&self,
user_id: i64,
limit: u64,
) -> Result<Vec<store::AccessRecord>, AuthError> {
store::recent_access_log(&self.db, user_id, limit).await
}
pub async fn password_changed_at(
&self,
user_id: i64,
) -> Result<Option<DateTime<Utc>>, AuthError> {
store::password_changed_at(&self.db, user_id).await
}
pub async fn reset_password(
&self,
username: &str,
new_password: &str,
actor: &Actor,
) -> Result<bool, AuthError> {
let Some(user) = store::find_user_by_username(&self.db, username).await? else {
return Ok(false);
};
self.change_password(user.id, new_password, None, actor)
.await?;
Ok(true)
}
pub async fn remember_holder(&self, cookie: &str) -> Result<Option<i64>, AuthError> {
let Some((selector, verifier)) = cookie.split_once(':') else {
return Ok(None);
};
let Some(found) = store::find_remember_token(&self.db, selector, Utc::now()).await? else {
return Ok(None);
};
Ok(constant_time_eq(&hash_token(verifier), &found.verifier_hash).then_some(found.user_id))
}
pub async fn remember_end(
&self,
cookie: &str,
) -> Result<Option<store::RevokeReason>, AuthError> {
let Some((selector, verifier)) = cookie.split_once(':') else {
return Ok(None);
};
let Some((verifier_hash, reason)) =
store::revoked_remember_token(&self.db, selector).await?
else {
return Ok(None);
};
if !constant_time_eq(&hash_token(verifier), &verifier_hash) {
return Ok(None);
}
store::delete_remember_token(&self.db, selector).await?;
Ok(Some(reason))
}
pub async fn session_end(&self, token: &str) -> Result<Option<store::SessionEnd>, AuthError> {
let token_hash = hash_token(token);
let end = store::session_end(&self.db, &token_hash, Utc::now()).await?;
if matches!(end, Some(store::SessionEnd::Revoked(_))) {
store::delete_session(&self.db, &token_hash).await?;
}
Ok(end)
}
pub async fn set_user_active(
&self,
actor_id: i64,
user_id: i64,
active: bool,
) -> Result<(), AuthError> {
if !active {
if actor_id == user_id {
return Err(AuthError::Refused(
"You cannot deactivate your own account.".to_string(),
));
}
let user = store::find_user_by_id(&self.db, user_id)
.await?
.ok_or(AuthError::SessionInvalid)?;
if user.is_superuser && store::other_active_superusers(&self.db, user_id).await? == 0 {
return Err(AuthError::Refused(
"This is the last active superuser; the panel would have no \
administrator left."
.to_string(),
));
}
}
if store::set_user_active(&self.db, user_id, active).await? == 0 {
return Err(AuthError::SessionInvalid);
}
Ok(())
}
pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
let hash = hash_token(token);
let owner = store::find_valid_session(&self.db, &hash, Utc::now()).await?;
store::delete_session(&self.db, &hash).await?;
if let Some(session) = owner {
self.events
.emit(&events::SignedOut::new(session.user_id))
.await;
}
Ok(())
}
pub async fn set_user_timezone(
&self,
user_id: i64,
timezone: Option<&str>,
) -> Result<(), AuthError> {
store::set_user_timezone(&self.db, user_id, timezone).await
}
pub async fn set_user_locale(
&self,
user_id: i64,
locale: Option<&str>,
) -> Result<(), AuthError> {
store::set_user_locale(&self.db, user_id, locale).await
}
pub async fn user_permission_overrides(
&self,
user_id: i64,
) -> Result<std::collections::HashMap<String, i64>, AuthError> {
store::load_user_permission_overrides(&self.db, user_id).await
}
pub async fn set_user_permissions(
&self,
user_id: i64,
overrides: &std::collections::HashMap<String, i64>,
) -> Result<(), AuthError> {
store::set_user_permissions(&self.db, user_id, overrides).await
}
pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
store::insert_audit_log(
&self.db,
entry.actor_user_id,
entry.actor_username,
entry.action,
entry.target_type,
entry.target_id,
entry.target_label,
entry.detail,
)
.await
}
pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
store::recent_audit(&self.db, limit).await
}
pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
store::any_user_exists(&self.db).await
}
pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
self.config.password_policy.check(new.password)?;
let hash = password::hash_password(new.password)?;
let id = store::create_user(
&self.db,
new.username,
new.email,
new.first_name,
new.last_name,
&hash,
true,
)
.await?;
if new.timezone.is_some() {
store::set_user_timezone(&self.db, id, new.timezone).await?;
}
if let Some(role_id) = store::role_id_by_code(&self.db, crate::ROLE_ADMIN).await? {
store::assign_role(&self.db, id, role_id).await?;
}
Ok(id)
}
async fn log(
&self,
user_id: Option<i64>,
username: &str,
event: AccessEvent,
ctx: &RequestContext,
) -> Result<(), AuthError> {
store::insert_access_log(
&self.db,
user_id,
username,
event,
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
match (event, user_id) {
(AccessEvent::LoginSuccess, Some(id)) => {
self.events
.emit(&events::SignedIn::new(id, username, ctx))
.await;
}
(AccessEvent::LoginFailure, _) => {
self.events
.emit(&events::SignInFailed::new(user_id, username, ctx))
.await;
}
(AccessEvent::LockedOut, _) => {
self.events
.emit(&events::LockedOut::new(user_id, username, ctx))
.await;
}
_ => {}
}
Ok(())
}
}
fn chrono_from_std(d: Duration) -> chrono::Duration {
chrono::Duration::from_std(d).expect("auth policy duration out of range")
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
to_hex(&bytes)
}
fn public_session_id(token_hash: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"laterite:session-id:");
hasher.update(token_hash.as_bytes());
to_hex(&hasher.finalize())
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
to_hex(&hasher.finalize())
}
fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
let hash = password::hash_password(password).unwrap();
store::create_user(
db,
username,
&format!("{username}@example.test"),
"Test",
Some("Operator"),
&hash,
superuser,
)
.await
.unwrap()
}
fn service(db: Db) -> AuthService {
AuthService::new(db, AuthConfig::default())
}
async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[crate::migrations()]).await
}
async fn set_last_seen(db: &Db, token: &str, seen: DateTime<Utc>) {
store::renew_session(
db,
&hash_token(token),
seen,
seen + chrono::Duration::hours(2),
)
.await
.unwrap();
}
async fn last_seen(db: &Db, token: &str) -> DateTime<Utc> {
store::find_valid_session(db, &hash_token(token), Utc::now())
.await
.unwrap()
.expect("session still valid")
.last_seen_at
}
#[tokio::test]
async fn a_request_past_the_halfway_mark_renews_the_session() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool.clone());
let issued = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let stale = Utc::now() - chrono::Duration::minutes(90);
set_last_seen(&pool, &issued.token, stale).await;
let resolved = svc.resolve_session(&issued.token).await.unwrap();
assert!(
last_seen(&pool, &issued.token).await > stale,
"a request past the halfway mark should push the idle clock"
);
assert!(
resolved.expires_at > Utc::now() + chrono::Duration::minutes(115),
"renewal should restore very nearly the full idle window"
);
}
#[tokio::test]
async fn verify_session_renews_like_the_path_it_wraps() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool.clone());
let issued = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let stale = Utc::now() - chrono::Duration::minutes(90);
set_last_seen(&pool, &issued.token, stale).await;
svc.verify_session(&issued.token).await.unwrap();
assert!(last_seen(&pool, &issued.token).await > stale);
}
#[tokio::test]
async fn a_request_before_the_halfway_mark_writes_nothing() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool.clone());
let issued = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let recent = Utc::now() - chrono::Duration::minutes(10);
set_last_seen(&pool, &issued.token, recent).await;
svc.resolve_session(&issued.token).await.unwrap();
assert_eq!(
last_seen(&pool, &issued.token).await.timestamp(),
recent.timestamp(),
"a request inside the halfway mark should leave the row alone"
);
}
#[tokio::test]
async fn a_session_past_its_idle_window_no_longer_resolves() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool.clone());
let issued = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let long_ago = Utc::now() - chrono::Duration::hours(3);
set_last_seen(&pool, &issued.token, long_ago).await;
assert!(matches!(
svc.resolve_session(&issued.token).await,
Err(AuthError::SessionInvalid)
));
}
#[tokio::test]
async fn a_remember_cookie_is_single_use_and_rotates() {
let (pool, _guard) = test_db().await;
let uid = seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let first = svc.issue_remember(uid).await.unwrap();
let recalled = svc
.consume_remember(&first.cookie, &RequestContext::default())
.await
.unwrap();
assert_ne!(
recalled.remember.cookie, first.cookie,
"using a credential must replace its secret"
);
assert_eq!(
recalled.remember.cookie.split_once(':').unwrap().0,
first.cookie.split_once(':').unwrap().0,
"the selector is the series and must survive rotation"
);
svc.verify_session(&recalled.session.token).await.unwrap();
assert!(matches!(
svc.consume_remember(&first.cookie, &RequestContext::default())
.await,
Err(AuthError::SessionInvalid)
));
}
#[tokio::test]
async fn a_stale_secret_drops_every_credential_the_user_holds() {
let (pool, _guard) = test_db().await;
let uid = seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let stolen = svc.issue_remember(uid).await.unwrap();
let other_device = svc.issue_remember(uid).await.unwrap();
let owner = svc
.consume_remember(&stolen.cookie, &RequestContext::default())
.await
.unwrap();
let selector = stolen.cookie.split_once(':').unwrap().0;
let forged = format!("{selector}:{}", generate_token());
assert!(matches!(
svc.consume_remember(&forged, &RequestContext::default())
.await,
Err(AuthError::SessionInvalid)
));
for credential in [owner.remember.cookie, other_device.cookie] {
assert!(matches!(
svc.consume_remember(&credential, &RequestContext::default())
.await,
Err(AuthError::SessionInvalid)
));
}
}
#[tokio::test]
async fn the_sessions_list_marks_the_one_asking() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let first = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let second = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let listed = svc
.list_sessions(first.user_id, &second.token)
.await
.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed.iter().filter(|s| s.current).count(), 1);
assert!(listed.iter().all(|s| !s.id.is_empty()));
}
#[tokio::test]
async fn one_account_cannot_revoke_anothers_session() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
seed_user(&pool, "mallory", "hunter2", false).await;
let svc = service(pool);
let victim = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let attacker = svc
.authenticate("mallory", "hunter2", &RequestContext::default())
.await
.unwrap();
let victims_id = svc
.list_sessions(victim.user_id, &victim.token)
.await
.unwrap()
.remove(0)
.id;
assert!(
!svc.revoke_session(attacker.user_id, &victims_id)
.await
.unwrap(),
"a revoke is scoped to its own account"
);
svc.verify_session(&victim.token).await.unwrap();
}
#[tokio::test]
async fn signing_out_everywhere_keeps_this_one_and_drops_credentials() {
let (pool, _guard) = test_db().await;
let uid = seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let keep = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let other = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let credential = svc.issue_remember(uid).await.unwrap();
let ended = svc.sign_out_everywhere(uid, &keep.token).await.unwrap();
assert_eq!(ended, 1);
svc.verify_session(&keep.token).await.unwrap();
assert!(matches!(
svc.verify_session(&other.token).await,
Err(AuthError::SessionInvalid)
));
assert!(matches!(
svc.consume_remember(&credential.cookie, &RequestContext::default())
.await,
Err(AuthError::SessionInvalid)
));
}
#[tokio::test]
async fn revoking_a_credential_ends_it() {
let (pool, _guard) = test_db().await;
let uid = seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let credential = svc.issue_remember(uid).await.unwrap();
svc.revoke_remember(&credential.cookie).await.unwrap();
assert!(matches!(
svc.consume_remember(&credential.cookie, &RequestContext::default())
.await,
Err(AuthError::SessionInvalid)
));
}
#[tokio::test]
async fn deactivating_blocks_sign_in_and_ends_the_sessions() {
let (pool, _guard) = test_db().await;
let admin = seed_user(&pool, "root", "hunter2", true).await;
let target = seed_user(&pool, "editor", "hunter2", false).await;
let svc = service(pool.clone());
let issued = svc
.authenticate("editor", "hunter2", &RequestContext::default())
.await
.unwrap();
let credential = svc.issue_remember(target).await.unwrap();
svc.set_user_active(admin, target, false).await.unwrap();
assert!(matches!(
svc.authenticate("editor", "hunter2", &RequestContext::default())
.await,
Err(AuthError::InactiveAccount)
));
assert!(matches!(
svc.verify_session(&issued.token).await,
Err(AuthError::SessionInvalid)
));
assert!(matches!(
svc.consume_remember(&credential.cookie, &RequestContext::default())
.await,
Err(AuthError::SessionInvalid)
));
}
#[tokio::test]
async fn reactivating_lets_them_back_in() {
let (pool, _guard) = test_db().await;
let admin = seed_user(&pool, "root", "hunter2", true).await;
let target = seed_user(&pool, "editor", "hunter2", false).await;
let svc = service(pool);
svc.set_user_active(admin, target, false).await.unwrap();
svc.set_user_active(admin, target, true).await.unwrap();
svc.authenticate("editor", "hunter2", &RequestContext::default())
.await
.unwrap();
}
#[tokio::test]
async fn an_operator_cannot_deactivate_themselves() {
let (pool, _guard) = test_db().await;
let admin = seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
assert!(matches!(
svc.set_user_active(admin, admin, false).await,
Err(AuthError::Refused(_))
));
}
#[tokio::test]
async fn the_last_superuser_cannot_be_deactivated() {
let (pool, _guard) = test_db().await;
let only = seed_user(&pool, "root", "hunter2", true).await;
let editor = seed_user(&pool, "editor", "hunter2", false).await;
let svc = service(pool.clone());
assert!(matches!(
svc.set_user_active(editor, only, false).await,
Err(AuthError::Refused(_))
));
let second = seed_user(&pool, "root2", "hunter2", true).await;
svc.set_user_active(second, only, false).await.unwrap();
}
#[tokio::test]
async fn audit_entries_record_and_read_back_newest_first() {
let (pool, _guard) = test_db().await;
let actor = seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
svc.record_audit(AuditEntry {
actor_user_id: Some(actor),
actor_username: "root",
action: "backend.role.update",
target_type: Some("backend_role"),
target_id: Some("42"),
target_label: Some("Editors"),
detail: Some(r#"{"added":["a.b"]}"#),
})
.await
.unwrap();
svc.record_audit(AuditEntry {
actor_user_id: Some(actor),
actor_username: "root",
action: "backend.plugin.disable",
target_type: Some("plugin"),
target_id: Some("acme.widgets"),
target_label: None,
detail: None,
})
.await
.unwrap();
let entries = svc.recent_audit(10).await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].action, "backend.plugin.disable");
assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
assert_eq!(entries[0].detail, None);
assert_eq!(entries[1].action, "backend.role.update");
assert_eq!(entries[1].actor_username, "root");
assert_eq!(entries[1].actor_user_id, Some(actor));
assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
}
#[tokio::test]
async fn authenticate_issues_a_verifiable_session() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let session = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.expect("login should succeed");
let identity = svc
.verify_session(&session.token)
.await
.expect("session should resolve");
assert_eq!(identity.user.username, "root");
assert_eq!(identity.user.full_name(), "Test Operator");
assert!(identity.allows("anything.superuser.can.do"));
}
#[tokio::test]
async fn session_data_blob_round_trips() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", true).await;
let svc = service(pool);
let session = svc
.authenticate("root", "hunter2", &RequestContext::default())
.await
.unwrap();
let resolved = svc.resolve_session(&session.token).await.unwrap();
assert_eq!(resolved.data, None);
assert_eq!(resolved.identity.user.username, "root");
svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
.await
.unwrap();
let resolved = svc.resolve_session(&session.token).await.unwrap();
assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
}
#[tokio::test]
async fn username_is_case_insensitive_across_backends() {
let (pool, _guard) = test_db().await;
let hash = password::hash_password("pw").unwrap();
store::create_user(
&pool,
"Root",
"Root@Example.test",
"Case",
None,
&hash,
true,
)
.await
.unwrap();
let svc = service(pool);
let session = svc
.authenticate("ROOT", "pw", &RequestContext::default())
.await
.expect("case-varied login should resolve to the same account");
let identity = svc.verify_session(&session.token).await.unwrap();
assert_eq!(identity.user.username, "root");
assert_eq!(identity.user.email, "root@example.test");
}
#[tokio::test]
async fn full_name_falls_back_to_first_name_when_last_is_absent() {
let (pool, _guard) = test_db().await;
let hash = password::hash_password("pw").unwrap();
store::create_user(
&pool,
"mono",
"mono@example.test",
"Prakash",
None,
&hash,
true,
)
.await
.unwrap();
let svc = service(pool);
let session = svc
.authenticate("mono", "pw", &RequestContext::default())
.await
.unwrap();
let identity = svc.verify_session(&session.token).await.unwrap();
assert_eq!(identity.user.full_name(), "Prakash");
}
#[tokio::test]
async fn operator_timezone_round_trips_and_clears() {
let (pool, _guard) = test_db().await;
let id = seed_user(&pool, "tz", "pw", true).await;
let user = store::find_active_user_by_id(&pool, id)
.await
.unwrap()
.unwrap();
assert_eq!(user.timezone, None);
let svc = service(pool.clone());
svc.set_user_timezone(id, Some("Asia/Kolkata"))
.await
.unwrap();
let user = store::find_active_user_by_id(&pool, id)
.await
.unwrap()
.unwrap();
assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
svc.set_user_timezone(id, None).await.unwrap();
let user = store::find_active_user_by_id(&pool, id)
.await
.unwrap()
.unwrap();
assert_eq!(user.timezone, None);
}
#[tokio::test]
async fn operator_locale_round_trips_and_clears() {
let (pool, _guard) = test_db().await;
let id = seed_user(&pool, "loc", "pw", true).await;
let user = store::find_active_user_by_id(&pool, id)
.await
.unwrap()
.unwrap();
assert_eq!(user.locale, None);
let svc = service(pool.clone());
svc.set_user_locale(id, Some("kn")).await.unwrap();
let user = store::find_active_user_by_id(&pool, id)
.await
.unwrap()
.unwrap();
assert_eq!(user.locale.as_deref(), Some("kn"));
svc.set_user_locale(id, None).await.unwrap();
let user = store::find_active_user_by_id(&pool, id)
.await
.unwrap()
.unwrap();
assert_eq!(user.locale, None);
}
#[tokio::test]
async fn has_any_operator_flips_after_the_first_account() {
let (pool, _guard) = test_db().await;
let svc = service(pool);
assert!(!svc.has_any_operator().await.unwrap());
svc.create_superuser(NewOperator {
username: "first",
email: "first@example.test",
first_name: "First",
last_name: None,
password: "hunter2-hunter2",
timezone: Some("Asia/Kolkata"),
})
.await
.unwrap();
assert!(svc.has_any_operator().await.unwrap());
let session = svc
.authenticate("first", "hunter2-hunter2", &RequestContext::default())
.await
.unwrap();
let identity = svc.verify_session(&session.token).await.unwrap();
assert!(identity.allows("anything.a.superuser.can.do"));
assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
}
#[tokio::test]
async fn wrong_password_is_rejected() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", false).await;
let svc = service(pool);
let err = svc
.authenticate("root", "wrong", &RequestContext::default())
.await
.unwrap_err();
assert!(matches!(err, AuthError::InvalidCredentials));
}
#[tokio::test]
async fn unknown_user_is_rejected_without_distinction() {
let (pool, _guard) = test_db().await;
let svc = service(pool);
let err = svc
.authenticate("ghost", "whatever", &RequestContext::default())
.await
.unwrap_err();
assert!(matches!(err, AuthError::InvalidCredentials));
}
#[tokio::test]
async fn lockout_trips_after_max_failures() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "hunter2", false).await;
let svc = AuthService::new(
pool,
AuthConfig {
max_failures: 3,
..AuthConfig::default()
},
);
let ctx = RequestContext::default();
for _ in 0..3 {
let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
assert!(matches!(err, AuthError::InvalidCredentials));
}
let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
assert!(matches!(err, AuthError::TooManyAttempts));
}
#[tokio::test]
async fn permissions_come_from_assigned_roles() {
let (pool, _guard) = test_db().await;
let user_id = seed_user(&pool, "mod", "pw", false).await;
let role_id = store::create_role(
&pool,
"content_editor",
"Content Editor",
&["posts.*".to_string()],
)
.await
.unwrap();
store::assign_role(&pool, user_id, role_id).await.unwrap();
let svc = service(pool);
let session = svc
.authenticate("mod", "pw", &RequestContext::default())
.await
.unwrap();
let identity = svc.verify_session(&session.token).await.unwrap();
assert!(identity.allows("posts.approve"));
assert!(!identity.allows("users.edit"));
identity.require("posts.edit").unwrap();
assert!(identity.require("users.edit").is_err());
}
#[tokio::test]
async fn logout_invalidates_the_session() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "pw", true).await;
let svc = service(pool);
let session = svc
.authenticate("root", "pw", &RequestContext::default())
.await
.unwrap();
svc.verify_session(&session.token).await.unwrap();
svc.logout(&session.token).await.unwrap();
let err = svc.verify_session(&session.token).await.unwrap_err();
assert!(matches!(err, AuthError::SessionInvalid));
}
#[tokio::test]
async fn inactive_account_is_refused_after_correct_password() {
let (pool, _guard) = test_db().await;
let user_id = seed_user(&pool, "root", "pw", false).await;
let (sql, values) = laterite_core::query::build(
pool.backend,
sea_query::Query::update()
.table(crate::schema::BackendUsers::Table)
.value(crate::schema::BackendUsers::IsActive, false)
.and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
.to_owned(),
);
laterite_core::query::bind_values(sqlx::query(&sql), values)
.execute(&pool.pool)
.await
.unwrap();
let svc = service(pool);
let err = svc
.authenticate("root", "pw", &RequestContext::default())
.await
.unwrap_err();
assert!(matches!(err, AuthError::InactiveAccount));
}
#[tokio::test]
async fn reset_password_updates_the_hash() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "oldpw", true).await;
let new_hash = password::hash_password("newpw").unwrap();
let affected = store::update_password_by_username(&pool, "root", &new_hash)
.await
.unwrap();
assert_eq!(affected, 1);
let svc = service(pool);
let ctx = RequestContext::default();
assert!(matches!(
svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
AuthError::InvalidCredentials
));
svc.authenticate("root", "newpw", &ctx).await.unwrap();
}
#[tokio::test]
async fn reset_password_reports_unknown_user() {
let (pool, _guard) = test_db().await;
let hash = password::hash_password("x").unwrap();
let affected = store::update_password_by_username(&pool, "ghost", &hash)
.await
.unwrap();
assert_eq!(affected, 0);
}
#[tokio::test]
async fn list_users_returns_all_seeded() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "alice", "pw", true).await;
seed_user(&pool, "bob", "pw", false).await;
let users = store::list_backend_users(&pool).await.unwrap();
assert_eq!(users.len(), 2);
assert!(users
.iter()
.any(|u| u.username == "alice" && u.is_superuser));
assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
}
#[tokio::test]
async fn unlock_clears_the_lockout() {
let (pool, _guard) = test_db().await;
seed_user(&pool, "root", "pw", false).await;
let svc = AuthService::new(
pool.clone(),
AuthConfig {
max_failures: 3,
..AuthConfig::default()
},
);
let ctx = RequestContext::default();
for _ in 0..3 {
let _ = svc.authenticate("root", "bad", &ctx).await;
}
assert!(matches!(
svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
AuthError::TooManyAttempts
));
let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
assert!(cleared >= 3);
svc.authenticate("root", "pw", &ctx).await.unwrap();
}
}