Skip to main content

open_chess_server/
auth.rs

1//! Who is calling: this server's API tokens (bearer) and session cookies.
2//!
3//! Players, tokens and sessions are the server's own (`chess.players`,
4//! `chess.api_tokens`, `chess.sessions`). Tokens are hashed with SHA-256,
5//! with Lichess's scope names (`board:play`, `bot:play`, `challenge:read`,
6//! `challenge:write`, `email:read`) plus `chess:fairplay` for reviewers.
7//! People make and revoke them on the Account page ([`crate::http::web`]),
8//! through the OAuth flow, or (for a host site's players) through the
9//! site's server ([`crate::http::web::esports_token`]). New tokens start
10//! `ocp_`; tokens imported from a host site keep its `esl_`. A short cache
11//! keeps a busy game from hitting the database for every move; a revoked
12//! token stops working within [`CACHE_TTL`].
13
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use anyhow::Result;
19use axum::http::{HeaderMap, header};
20use sha2::{Digest, Sha256};
21
22use crate::db::Db;
23
24/// New tokens' prefix.
25pub const TOKEN_PREFIX: &str = "ocp_";
26/// Tokens imported from a host site (esports-lab) keep its prefix.
27pub const SITE_TOKEN_PREFIX: &str = "esl_";
28/// Signed in to this server (its own sign-in, or with the host site).
29pub const SESSION_COOKIE: &str = "oc_session";
30pub const CACHE_TTL: Duration = Duration::from_secs(15);
31const NEGATIVE_TTL: Duration = Duration::from_secs(5);
32
33/// Scopes this server understands (Lichess's names, and one of ours).
34pub const SCOPES: &[(&str, &str)] = &[
35    ("board:play", "Play games with the Board API"),
36    ("bot:play", "Play games with the Bot API (bot accounts)"),
37    ("challenge:read", "Read incoming challenges"),
38    ("challenge:write", "Create, accept and decline challenges"),
39    ("email:read", "Read your email address"),
40    ("chess:fairplay", "Review fair-play reports (managers and admins)"),
41];
42
43pub fn hash_key(key: &str) -> String {
44    hex::encode(Sha256::digest(key.as_bytes()))
45}
46
47pub fn random_token(len: usize) -> String {
48    use rand::distr::{Alphanumeric, SampleString};
49    Alphanumeric.sample_string(&mut rand::rng(), len)
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Identity {
54    pub player: i64,
55    pub token_id: i64,
56    pub scopes: Vec<String>,
57    pub admin: bool,
58    pub manager: bool,
59    /// When the token expires, Unix ms.
60    pub expires_ms: i64,
61}
62
63impl Identity {
64    pub fn has(&self, scope: &str) -> bool {
65        self.scopes.iter().any(|s| s == scope)
66    }
67
68    pub fn has_any(&self, scopes: &[&str]) -> bool {
69        scopes.iter().any(|s| self.has(s))
70    }
71}
72
73type Cache = Arc<Mutex<HashMap<String, (Option<Identity>, Instant)>>>;
74
75#[derive(Clone)]
76pub struct Auth {
77    db: Db,
78    cache: Cache,
79}
80
81pub fn bearer(h: &HeaderMap) -> Option<&str> {
82    h.get(header::AUTHORIZATION)
83        .and_then(|v| v.to_str().ok())
84        .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
85        .map(str::trim)
86        .filter(|t| !t.is_empty())
87}
88
89pub fn cookie(h: &HeaderMap, name: &str) -> Option<String> {
90    h.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';')).find_map(
91        |kv| {
92            let (k, v) = kv.trim().split_once('=')?;
93            (k == name).then(|| v.to_owned())
94        },
95    )
96}
97
98impl Auth {
99    pub fn new(db: Db) -> Auth {
100        Auth { db, cache: Arc::default() }
101    }
102
103    /// The identity behind a token, if it is valid now.
104    pub async fn token(&self, token: &str) -> Result<Option<Identity>> {
105        if !(token.starts_with(TOKEN_PREFIX) || token.starts_with(SITE_TOKEN_PREFIX)) {
106            return Ok(None);
107        }
108        let key = hash_key(token);
109        if let Ok(c) = self.cache.lock()
110            && let Some((id, at)) = c.get(&key)
111        {
112            let ttl = if id.is_some() { CACHE_TTL } else { NEGATIVE_TTL };
113            if at.elapsed() < ttl {
114                return Ok(id.clone());
115            }
116        }
117        let c = self.db.conn().await?;
118        let r = c
119            .query_opt(
120                &self.db.sql(
121                    "UPDATE chess.api_tokens t
122                        SET last_used_at = CASE WHEN t.last_used_at IS NULL OR t.last_used_at < now() - interval '1 minute'
123                                                THEN now() ELSE t.last_used_at END
124                       FROM chess.players p
125                      WHERE t.token_hash = $1 AND p.id = t.player_id AND t.revoked_at IS NULL
126                        AND t.expires_at > now() AND p.disabled_at IS NULL
127                     RETURNING t.id, t.player_id, t.scopes, p.role, p.roles,
128                               (extract(epoch from t.expires_at) * 1000)::bigint",
129                ),
130                &[&key],
131            )
132            .await?;
133        let id = r.map(|r| {
134            let role: String = r.get(3);
135            let roles: Vec<String> = r.get(4);
136            Identity {
137                token_id: r.get(0),
138                player: r.get(1),
139                scopes: r.get(2),
140                admin: role == "admin",
141                manager: roles.iter().any(|x| x == "manager"),
142                expires_ms: r.get(5),
143            }
144        });
145        if let Ok(mut c) = self.cache.lock() {
146            if c.len() > 10_000 {
147                c.clear();
148            }
149            c.insert(key, (id.clone(), Instant::now()));
150        }
151        Ok(id)
152    }
153
154    /// Forget a token at once (after revoking it here).
155    pub fn forget(&self, token: &str) {
156        if let Ok(mut c) = self.cache.lock() {
157            c.remove(&hash_key(token));
158        }
159    }
160
161    /// The player signed in with this session cookie.
162    pub async fn session(&self, h: &HeaderMap) -> Result<Option<i64>> {
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 chess.sessions s JOIN chess.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        // sha256("abc")
187        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; oc_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}