freebird_verifier/
store.rs1use anyhow::{Context, Result};
4use async_trait::async_trait;
5use redis::{aio::ConnectionLike, AsyncCommands, Script};
6use std::{
7 collections::HashMap,
8 sync::Arc,
9 time::{Duration, Instant},
10};
11use subtle::ConstantTimeEq;
12use tokio::sync::RwLock;
13use tracing::{debug, info, warn};
14
15const LUA_MARK_SPENT: &str = r#"
20 -- KEYS[1] = token_key, ARGV[1] = ttl (seconds)
21 if redis.call('SETNX', KEYS[1], '1') == 1 then
22 redis.call('EXPIRE', KEYS[1], ARGV[1])
23 return 1
24 else
25 return 0
26 end
27"#;
28
29pub async fn has_been_spent<C: ConnectionLike + Send>(
33 conn: &mut C,
34 token_key: &str,
35) -> Result<bool> {
36 let v: Option<String> = conn.get(token_key).await?;
37 Ok(v.is_some())
38}
39
40pub async fn mark_spent_atomic<C: ConnectionLike + Send>(
41 conn: &mut C,
42 token_key: &str,
43 ttl_seconds: usize,
44) -> Result<bool> {
45 let script = Script::new(LUA_MARK_SPENT);
46 let res: i32 = script
47 .key(token_key)
48 .arg(ttl_seconds)
49 .invoke_async(conn)
50 .await
51 .context("invoke redis lua")?;
52 Ok(res == 1)
53}
54
55#[async_trait]
59pub trait SpendStore: Send + Sync {
60 async fn mark_spent(&self, key: &str, ttl: Duration) -> Result<bool>;
64}
65
66fn constant_time_eq(a: &str, b: &str) -> bool {
71 if a.len() != b.len() {
72 return false;
73 }
74 bool::from(a.as_bytes().ct_eq(b.as_bytes()))
75}
76
77#[derive(Default)]
81pub struct InMemoryStore {
82 map: Arc<RwLock<HashMap<String, Instant>>>,
83}
84
85#[async_trait]
86impl SpendStore for InMemoryStore {
87 async fn mark_spent(&self, key: &str, ttl: Duration) -> Result<bool> {
88 let mut map = self.map.write().await;
89 let now = Instant::now();
90
91 map.retain(|_, &mut exp| exp > now);
93
94 let mut found = false;
97 for stored_key in map.keys() {
98 if constant_time_eq(stored_key, key) {
99 found = true;
100 break;
101 }
102 }
103
104 if found {
105 debug!(%key, "replay detected (in-memory)");
106 Ok(false)
107 } else {
108 map.insert(key.to_owned(), now + ttl);
109 debug!(%key, ttl=?ttl, "marked spent (in-memory)");
110 Ok(true)
111 }
112 }
113}
114
115pub struct RedisStore {
119 client: redis::Client,
120}
121
122impl RedisStore {
123 pub fn new(url: &str) -> Result<Self> {
124 let client =
125 redis::Client::open(url).with_context(|| format!("connect redis @ {}", url))?;
126 Ok(Self { client })
127 }
128
129 async fn get_conn(&self) -> Result<redis::aio::Connection> {
130 let mut backoff_ms = 200u64;
131 for attempt in 1..=3 {
132 match self.client.get_async_connection().await {
133 Ok(conn) => return Ok(conn),
134 Err(e) if attempt < 3 => {
135 warn!(
136 attempt,
137 "redis connect failed: {e}; retrying in {backoff_ms}ms"
138 );
139 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
140 backoff_ms *= 2;
141 }
142 Err(e) => return Err(e.into()),
143 }
144 }
145 unreachable!()
146 }
147}
148
149#[async_trait]
150impl SpendStore for RedisStore {
151 async fn mark_spent(&self, key: &str, ttl: Duration) -> Result<bool> {
152 let ttl_secs = ttl.as_secs().max(1) as usize;
153 let mut conn = self.get_conn().await?;
154 let fresh = mark_spent_atomic(&mut conn, key, ttl_secs).await?;
155
156 if fresh {
157 info!(%key, ttl = %ttl_secs, "marked spent (redis)");
158 } else {
159 warn!(%key, "replay detected (redis)");
160 }
161 Ok(fresh)
162 }
163}
164
165pub enum StoreBackend {
169 InMemory,
170 Redis(String),
171}
172
173impl StoreBackend {
174 pub async fn build(self) -> Arc<dyn SpendStore> {
175 match self {
176 StoreBackend::InMemory => {
177 info!("using InMemory spend store");
178 Arc::new(InMemoryStore::default())
179 }
180 StoreBackend::Redis(url) => {
181 info!(%url, "using Redis spend store");
182 Arc::new(RedisStore::new(&url).expect("connect redis"))
183 }
184 }
185 }
186}