use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use argon2::Argon2;
use argon2::password_hash::phc::PasswordHash;
use argon2::password_hash::{PasswordHasher, PasswordVerifier};
use async_trait::async_trait;
use axum::extract::{ConnectInfo, FromRequestParts};
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use axum::http::{HeaderMap, HeaderName};
use axum::response::{IntoResponse, Redirect, Response};
use axum_extra::extract::CookieJar;
use hmac::{Hmac, KeyInit, Mac};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use sha2::Digest;
use crate::AdminState;
use crate::access::{Access, Action};
use crate::error::{AdminError, ApiError};
pub(crate) const COOKIE: &str = "admin_token";
const MIN_SECRET_BYTES: usize = 32;
const TOTP_STEP_SECS: u64 = 30;
#[derive(Clone)]
pub struct AuthConfig {
jwt_secret: Vec<u8>,
pub(crate) token_ttl_secs: u64,
pub(crate) totp_required: bool,
pub(crate) cookie_secure: bool,
pub(crate) login_max_attempts: u32,
pub(crate) login_lockout_secs: u64,
pub(crate) client_ip_header: Option<HeaderName>,
pub(crate) methods: Arc<crate::oauth::Methods>,
pub(crate) api_keys: bool,
}
impl std::fmt::Debug for AuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthConfig")
.field("jwt_secret", &"<redacted>")
.field("token_ttl_secs", &self.token_ttl_secs)
.field("totp_required", &self.totp_required)
.field("cookie_secure", &self.cookie_secure)
.field("login_max_attempts", &self.login_max_attempts)
.field("login_lockout_secs", &self.login_lockout_secs)
.field("client_ip_header", &self.client_ip_header)
.field("password_login", &self.methods.password)
.field("api_keys", &self.api_keys)
.field(
"providers",
&self
.methods
.providers
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>(),
)
.finish()
}
}
impl AuthConfig {
pub fn new(jwt_secret: impl Into<Vec<u8>>) -> Result<Self, AdminError> {
let jwt_secret = jwt_secret.into();
if jwt_secret.len() < MIN_SECRET_BYTES {
return Err(AdminError::Config(format!(
"the JWT secret must be at least {MIN_SECRET_BYTES} bytes"
)));
}
Ok(Self {
jwt_secret,
token_ttl_secs: 3600,
totp_required: false,
cookie_secure: true,
login_max_attempts: 5,
login_lockout_secs: 300,
client_ip_header: None,
methods: Arc::new(crate::oauth::Methods::password_only()),
api_keys: true,
})
}
pub fn from_env() -> Result<Self, AdminError> {
Self::from_lookup(|key| std::env::var(key).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AdminError> {
let secret = lookup("ADMIN_JWT_SECRET")
.ok_or_else(|| AdminError::Config("ADMIN_JWT_SECRET is not set".into()))?;
let mut config = Self::new(secret)?;
if let Some(v) = lookup("ADMIN_JWT_TTL_SECS") {
config.token_ttl_secs = v.parse::<u64>().ok().filter(|n| *n > 0).ok_or_else(|| {
AdminError::Config("ADMIN_JWT_TTL_SECS must be a positive integer".into())
})?;
}
if let Some(v) = lookup("ADMIN_TOTP_REQUIRED") {
config.totp_required = parse_bool("ADMIN_TOTP_REQUIRED", &v)?;
}
if let Some(v) = lookup("ADMIN_COOKIE_SECURE") {
config.cookie_secure = parse_bool("ADMIN_COOKIE_SECURE", &v)?;
}
if let Some(v) = lookup("ADMIN_LOGIN_MAX_ATTEMPTS") {
config.login_max_attempts = v.parse().map_err(|_| {
AdminError::Config("ADMIN_LOGIN_MAX_ATTEMPTS must be a non-negative integer".into())
})?;
}
if let Some(v) = lookup("ADMIN_LOGIN_LOCKOUT_SECS") {
config.login_lockout_secs =
v.parse::<u64>().ok().filter(|n| *n > 0).ok_or_else(|| {
AdminError::Config("ADMIN_LOGIN_LOCKOUT_SECS must be a positive integer".into())
})?;
}
if let Some(v) = lookup("ADMIN_CLIENT_IP_HEADER").filter(|v| !v.is_empty()) {
config = config.client_ip_header(&v)?;
}
Ok(config)
}
#[must_use]
pub fn token_ttl_secs(mut self, secs: u64) -> Self {
self.token_ttl_secs = secs.max(1);
self
}
#[must_use]
pub fn totp_required(mut self, required: bool) -> Self {
self.totp_required = required;
self
}
#[must_use]
pub fn login_limit(mut self, max_attempts: u32, lockout_secs: u64) -> Self {
self.login_max_attempts = max_attempts;
self.login_lockout_secs = lockout_secs.max(1);
self
}
pub fn client_ip_header(mut self, name: &str) -> Result<Self, AdminError> {
let name = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| AdminError::Config(format!("`{name}` is not a valid header name")))?;
self.client_ip_header = Some(name);
Ok(self)
}
pub(crate) fn client_ip(&self, parts: &Parts) -> Option<IpAddr> {
let forwarded = self.client_ip_header.as_ref().and_then(|name| {
let value = parts.headers.get(name)?.to_str().ok()?;
value.rsplit(',').next()?.trim().parse().ok()
});
forwarded.or_else(|| {
parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|peer| peer.0.ip())
})
}
#[must_use]
pub fn api_keys(mut self, enabled: bool) -> Self {
self.api_keys = enabled;
self
}
#[must_use]
pub fn cookie_secure(mut self, secure: bool) -> Self {
self.cookie_secure = secure;
self
}
pub(crate) fn issue(&self, username: &str) -> Result<String, AdminError> {
let now = unix_now();
let claims = Claims {
sub: username.to_owned(),
iat: now,
exp: now + self.token_ttl_secs,
csrf: rand::random::<[u8; 16]>()
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
};
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(&self.jwt_secret),
)
.map_err(|e| AdminError::Config(format!("could not sign token: {e}")))
}
pub(crate) fn with_methods(mut self, methods: crate::oauth::Methods) -> Self {
self.methods = Arc::new(methods);
self
}
fn purpose_key(&self, purpose: &str) -> Vec<u8> {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&self.jwt_secret)
.expect("HMAC accepts keys of any length");
mac.update(b"heraapi:");
mac.update(purpose.as_bytes());
mac.finalize().into_bytes().to_vec()
}
pub(crate) fn seal<T: Serialize>(
&self,
purpose: &str,
claims: &T,
) -> Result<String, AdminError> {
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
claims,
&EncodingKey::from_secret(&self.purpose_key(purpose)),
)
.map_err(|e| AdminError::Config(format!("could not sign token: {e}")))
}
pub(crate) fn open<T: serde::de::DeserializeOwned>(
&self,
purpose: &str,
token: &str,
) -> Option<T> {
jsonwebtoken::decode::<T>(
token,
&DecodingKey::from_secret(&self.purpose_key(purpose)),
&Validation::new(Algorithm::HS256),
)
.ok()
.map(|data| data.claims)
}
fn verify(&self, token: &str) -> Option<Claims> {
jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(&self.jwt_secret),
&Validation::new(Algorithm::HS256),
)
.ok()
.map(|data| data.claims)
}
}
fn parse_bool(key: &str, value: &str) -> Result<bool, AdminError> {
match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(AdminError::Config(format!("{key} must be true or false"))),
}
}
#[derive(Deserialize)]
pub(crate) struct Credentials {
pub(crate) username: String,
pub(crate) password: String,
#[serde(default)]
pub(crate) totp: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
iat: u64,
exp: u64,
csrf: String,
}
#[derive(Clone)]
pub struct AdminUser {
pub username: String,
pub password_hash: String,
pub totp_secret: Option<String>,
}
#[async_trait]
pub trait UserStore: Send + Sync {
async fn find(&self, username: &str) -> Result<Option<AdminUser>, AdminError>;
async fn access(&self, username: &str) -> Result<Option<Access>, AdminError> {
Ok(self.find(username).await?.map(|_| Access::superuser()))
}
async fn logged_in(&self, _username: &str) -> Result<(), AdminError> {
Ok(())
}
async fn accept_totp_step(&self, _username: &str, _step: u64) -> Result<bool, AdminError> {
Ok(true)
}
async fn use_recovery_code(&self, _username: &str, _digest: &str) -> Result<bool, AdminError> {
Ok(false)
}
async fn api_key(&self, _digest: &str) -> Result<Option<ApiKeyOwner>, AdminError> {
Ok(None)
}
}
#[derive(Clone, Debug)]
pub struct ApiKeyOwner {
pub username: String,
pub read_only: bool,
}
#[derive(Clone)]
pub struct EnvUserStore {
user: AdminUser,
last_totp_step: Arc<AtomicU64>,
}
impl EnvUserStore {
pub fn from_env() -> Result<Self, AdminError> {
Self::from_lookup(|key| std::env::var(key).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AdminError> {
let required =
|key: &str| lookup(key).ok_or_else(|| AdminError::Config(format!("{key} is not set")));
Self::new(AdminUser {
username: required("ADMIN_USERNAME")?,
password_hash: required("ADMIN_PASSWORD_HASH")?,
totp_secret: lookup("ADMIN_TOTP_SECRET"),
})
}
pub fn new(user: AdminUser) -> Result<Self, AdminError> {
PasswordHash::new(&user.password_hash).map_err(|_| {
AdminError::Config("the password hash is not an Argon2 PHC string".into())
})?;
if let Some(secret) = &user.totp_secret {
decode_secret(secret)
.ok_or_else(|| AdminError::Config("the TOTP secret is not valid base32".into()))?;
}
Ok(Self {
user,
last_totp_step: Arc::new(AtomicU64::new(0)),
})
}
}
#[async_trait]
impl UserStore for EnvUserStore {
async fn find(&self, username: &str) -> Result<Option<AdminUser>, AdminError> {
Ok((username == self.user.username).then(|| self.user.clone()))
}
async fn accept_totp_step(&self, _username: &str, step: u64) -> Result<bool, AdminError> {
Ok(self.last_totp_step.fetch_max(step, Ordering::SeqCst) < step)
}
}
pub async fn hash_password_async(password: &str) -> Result<String, AdminError> {
let password = password.to_owned();
tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| AdminError::Config(format!("password hash task failed: {e}")))?
}
pub fn hash_password(password: &str) -> Result<String, AdminError> {
Argon2::default()
.hash_password(password.as_bytes())
.map(|hash| hash.to_string())
.map_err(|e| AdminError::Config(format!("could not hash password: {e}")))
}
fn password_matches(password: &str, phc: &str) -> bool {
PasswordHash::new(phc).is_ok_and(|hash| {
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok()
})
}
fn decoy_hash() -> &'static str {
static HASH: OnceLock<String> = OnceLock::new();
HASH.get_or_init(|| hash_password("no account has this password").unwrap_or_default())
}
pub(crate) async fn login(
state: &AdminState,
credentials: &Credentials,
address: Option<IpAddr>,
) -> Result<String, AdminError> {
if !state.auth.methods.password {
return Err(AdminError::Forbidden(
"password login is turned off on this site".into(),
));
}
let typed = credentials.username.trim();
let user = state.users.find(typed).await?;
let key = user
.as_ref()
.map_or_else(|| typed.to_lowercase(), |u| u.username.clone());
state
.limiter
.attempt(&key, address)
.map_err(|retry_after_secs| {
tracing::warn!(login = %typed, ?address, "login attempts exhausted");
AdminError::RateLimited { retry_after_secs }
})?;
let candidate = credentials.password.clone();
let hash = user
.as_ref()
.map_or_else(|| decoy_hash().to_owned(), |u| u.password_hash.clone());
let matches = tokio::task::spawn_blocking(move || password_matches(&candidate, &hash))
.await
.map_err(|e| AdminError::Config(format!("password check task failed: {e}")))?;
let Some(user) = user.filter(|_| matches) else {
return Err(AdminError::Unauthorized);
};
match &user.totp_secret {
Some(secret) => {
let code = credentials.totp.as_deref().unwrap_or_default();
if !second_factor(state, &user.username, secret, code).await? {
return Err(AdminError::Unauthorized);
}
}
None if state.auth.totp_required => return Err(AdminError::Unauthorized),
None => {}
}
state.limiter.succeeded(&key, address);
state.users.logged_in(&user.username).await?;
state.auth.issue(&user.username)
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn decode_secret(base32_secret: &str) -> Option<Vec<u8>> {
let cleaned: String = base32_secret
.chars()
.filter(|c| !c.is_whitespace() && *c != '=')
.collect();
base32::decode(
base32::Alphabet::Rfc4648 { padding: false },
&cleaned.to_ascii_uppercase(),
)
.filter(|bytes| !bytes.is_empty())
}
fn hotp(secret: &[u8], counter: u64) -> Option<u32> {
let mut mac = Hmac::<Sha1>::new_from_slice(secret).ok()?;
mac.update(&counter.to_be_bytes());
let digest = mac.finalize().into_bytes();
let offset = usize::from(digest.last()? & 0x0f);
let chunk: [u8; 4] = digest.get(offset..offset + 4)?.try_into().ok()?;
Some((u32::from_be_bytes(chunk) & 0x7fff_ffff) % 1_000_000)
}
#[must_use]
pub fn verify_totp(base32_secret: &str, code: &str, now_unix: u64) -> bool {
totp_step(base32_secret, code, now_unix).is_some()
}
pub(crate) async fn confirm_password(
state: &AdminState,
username: &str,
password: &str,
) -> Result<bool, AdminError> {
state
.limiter
.attempt(username, None)
.map_err(|retry_after_secs| AdminError::RateLimited { retry_after_secs })?;
let Some(user) = state.users.find(username).await? else {
return Ok(false);
};
let (candidate, hash) = (password.to_owned(), user.password_hash);
let matches = tokio::task::spawn_blocking(move || password_matches(&candidate, &hash))
.await
.map_err(|e| AdminError::Config(format!("password check task failed: {e}")))?;
if matches {
state.limiter.succeeded(username, None);
}
Ok(matches)
}
const RECOVERY_ALPHABET: &[u8] = b"23456789abcdefghjkmnpqrstuvwxyz";
const RECOVERY_CHARS: usize = 16;
pub(crate) const RECOVERY_CODES: usize = 10;
const API_KEY_PREFIX: &str = "cadmus_";
const API_KEY_ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const API_KEY_CHARS: usize = 40;
pub(crate) fn new_api_key() -> (String, String) {
let random: String = (0..API_KEY_CHARS)
.map(|_| char::from(API_KEY_ALPHABET[rand::random_range(0..API_KEY_ALPHABET.len())]))
.collect();
let key = format!("{API_KEY_PREFIX}{random}");
let prefix = key[..API_KEY_PREFIX.len() + 8].to_owned();
(key, prefix)
}
pub(crate) fn api_key_digest(key: &str) -> Option<String> {
let random = key.strip_prefix(API_KEY_PREFIX)?;
if random.len() != API_KEY_CHARS || !random.bytes().all(|b| API_KEY_ALPHABET.contains(&b)) {
return None;
}
let digest = sha2::Sha256::digest(format!("heraapi-api-key:{key}").as_bytes());
Some(digest.iter().map(|b| format!("{b:02x}")).collect())
}
pub(crate) fn new_recovery_codes() -> Vec<String> {
(0..RECOVERY_CODES)
.map(|_| {
let chars: Vec<char> = (0..RECOVERY_CHARS)
.map(|_| {
char::from(RECOVERY_ALPHABET[rand::random_range(0..RECOVERY_ALPHABET.len())])
})
.collect();
chars
.chunks(4)
.map(|group| group.iter().collect::<String>())
.collect::<Vec<_>>()
.join("-")
})
.collect()
}
pub(crate) fn recovery_digest(typed: &str) -> Option<String> {
let normalised: String = typed
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.map(|c| c.to_ascii_lowercase())
.collect();
if normalised.len() != RECOVERY_CHARS
|| !normalised.bytes().all(|b| RECOVERY_ALPHABET.contains(&b))
{
return None;
}
let digest = sha2::Sha256::digest(format!("heraapi-recovery:{normalised}").as_bytes());
Some(digest.iter().map(|b| format!("{b:02x}")).collect())
}
pub(crate) async fn second_factor(
state: &AdminState,
username: &str,
secret: &str,
typed: &str,
) -> Result<bool, AdminError> {
if let Some(step) = totp_step(secret, typed, unix_now()) {
let fresh = state.users.accept_totp_step(username, step).await?;
if !fresh {
tracing::warn!(%username, "authenticator code reused");
}
return Ok(fresh);
}
if let Some(digest) = recovery_digest(typed)
&& state.users.use_recovery_code(username, &digest).await?
{
tracing::warn!(%username, "logged in with a recovery code");
return Ok(true);
}
Ok(false)
}
pub(crate) fn totp_step_now(base32_secret: &str, code: &str) -> Option<u64> {
totp_step(base32_secret, code, unix_now())
}
fn totp_step(base32_secret: &str, code: &str, now_unix: u64) -> Option<u64> {
let secret = decode_secret(base32_secret)?;
let code = code.trim();
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let step = now_unix / TOTP_STEP_SECS;
let mut matched = None;
for counter in [step.saturating_sub(1), step, step.saturating_add(1)] {
if let Some(expected) = hotp(&secret, counter) {
if constant_time_eq(format!("{expected:06}").as_bytes(), code.as_bytes()) {
matched = Some(counter);
}
}
}
matched
}
pub(crate) fn new_totp_secret() -> String {
base32::encode(
base32::Alphabet::Rfc4648 { padding: false },
&rand::random::<[u8; 20]>(),
)
}
pub(crate) fn totp_secret_is_strong(base32_secret: &str) -> bool {
decode_secret(base32_secret).is_some_and(|bytes| bytes.len() >= 10)
}
#[must_use]
pub fn totp_uri(issuer: &str, username: &str, base32_secret: &str) -> String {
let mut uri = url::Url::parse("otpauth://totp/").expect("a constant, valid URL");
uri.set_path(&format!("{issuer}:{username}"));
uri.query_pairs_mut()
.append_pair("secret", base32_secret)
.append_pair("issuer", issuer);
uri.into()
}
pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
pub(crate) struct Session {
pub(crate) username: String,
pub(crate) access: Access,
csrf: String,
via_cookie: bool,
issued_at: u64,
read_only: bool,
}
const FRESH_LOGIN_SECS: u64 = 15 * 60;
impl Session {
pub(crate) fn require_bearer(&self) -> Result<(), AdminError> {
if self.via_cookie {
return Err(AdminError::Forbidden(
"the API requires an Authorization: Bearer token".into(),
));
}
Ok(())
}
async fn load(
state: &AdminState,
claims: Claims,
via_cookie: bool,
) -> Result<Option<Self>, AdminError> {
Ok(state.users.access(&claims.sub).await?.map(|access| Self {
username: claims.sub,
access,
csrf: claims.csrf,
via_cookie,
issued_at: claims.iat,
read_only: false,
}))
}
async fn from_api_key(state: &AdminState, key: &str) -> Result<Option<Self>, AdminError> {
if !state.auth.api_keys {
return Ok(None);
}
let Some(digest) = api_key_digest(key) else {
return Ok(None);
};
let Some(owner) = state.users.api_key(&digest).await? else {
return Ok(None);
};
Ok(state
.users
.access(&owner.username)
.await?
.map(|access| Self {
username: owner.username,
access,
csrf: String::new(),
via_cookie: false,
issued_at: 0,
read_only: owner.read_only,
}))
}
pub(crate) async fn from_cookie(
headers: &HeaderMap,
state: &AdminState,
) -> Result<Option<Self>, AdminError> {
let jar = CookieJar::from_headers(headers);
let claims = jar
.get(COOKIE)
.and_then(|cookie| state.auth.verify(cookie.value()));
match claims {
Some(claims) => Self::load(state, claims, true).await,
None => Ok(None),
}
}
pub(crate) fn is_fresh(&self) -> bool {
unix_now().saturating_sub(self.issued_at) <= FRESH_LOGIN_SECS
}
pub(crate) fn can(&self, model: &str, action: Action) -> bool {
(!self.read_only || action == Action::View) && self.access.allows(model, action)
}
pub(crate) fn require(&self, model: &str, action: Action) -> Result<(), AdminError> {
if self.can(model, action) {
return Ok(());
}
Err(AdminError::Forbidden(format!(
"you do not have permission to {} `{model}`",
action.as_str()
)))
}
pub(crate) fn require_superuser(&self) -> Result<(), AdminError> {
if self.access.is_superuser() && !self.read_only {
return Ok(());
}
Err(AdminError::Forbidden(
"only superusers can manage accounts".into(),
))
}
pub(crate) fn csrf(&self) -> &str {
&self.csrf
}
pub(crate) fn check_csrf(&self, submitted: Option<&str>) -> Result<(), AdminError> {
if !self.via_cookie {
return Ok(());
}
match submitted {
Some(token) if constant_time_eq(token.as_bytes(), self.csrf.as_bytes()) => Ok(()),
_ => Err(AdminError::Forbidden(
"missing or invalid CSRF token".into(),
)),
}
}
}
impl FromRequestParts<Arc<AdminState>> for Session {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AdminState>,
) -> Result<Self, Self::Rejection> {
let bearer = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let api = parts
.uri
.path()
.starts_with(&format!("{}/", state.api_base));
let api_key = bearer
.filter(|token| token.starts_with(API_KEY_PREFIX))
.or_else(|| parts.headers.get("x-api-key").and_then(|v| v.to_str().ok()));
let session = match (api_key, bearer) {
(Some(key), _) if api => Self::from_api_key(state, key).await,
(Some(_), _) => Ok(None),
(None, Some(token)) => match state.auth.verify(token) {
Some(claims) => Self::load(state, claims, false).await,
None => Ok(None),
},
(None, None) => Self::from_cookie(&parts.headers, state).await,
};
let bearer = bearer.or(api_key);
match session {
Ok(Some(session)) => Ok(session),
Ok(None) if bearer.is_some() || api => {
Err(ApiError::from(AdminError::Unauthorized).into_response())
}
Ok(None) => Err(Redirect::to(&format!("{}/login", state.base)).into_response()),
Err(error) if bearer.is_some() || api => Err(ApiError::from(error).into_response()),
Err(error) => Err(error.into_response()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const RFC_SECRET: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
#[test]
fn totp_matches_rfc_6238_vectors() {
assert!(verify_totp(RFC_SECRET, "287082", 59));
assert!(verify_totp(RFC_SECRET, "081804", 1_111_111_109));
assert!(verify_totp(RFC_SECRET, "005924", 1_234_567_890));
}
#[test]
fn totp_rejects_wrong_stale_and_malformed_codes() {
assert!(!verify_totp(RFC_SECRET, "287083", 59));
assert!(!verify_totp(RFC_SECRET, "287082", 59 + 90));
assert!(!verify_totp(RFC_SECRET, "28708", 59));
assert!(!verify_totp(RFC_SECRET, "28708a", 59));
assert!(!verify_totp("not base32 !!", "287082", 59));
}
#[test]
fn token_round_trips_and_rejects_other_secrets() {
let config = AuthConfig::new("0123456789abcdef0123456789abcdef").unwrap();
let token = config.issue("root").unwrap();
assert_eq!(config.verify(&token).unwrap().sub, "root");
let other = AuthConfig::new("ffffffffffffffffffffffffffffffff").unwrap();
assert!(other.verify(&token).is_none());
assert!(config.verify("not.a.token").is_none());
}
#[test]
fn recovery_codes_are_random_readable_and_normalised() {
let codes = new_recovery_codes();
assert_eq!(codes.len(), RECOVERY_CODES);
let mut unique = codes.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), codes.len());
for code in &codes {
assert_eq!(code.len(), 19);
assert_eq!(code.matches('-').count(), 3);
assert!(!code.contains(['0', 'o', '1', 'l', 'i']));
let digest = recovery_digest(code).unwrap();
assert_eq!(recovery_digest(&code.to_uppercase()), Some(digest.clone()));
assert_eq!(
recovery_digest(&code.replace('-', " ")),
Some(digest.clone())
);
assert_eq!(recovery_digest(&code.replace('-', "")), Some(digest));
}
assert_eq!(recovery_digest("123456"), None);
assert_eq!(recovery_digest("aaaa-aaaa-aaaa-aaa0"), None);
assert_eq!(recovery_digest("aaaa-aaaa-aaaa-aaaa-a"), None);
}
#[test]
fn short_secret_is_refused() {
assert!(AuthConfig::new("too short").is_err());
}
#[test]
fn password_hash_verifies_only_the_right_password() {
let hash = hash_password("correct horse").unwrap();
assert!(password_matches("correct horse", &hash));
assert!(!password_matches("wrong", &hash));
assert!(!password_matches("correct horse", "not a hash"));
}
}