use std::sync::Arc;
use std::time::Duration;
use rainier_cache::{Cache, CacheExt};
use rainier_support::{Error, Result};
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct Pending {
code: String,
attempts: u32,
max_attempts: u32,
}
pub struct Challenges {
cache: Arc<dyn Cache>,
prefix: String,
ttl: Duration,
max_attempts: u32,
digits: usize,
}
impl Challenges {
pub fn new(cache: Arc<dyn Cache>) -> Self {
Self {
cache,
prefix: "challenge:".to_string(),
ttl: Duration::from_secs(900),
max_attempts: 5,
digits: 6,
}
}
#[must_use = "this returns a configured issuer rather than configuring in place"]
pub fn lasting(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
#[must_use = "this returns a configured issuer rather than configuring in place"]
pub fn max_attempts(mut self, attempts: u32) -> Self {
self.max_attempts = attempts;
self
}
#[must_use = "this returns a configured issuer rather than configuring in place"]
pub fn digits(mut self, digits: usize) -> Self {
self.digits = digits.clamp(4, 12);
self
}
#[must_use = "this returns a configured issuer rather than configuring in place"]
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn is_shared(&self) -> bool {
self.cache.is_shared()
}
pub async fn issue(&self, subject: impl std::fmt::Display, purpose: &str) -> Result<String> {
let code = self.generate();
self.issue_code(subject, purpose, &code).await?;
Ok(code)
}
pub async fn issue_code(
&self,
subject: impl std::fmt::Display,
purpose: &str,
code: &str,
) -> Result<()> {
let pending =
Pending { code: code.to_string(), attempts: 0, max_attempts: self.max_attempts };
self.cache.put_json(&self.key(subject, purpose), &pending, Some(self.ttl)).await
}
pub async fn consume(
&self,
subject: impl std::fmt::Display + Clone,
purpose: &str,
answer: &str,
) -> Result<()> {
let key = self.key(subject.clone(), purpose);
let Some(mut pending) = self.cache.get_json::<Pending>(&key).await? else {
return Err(Error::unauthorized("That code is not valid."));
};
if pending.code.as_bytes().ct_eq(answer.as_bytes()).into() {
self.cache.forget(&key).await?;
return Ok(());
}
pending.attempts += 1;
if pending.attempts >= pending.max_attempts {
self.cache.forget(&key).await?;
return Err(Error::unauthorized(
"That code is not valid, and there have been too many attempts. Request a new one.",
));
}
let remaining = pending.max_attempts - pending.attempts;
self.cache.put_json(&key, &pending, Some(self.ttl)).await?;
Err(Error::unauthorized(format!(
"That code is not valid. {remaining} attempt(s) remaining."
)))
}
pub async fn is_pending(&self, subject: impl std::fmt::Display, purpose: &str) -> Result<bool> {
Ok(self.cache.get_json::<Pending>(&self.key(subject, purpose)).await?.is_some())
}
pub async fn attempts_remaining(
&self,
subject: impl std::fmt::Display,
purpose: &str,
) -> Result<Option<u32>> {
Ok(self
.cache
.get_json::<Pending>(&self.key(subject, purpose))
.await?
.map(|pending| pending.max_attempts.saturating_sub(pending.attempts)))
}
pub async fn cancel(&self, subject: impl std::fmt::Display, purpose: &str) -> Result<()> {
self.cache.forget(&self.key(subject, purpose)).await?;
Ok(())
}
fn key(&self, subject: impl std::fmt::Display, purpose: &str) -> String {
format!("{}{}:{}", self.prefix, purpose, subject)
}
fn generate(&self) -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..self.digits).map(|_| char::from(b'0' + rng.gen_range(0..10))).collect()
}
}
impl std::fmt::Debug for Challenges {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Challenges")
.field("store", &self.cache.name())
.field("shared", &self.cache.is_shared())
.field("ttl", &self.ttl)
.field("max_attempts", &self.max_attempts)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_cache::MemoryCache;
fn challenges() -> Challenges {
Challenges::new(Arc::new(MemoryCache::new()))
}
#[tokio::test]
async fn a_code_is_issued_and_consumed_once() {
let challenges = challenges();
let code = challenges.issue(42, "email-change").await.unwrap();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
challenges.consume(42, "email-change", &code).await.unwrap();
assert!(challenges.consume(42, "email-change", &code).await.is_err());
}
#[tokio::test]
async fn a_code_is_bound_to_its_purpose() {
let challenges = challenges();
let code = challenges.issue(42, "email-change").await.unwrap();
assert!(challenges.consume(42, "remove-factor", &code).await.is_err());
assert!(challenges.consume(42, "email-change", &code).await.is_ok());
}
#[tokio::test]
async fn a_code_is_bound_to_its_subject() {
let challenges = challenges();
let code = challenges.issue(42, "email-change").await.unwrap();
assert!(challenges.consume(43, "email-change", &code).await.is_err());
}
#[tokio::test]
async fn attempts_are_counted_and_then_it_is_destroyed() {
let challenges = challenges().max_attempts(3);
let code = challenges.issue(42, "verify").await.unwrap();
assert_eq!(challenges.attempts_remaining(42, "verify").await.unwrap(), Some(3));
assert!(challenges.consume(42, "verify", "000000").await.is_err());
assert_eq!(challenges.attempts_remaining(42, "verify").await.unwrap(), Some(2));
assert!(challenges.consume(42, "verify", "000000").await.is_err());
assert!(challenges.consume(42, "verify", "000000").await.is_err());
assert!(!challenges.is_pending(42, "verify").await.unwrap());
assert!(challenges.consume(42, "verify", &code).await.is_err());
}
#[tokio::test]
async fn the_message_says_how_many_attempts_are_left() {
let challenges = challenges().max_attempts(3);
challenges.issue(42, "verify").await.unwrap();
let error = challenges.consume(42, "verify", "000000").await.unwrap_err();
assert!(error.message().contains('2'), "{}", error.message());
}
#[tokio::test]
async fn a_wrong_answer_does_not_consume_the_challenge() {
let challenges = challenges();
let code = challenges.issue(42, "verify").await.unwrap();
assert!(challenges.consume(42, "verify", "000000").await.is_err());
assert!(challenges.consume(42, "verify", &code).await.is_ok());
}
#[tokio::test]
async fn reissuing_replaces_the_outstanding_one() {
let challenges = challenges();
let first = challenges.issue(42, "verify").await.unwrap();
let second = challenges.issue(42, "verify").await.unwrap();
assert!(challenges.consume(42, "verify", &first).await.is_err());
assert!(challenges.consume(42, "verify", &second).await.is_ok());
}
#[tokio::test]
async fn a_challenge_expires_on_its_own() {
let challenges = challenges().lasting(Duration::from_millis(40));
let code = challenges.issue(42, "verify").await.unwrap();
tokio::time::sleep(Duration::from_millis(60)).await;
assert!(!challenges.is_pending(42, "verify").await.unwrap());
assert!(challenges.consume(42, "verify", &code).await.is_err());
}
#[tokio::test]
async fn an_absent_and_an_expired_challenge_answer_identically() {
let challenges = challenges();
let absent = challenges.consume(42, "verify", "000000").await.unwrap_err();
challenges.issue(43, "verify").await.unwrap();
challenges.cancel(43, "verify").await.unwrap();
let used = challenges.consume(43, "verify", "000000").await.unwrap_err();
assert_eq!(absent.message(), used.message());
}
#[tokio::test]
async fn a_supplied_code_is_used_as_given() {
let challenges = challenges();
challenges.issue_code(42, "verify", "let-me-in").await.unwrap();
assert!(challenges.consume(42, "verify", "let-me-in").await.is_ok());
}
#[tokio::test]
async fn cancelling_removes_it() {
let challenges = challenges();
let code = challenges.issue(42, "verify").await.unwrap();
challenges.cancel(42, "verify").await.unwrap();
assert!(!challenges.is_pending(42, "verify").await.unwrap());
assert!(challenges.consume(42, "verify", &code).await.is_err());
}
#[tokio::test]
async fn codes_are_not_predictable() {
let challenges = challenges();
let mut seen = std::collections::HashSet::new();
for subject in 0..50 {
seen.insert(challenges.issue(subject, "verify").await.unwrap());
}
assert!(seen.len() > 45, "only {} distinct codes in 50", seen.len());
}
#[test]
fn the_digit_count_is_clamped_to_something_sensible() {
assert_eq!(challenges().digits(1).generate().len(), 4);
assert_eq!(challenges().digits(100).generate().len(), 12);
assert_eq!(challenges().digits(8).generate().len(), 8);
}
#[tokio::test]
async fn it_reports_whether_it_is_shared() {
assert!(!challenges().is_shared());
}
}