use chrono::{Duration, NaiveDateTime, Utc};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use crate::application::service::digest_error::DigestError;
pub const DIGEST_TOKEN_SECRET_ENV: &str = "DIGEST_TOKEN_SECRET";
pub const TOKEN_TTL_DAYS: i64 = 180;
const DOMAIN: &str = "digest-unsubscribe";
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsubscribeToken {
pub digest_id: Uuid,
pub user_id: Uuid,
pub exp: i64,
pub mac: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenVerdict {
Valid,
Refused,
}
pub const VERIFY_MAX_FAILURES: i32 = 5;
pub const VERIFY_LOCK_SECONDS: i64 = 60;
#[derive(Debug, Default)]
pub struct VerifyFailureBook {
entries: Mutex<HashMap<String, i32>>,
}
impl VerifyFailureBook {
pub fn new() -> Self {
Self::default()
}
fn key(digest_id: Uuid, user_id: Uuid) -> String {
format!("digest-unsub|{digest_id}|{user_id}")
}
pub fn locked(&self, digest_id: Uuid, user_id: Uuid) -> bool {
let n = *self
.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&Self::key(digest_id, user_id))
.unwrap_or(&0);
n >= VERIFY_MAX_FAILURES
}
pub fn register_failure(&self, digest_id: Uuid, user_id: Uuid) {
let mut guard = self.entries.lock().unwrap_or_else(|e| e.into_inner());
*guard.entry(Self::key(digest_id, user_id)).or_insert(0) += 1;
}
pub fn reset(&self, digest_id: Uuid, user_id: Uuid) {
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&Self::key(digest_id, user_id));
}
}
pub struct UnsubscribeService {
pool: PgPool,
secret: Vec<u8>,
failures: Arc<VerifyFailureBook>,
}
fn compute_mac(secret: &[u8], digest_id: &Uuid, user_id: &Uuid, exp: i64) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(format!("{DOMAIN}|{digest_id}|{user_id}|{exp}").as_bytes());
mac.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn verify_mac(secret: &[u8], token: &UnsubscribeToken) -> bool {
let Some(bytes) = hex_bytes(&token.mac) else {
return false;
};
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(format!("{DOMAIN}|{}|{}|{}", token.digest_id, token.user_id, token.exp).as_bytes());
mac.verify_slice(&bytes).is_ok()
}
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
impl UnsubscribeService {
pub fn with_secret(pool: PgPool, secret: &[u8]) -> Self {
Self {
pool,
secret: secret.to_vec(),
failures: Arc::new(VerifyFailureBook::new()),
}
}
pub fn from_env(pool: PgPool) -> Result<Self, DigestError> {
let secret = std::env::var(DIGEST_TOKEN_SECRET_ENV)
.map_err(|_| DigestError::SecretNotConfigured)?;
Ok(Self::with_secret(pool, secret.as_bytes()))
}
pub fn failure_book(&self) -> Arc<VerifyFailureBook> {
self.failures.clone()
}
pub fn mint(&self, digest_id: Uuid, user_id: Uuid, now: impl Into<NaiveDateTime>) -> UnsubscribeToken {
let now: NaiveDateTime = now.into();
let exp_ts = (now + Duration::days(TOKEN_TTL_DAYS)).and_utc().timestamp();
UnsubscribeToken {
digest_id,
user_id,
exp: exp_ts,
mac: compute_mac(&self.secret, &digest_id, &user_id, exp_ts),
}
}
pub fn mint_link_token(&self, digest_id: Uuid, user_id: Uuid) -> String {
let t = self.mint(digest_id, user_id, Utc::now().naive_utc());
format!("{}.{}.{}.{}", t.digest_id, t.user_id, t.exp, t.mac)
}
pub fn parse(token: &str) -> Option<UnsubscribeToken> {
let mut parts = token.split('.');
let digest_id = Uuid::parse_str(parts.next()?).ok()?;
let user_id = Uuid::parse_str(parts.next()?).ok()?;
let exp: i64 = parts.next()?.parse().ok()?;
let mac = parts.next()?.to_string();
if mac.len() != 64 || !mac.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
if parts.next().is_some() {
return None;
}
Some(UnsubscribeToken { digest_id, user_id, exp, mac })
}
pub fn verify(&self, token: &UnsubscribeToken, now: i64) -> TokenVerdict {
if self.failures.locked(token.digest_id, token.user_id) {
return TokenVerdict::Refused;
}
let ok = verify_mac(&self.secret, token)
&& now <= token.exp;
if ok {
self.failures.reset(token.digest_id, token.user_id);
TokenVerdict::Valid
} else {
self.failures.register_failure(token.digest_id, token.user_id);
TokenVerdict::Refused
}
}
pub async fn apply(&self, digest_id: Uuid, user_id: Uuid) -> Result<bool, DigestError> {
let performed = sqlx::query(
r#"UPDATE digest.digest_subscriptions
SET state = 'unsubscribed', unsubscribed_at = NOW()
WHERE digest_id = $1 AND user_id = $2
AND state = 'subscribed'
AND (metadata->>'deleted_at') IS NULL"#,
)
.bind(digest_id)
.bind(user_id)
.execute(&self.pool)
.await?
.rows_affected()
== 1;
if performed {
tracing::info!(
target: "digest::audit",
event = "digest_unsubscribed",
digest_id = %digest_id,
user_id = %user_id,
channel = "rfc8058_one_click"
);
}
Ok(performed)
}
pub async fn resubscribe(&self, digest_id: Uuid, user_id: Uuid) -> Result<bool, DigestError> {
let performed = sqlx::query(
r#"UPDATE digest.digest_subscriptions
SET state = 'subscribed', unsubscribed_at = NULL
WHERE digest_id = $1 AND user_id = $2
AND state = 'unsubscribed'
AND (metadata->>'deleted_at') IS NULL"#,
)
.bind(digest_id)
.bind(user_id)
.execute(&self.pool)
.await?
.rows_affected()
== 1;
Ok(performed)
}
pub async fn unsubscribe_by_token(&self, token_str: &str, now: i64) -> Result<bool, DigestError> {
let Some(token) = Self::parse(token_str) else {
self.failures.register_failure(Uuid::nil(), Uuid::nil());
return Ok(false);
};
match self.verify(&token, now) {
TokenVerdict::Valid => self.apply(token.digest_id, token.user_id).await,
TokenVerdict::Refused => Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn svc() -> UnsubscribeService {
UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").expect("lazy"), b"probe-secret")
}
#[tokio::test]
async fn mint_parse_roundtrip() {
let s = svc();
let d = Uuid::new_v4();
let u = Uuid::new_v4();
let t = s.mint(d, u, Utc::now().naive_utc());
let link = format!("{}.{}.{}.{}", t.digest_id, t.user_id, t.exp, t.mac);
let parsed = UnsubscribeService::parse(&link).expect("parse");
assert_eq!(parsed, t);
}
#[tokio::test]
async fn verify_accepts_fresh_and_refuses_expired() {
let s = svc();
let d = Uuid::new_v4();
let u = Uuid::new_v4();
let now = Utc::now().timestamp();
let t = s.mint(d, u, Utc::now().naive_utc());
assert_eq!(s.verify(&t, now), TokenVerdict::Valid);
assert_eq!(s.verify(&t, t.exp + 1), TokenVerdict::Refused);
}
#[tokio::test]
async fn forged_mac_is_refused_and_locks() {
let s = svc();
let d = Uuid::new_v4();
let u = Uuid::new_v4();
let now = Utc::now().timestamp();
let mut t = s.mint(d, u, Utc::now().naive_utc());
t.mac = "0".repeat(64);
for _ in 0..VERIFY_MAX_FAILURES {
assert_eq!(s.verify(&t, now), TokenVerdict::Refused);
}
assert!(s.failures.locked(d, u));
let good = s.mint(d, u, Utc::now().naive_utc());
assert_eq!(s.verify(&good, now), TokenVerdict::Refused);
}
#[tokio::test]
async fn wrong_secret_is_refused() {
let a = UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").unwrap(), b"one");
let b = UnsubscribeService::with_secret(PgPool::connect_lazy("postgres://x").unwrap(), b"two");
let t = a.mint(Uuid::new_v4(), Uuid::new_v4(), Utc::now().naive_utc());
assert_eq!(b.verify(&t, Utc::now().timestamp()), TokenVerdict::Refused);
}
}