open_chess_server/
auth.rs1use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use anyhow::Result;
16use axum::http::{HeaderMap, header};
17use sha2::{Digest, Sha256};
18
19use crate::db::Db;
20
21pub const TOKEN_PREFIX: &str = "esl_";
22pub const SESSION_COOKIE: &str = "esl_session";
23pub const CACHE_TTL: Duration = Duration::from_secs(15);
24const NEGATIVE_TTL: Duration = Duration::from_secs(5);
25
26pub const SCOPES: &[(&str, &str)] = &[
28 ("board:play", "Play games with the Board API"),
29 ("bot:play", "Play games with the Bot API (bot accounts)"),
30 ("challenge:read", "Read incoming challenges"),
31 ("challenge:write", "Create, accept and decline challenges"),
32 ("email:read", "Read your email address"),
33 ("chess:fairplay", "Review fair-play reports (managers and admins)"),
34];
35
36pub fn hash_key(key: &str) -> String {
37 hex::encode(Sha256::digest(key.as_bytes()))
38}
39
40pub fn random_token(len: usize) -> String {
41 use rand::distr::{Alphanumeric, SampleString};
42 Alphanumeric.sample_string(&mut rand::rng(), len)
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Identity {
47 pub player: i64,
48 pub token_id: i64,
49 pub scopes: Vec<String>,
50 pub admin: bool,
51 pub manager: bool,
52 pub expires_ms: i64,
54}
55
56impl Identity {
57 pub fn has(&self, scope: &str) -> bool {
58 self.scopes.iter().any(|s| s == scope)
59 }
60
61 pub fn has_any(&self, scopes: &[&str]) -> bool {
62 scopes.iter().any(|s| self.has(s))
63 }
64}
65
66type Cache = Arc<Mutex<HashMap<String, (Option<Identity>, Instant)>>>;
67
68#[derive(Clone)]
69pub struct Auth {
70 db: Db,
71 cache: Cache,
72 login: bool,
74}
75
76pub fn bearer(h: &HeaderMap) -> Option<&str> {
77 h.get(header::AUTHORIZATION)
78 .and_then(|v| v.to_str().ok())
79 .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
80 .map(str::trim)
81 .filter(|t| !t.is_empty())
82}
83
84pub fn cookie(h: &HeaderMap, name: &str) -> Option<String> {
85 h.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';')).find_map(
86 |kv| {
87 let (k, v) = kv.trim().split_once('=')?;
88 (k == name).then(|| v.to_owned())
89 },
90 )
91}
92
93impl Auth {
94 pub fn new(db: Db, login: bool) -> Auth {
96 Auth { db, cache: Arc::default(), login }
97 }
98
99 pub async fn token(&self, token: &str) -> Result<Option<Identity>> {
101 if !token.starts_with(TOKEN_PREFIX) {
102 return Ok(None);
103 }
104 let key = hash_key(token);
105 if let Ok(c) = self.cache.lock()
106 && let Some((id, at)) = c.get(&key)
107 {
108 let ttl = if id.is_some() { CACHE_TTL } else { NEGATIVE_TTL };
109 if at.elapsed() < ttl {
110 return Ok(id.clone());
111 }
112 }
113 let c = self.db.conn().await?;
114 let r = c
115 .query_opt(
116 &self.db.sql(
117 "UPDATE site.api_tokens t
118 SET last_used_at = CASE WHEN t.last_used_at IS NULL OR t.last_used_at < now() - interval '1 minute'
119 THEN now() ELSE t.last_used_at END
120 FROM site.players p
121 WHERE t.token_hash = $1 AND p.id = t.player_id AND t.revoked_at IS NULL
122 AND t.expires_at > now() AND p.disabled_at IS NULL
123 RETURNING t.id, t.player_id, t.scopes, p.role, p.roles,
124 (extract(epoch from t.expires_at) * 1000)::bigint",
125 ),
126 &[&key],
127 )
128 .await?;
129 let id = r.map(|r| {
130 let role: String = r.get(3);
131 let roles: Vec<String> = r.get(4);
132 Identity {
133 token_id: r.get(0),
134 player: r.get(1),
135 scopes: r.get(2),
136 admin: role == "admin",
137 manager: roles.iter().any(|x| x == "manager"),
138 expires_ms: r.get(5),
139 }
140 });
141 if let Ok(mut c) = self.cache.lock() {
142 if c.len() > 10_000 {
143 c.clear();
144 }
145 c.insert(key, (id.clone(), Instant::now()));
146 }
147 Ok(id)
148 }
149
150 pub fn forget(&self, token: &str) {
152 if let Ok(mut c) = self.cache.lock() {
153 c.remove(&hash_key(token));
154 }
155 }
156
157 pub async fn session(&self, h: &HeaderMap) -> Result<Option<i64>> {
160 if !self.login {
161 return Ok(None);
162 }
163 let Some(token) = cookie(h, SESSION_COOKIE) else {
164 return Ok(None);
165 };
166 let c = self.db.conn().await?;
167 Ok(c.query_opt(
168 &self.db.sql(
169 "SELECT s.player_id FROM site.sessions s JOIN site.players p ON p.id = s.player_id
170 WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() AND p.disabled_at IS NULL",
171 ),
172 &[&hash_key(&token)],
173 )
174 .await?
175 .map(|r| r.get(0)))
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use axum::http::HeaderValue;
183
184 #[test]
185 fn hashes_like_the_site() {
186 assert_eq!(hash_key("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
188 let t = random_token(36);
189 assert_eq!(t.len(), 36);
190 assert!(t.chars().all(|c| c.is_ascii_alphanumeric()));
191 }
192
193 #[test]
194 fn reads_bearer_and_cookie() {
195 let mut h = HeaderMap::new();
196 h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer esl_abc"));
197 h.insert(header::COOKIE, HeaderValue::from_static("a=1; esl_session=xyz; b=2"));
198 assert_eq!(bearer(&h), Some("esl_abc"));
199 assert_eq!(cookie(&h, SESSION_COOKIE).as_deref(), Some("xyz"));
200 assert_eq!(cookie(&h, "none"), None);
201 let mut h = HeaderMap::new();
202 h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer "));
203 assert_eq!(bearer(&h), None);
204 }
205
206 #[test]
207 fn identity_scopes() {
208 let id = Identity {
209 player: 1,
210 token_id: 2,
211 scopes: vec!["board:play".into()],
212 admin: false,
213 manager: false,
214 expires_ms: 0,
215 };
216 assert!(id.has("board:play"));
217 assert!(id.has_any(&["bot:play", "board:play"]));
218 assert!(!id.has("bot:play"));
219 }
220}