Skip to main content

open_chess_server/http/
web.rs

1//! Signing in to this server: its own accounts (a username and a
2//! password), and, with `ESPORTS_LOGIN`, "Sign in with the host site"
3//! (esports-lab). Both end in this server's session cookie, which the OAuth
4//! consent page and the Account page (API tokens) use.
5//!
6//! **The host site.** It shares a secret with this server
7//! (`ESPORTS_SECRET`) and uses it two ways:
8//!
9//! * Signing in: `GET /auth/esports` sends the browser to the site's
10//!   `/chess/sso?return=…&nonce=…`; the site sends it back to
11//!   `/auth/esports/back?a=…` with a signed statement of who is signed in
12//!   there ([`sign`]: `base64url(json).base64url(HMAC-SHA256)`, the JSON
13//!   `{sub, name, email, role, roles, nonce, exp}`).
14//! * Its server asks for a token for one of its players:
15//!   `POST /api/esports/token` with `Authorization: Bearer <secret>` and
16//!   the same fields as a form (plus `scopes`, `days`, `label`).
17//!
18//! Either way the site's player is linked to a player here by `esports_id`
19//! (made on first use, with a chess account named after them), so the
20//! same person is the same chess player whichever way they come in.
21
22use std::collections::HashMap;
23use std::fmt::Write as _;
24
25use argon2::Argon2;
26use argon2::password_hash::phc::PasswordHash;
27use argon2::password_hash::{PasswordHasher, PasswordVerifier};
28use axum::Json;
29use axum::extract::{Query, State};
30use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
31use axum::response::{IntoResponse, Redirect, Response};
32use base64::Engine as _;
33use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
34use bytes::Bytes;
35use chrono::{Duration, Utc};
36use serde_json::{Value, json};
37use sha2::{Digest, Sha256};
38
39use crate::app::{Ctx, Site, Who};
40use crate::auth::{SCOPES, SESSION_COOKIE, TOKEN_PREFIX, cookie, hash_key, random_token};
41use crate::error::{ApiError, ApiResult};
42use crate::http::oauth::page;
43use crate::http::{form, html_escape};
44use crate::{model, passkeys};
45
46const SESSION_DAYS: i64 = 30;
47const SSO_COOKIE: &str = "oc_sso";
48
49// ─── HMAC and the site's signed statements ───
50
51/// HMAC-SHA256 (RFC 2104).
52pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
53    let mut k = [0u8; 64];
54    if key.len() > 64 {
55        k[..32].copy_from_slice(&Sha256::digest(key));
56    } else {
57        k[..key.len()].copy_from_slice(key);
58    }
59    let pad = |b: u8| k.map(|x| x ^ b);
60    let inner = Sha256::new().chain_update(pad(0x36)).chain_update(msg).finalize();
61    Sha256::new().chain_update(pad(0x5c)).chain_update(inner).finalize().into()
62}
63
64use crate::auth::same;
65
66/// A signed statement: `base64url(json).base64url(hmac)`.
67pub fn sign(secret: &str, v: &Value) -> String {
68    let body = B64.encode(v.to_string());
69    let mac = B64.encode(hmac(secret.as_bytes(), body.as_bytes()));
70    format!("{body}.{mac}")
71}
72
73/// The statement, if its signature is right and it hasn't expired.
74pub fn verify(secret: &str, s: &str, now_secs: i64) -> Option<Value> {
75    let (body, mac) = s.split_once('.')?;
76    let want = hmac(secret.as_bytes(), body.as_bytes());
77    if !same(&B64.decode(mac).ok()?, &want) {
78        return None;
79    }
80    let v: Value = serde_json::from_slice(&B64.decode(body).ok()?).ok()?;
81    (v.get("exp")?.as_i64()? > now_secs).then_some(v)
82}
83
84/// A player of the host site, as it describes them.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct SitePlayer {
87    pub sub: String,
88    pub name: String,
89    pub email: Option<String>,
90    pub role: String,
91    pub roles: Vec<String>,
92}
93
94impl SitePlayer {
95    fn from_fields(get: impl Fn(&str) -> Option<String>) -> Option<SitePlayer> {
96        let sub = get("sub").filter(|s| !s.is_empty() && s.len() <= 64)?;
97        Some(SitePlayer {
98            name: get("name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("player{sub}")),
99            email: get("email").filter(|e| !e.is_empty()),
100            role: get("role").unwrap_or_else(|| "player".into()),
101            roles: get("roles")
102                .map(|r| r.split([',', ' ']).filter(|x| !x.is_empty()).map(str::to_owned).collect())
103                .unwrap_or_default(),
104            sub,
105        })
106    }
107
108    fn from_json(v: &Value) -> Option<SitePlayer> {
109        SitePlayer::from_fields(|k| match v.get(k)? {
110            Value::String(s) => Some(s.clone()),
111            Value::Number(n) => Some(n.to_string()),
112            Value::Array(a) => Some(a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(",")),
113            _ => None,
114        })
115    }
116}
117
118/// The player here linked to the site's, made on first use (and kept up
119/// to date: the email, which the site vouches for). The site's roles stay
120/// the site's: roles here are this server's own. Returns the player's id and
121/// chess username.
122pub async fn link(ctx: &Ctx, p: &SitePlayer) -> ApiResult<(i64, String)> {
123    let c = ctx.db.conn().await?;
124    let id: i64 = c
125        .query_one(
126            &ctx.db.sql(
127                "INSERT INTO chess.players (name, email, email_verified_at, esports_id, key_hash)
128                 VALUES ($1, $2, CASE WHEN $2::text IS NULL THEN NULL ELSE now() END, $3, $4)
129                 ON CONFLICT (esports_id) DO UPDATE
130                    SET email = EXCLUDED.email, email_verified_at = EXCLUDED.email_verified_at
131                 RETURNING id",
132            ),
133            &[&p.name, &p.email, &p.sub, &hash_key(&random_token(28))],
134        )
135        .await?
136        .get(0);
137    let a = model::ensure_account(&ctx.db, &*c, id).await?;
138    drop(c);
139    grant_listed(ctx, id).await?;
140    Ok((id, a.username))
141}
142
143// ─── sessions ───
144
145fn secure(ctx: &Ctx) -> &'static str {
146    if ctx.cfg.public_url().starts_with("https://") { "; Secure" } else { "" }
147}
148
149fn cookie_path(ctx: &Ctx) -> String {
150    let base = ctx.cfg.base_path().unwrap_or_default();
151    if base.is_empty() { "/".into() } else { base }
152}
153
154/// `CHESS_ADMIN_EMAILS`: this player becomes an admin if their confirmed
155/// email is listed and they have a passkey (an admin's rights need one).
156/// Their email is as good as the host site's sign-in that vouched for it.
157pub async fn grant_listed(ctx: &Ctx, player: i64) -> ApiResult<()> {
158    let emails = ctx.cfg.admin_emails();
159    if emails.is_empty() {
160        return Ok(());
161    }
162    let c = ctx.db.conn().await?;
163    let n = c
164        .execute(
165            &ctx.db.sql(
166                "UPDATE chess.players SET role = 'admin'
167                  WHERE id = $1 AND role <> 'admin' AND email_verified_at IS NOT NULL AND lower(email) = ANY($2)
168                    AND EXISTS (SELECT 1 FROM chess.passkeys WHERE player_id = $1)",
169            ),
170            &[&player, &emails],
171        )
172        .await?;
173    if n > 0 {
174        tracing::info!(player, "made an admin (CHESS_ADMIN_EMAILS)");
175        ctx.auth.forget_all();
176    }
177    Ok(())
178}
179
180/// Start a session: the cookie to set. `checked`: signed in with a passkey,
181/// which counts as an admin's or manager's passkey check.
182async fn start_session(ctx: &Ctx, player: i64, checked: bool) -> ApiResult<HeaderValue> {
183    grant_listed(ctx, player).await?;
184    let token = random_token(40);
185    let c = ctx.db.conn().await?;
186    c.execute(
187        &ctx.db.sql(
188            "INSERT INTO chess.sessions (token_hash, player_id, expires_at, staff_verified_at)
189             VALUES ($1, $2, now() + make_interval(days => $3), CASE WHEN $4 THEN now() END)",
190        ),
191        &[&hash_key(&token), &player, &i32::try_from(SESSION_DAYS).unwrap_or(30), &checked],
192    )
193    .await?;
194    HeaderValue::from_str(&format!(
195        "{SESSION_COOKIE}={token}; Path={}; HttpOnly; SameSite=Lax; Max-Age={}{}",
196        cookie_path(ctx),
197        SESSION_DAYS * 86_400,
198        secure(ctx)
199    ))
200    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))
201}
202
203/// A string as a JavaScript literal that's safe inside an inline
204/// `<script>`: no `<`, `>` or `&` survive to end the script or start a
205/// comment.
206fn js_string(s: &str) -> String {
207    serde_json::to_string(s)
208        .unwrap_or_else(|_| "\"\"".into())
209        .replace('<', "\\u003c")
210        .replace('>', "\\u003e")
211        .replace('&', "\\u0026")
212}
213
214/// Where to go after signing in: a path on this server, or the Account page.
215fn back_to(ctx: &Ctx, want: Option<&str>) -> String {
216    let base = ctx.cfg.base_path().unwrap_or_default();
217    match want {
218        Some(p)
219            if p.starts_with('/')
220                && !p.starts_with("//")
221                && !p.contains('\\')
222                && p.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '<' | '>' | '"' | '\'' | '`')) =>
223        {
224            p.to_owned()
225        }
226        _ => format!("{base}/account"),
227    }
228}
229
230fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response {
231    let mut r = Redirect::to(to).into_response();
232    for c in cookies {
233        r.headers_mut().append(header::SET_COOKIE, c);
234    }
235    r
236}
237
238/// A form tied to the session that saw it.
239fn csrf(session: &str, what: &str) -> String {
240    hash_key(&format!("{session}|{what}"))
241}
242
243// ─── pages ───
244
245fn hidden(k: &str, v: &str) -> String {
246    format!("<input type=hidden name=\"{k}\" value=\"{}\">", html_escape(v))
247}
248
249fn sign_in_form(ctx: &Ctx, ret: &str, error: Option<&str>) -> String {
250    let base = ctx.cfg.base_path().unwrap_or_default();
251    let mut body = String::new();
252    if let Some(e) = error {
253        let _ = write!(body, "<p class=err>{}</p>", html_escape(e));
254    }
255    if ctx.cfg.esports().is_some() {
256        let _ = write!(
257            body,
258            "<p><a class=btn href=\"{base}/auth/esports?return={r}\">Sign in with {site}</a></p><p class=muted>or with an account here:</p>",
259            r = urlencoding::encode(ret),
260            site = html_escape(ctx.cfg.host_name())
261        );
262    }
263    let _ = write!(
264        body,
265        "<form method=post action=\"{base}/login\">{h}\
266         <label>Username or email<input name=name autocomplete=\"username webauthn\" required></label>\
267         <label>Password<input name=password type=password autocomplete=current-password required></label>\
268         <button class=go>Sign in</button></form>\
269         <p class=pk hidden><button type=button id=pkSignIn>Sign in with a passkey</button></p><p class=err id=pkErr></p>\
270         <script>{js}\
271         if (pkWorks()) {{ document.querySelector('.pk').hidden = false; }}\
272         document.getElementById('pkSignIn').onclick = () => pkRun(async () => {{\
273           const r = await pkGet('{base}/auth/passkey/options', {{}}, '{base}/auth/passkey', {{ return: {ret_js} }});\
274           location.assign(r.to);\
275         }});</script>",
276        h = hidden("return", ret),
277        js = PASSKEY_JS,
278        ret_js = js_string(ret),
279    );
280    if ctx.cfg.signup {
281        let _ = write!(
282            body,
283            "<p class=muted>No account? <a href=\"{base}/signup?return={}\">Make one</a>.</p>",
284            urlencoding::encode(ret)
285        );
286    }
287    body
288}
289
290/// `GET /login`.
291pub async fn login_page(State(ctx): State<Ctx>, Query(q): Query<HashMap<String, String>>) -> Response {
292    let ret = back_to(&ctx, q.get("return").map(String::as_str));
293    page("Sign in", &sign_in_form(&ctx, &ret, q.get("error").map(String::as_str)))
294}
295
296/// `POST /login`: a username (or email) and password.
297pub async fn login(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
298    let f = form(&body);
299    let ret = back_to(&ctx, f.get("return").map(String::as_str));
300    let name = f.get("name").map_or("", |s| s.trim());
301    let password = f.get("password").map_or("", String::as_str);
302    let c = ctx.db.conn().await?;
303    let row = c
304        .query_opt(
305            &ctx.db.sql(
306                "SELECT p.id, p.password_hash FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
307                  WHERE (lower(a.username) = lower($1) OR lower(p.email) = lower($1))
308                    AND p.disabled_at IS NULL AND p.password_hash IS NOT NULL
309                  LIMIT 1",
310            ),
311            &[&name],
312        )
313        .await?;
314    drop(c);
315    let ok = row.as_ref().is_some_and(|r| {
316        let stored: String = r.get(1);
317        PasswordHash::new(&stored)
318            .is_ok_and(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
319    });
320    let Some(row) = row.filter(|_| ok) else {
321        // The same pace either way, and a little slower.
322        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
323        return Ok((
324            StatusCode::UNAUTHORIZED,
325            page(
326                "Sign in",
327                &sign_in_form(&ctx, &ret, Some("That name and password don't match an account here.")),
328            ),
329        )
330            .into_response());
331    };
332    let cookie = start_session(&ctx, row.get(0), false).await?;
333    Ok(redirect_with_cookies(&ret, vec![cookie]))
334}
335
336fn signup_form(ctx: &Ctx, ret: &str, error: Option<&str>, name: &str, email: &str) -> String {
337    let base = ctx.cfg.base_path().unwrap_or_default();
338    let mut body = String::new();
339    if let Some(e) = error {
340        let _ = write!(body, "<p class=err>{}</p>", html_escape(e));
341    }
342    let _ = write!(
343        body,
344        "<form method=post action=\"{base}/signup\">{h}\
345         <label>Username (letters, digits, _ and -)<input name=name value=\"{n}\" autocomplete=username required minlength=2 maxlength=20></label>\
346         <label>Email (optional, to sign in with)<input name=email type=email value=\"{e}\" autocomplete=email></label>\
347         <label>Password (10 or more characters)<input name=password type=password autocomplete=new-password minlength=10></label>\
348         <button class=go>Make the account</button></form>\
349         <p class=pk hidden>Or, instead of a password: <button type=button id=pkSignUp>Make it with a passkey</button></p><p class=err id=pkErr></p>\
350         <p class=muted>Already have one? <a href=\"{base}/login?return={r}\">Sign in</a>.</p>\
351         <script>{js}\
352         if (pkWorks()) {{ document.querySelector('.pk').hidden = false; }}\
353         document.getElementById('pkSignUp').onclick = () => pkRun(async () => {{\
354           const f = document.querySelector('form');\
355           const who = {{ name: f.name.value.trim(), email: f.email.value.trim() }};\
356           const r = await pkCreate('{base}/signup/passkey/options', who, '{base}/signup/passkey', {{ return: {ret_js} }});\
357           location.assign(r.to);\
358         }});</script>",
359        h = hidden("return", ret),
360        n = html_escape(name),
361        e = html_escape(email),
362        r = urlencoding::encode(ret),
363        js = PASSKEY_JS,
364        ret_js = js_string(ret),
365    );
366    body
367}
368
369/// `GET /signup`.
370pub async fn signup_page(State(ctx): State<Ctx>, Query(q): Query<HashMap<String, String>>) -> Response {
371    if !ctx.cfg.signup {
372        return (
373            StatusCode::FORBIDDEN,
374            page("No sign-ups here", "<p>This server doesn't take new accounts.</p>"),
375        )
376            .into_response();
377    }
378    let ret = back_to(&ctx, q.get("return").map(String::as_str));
379    page("Make an account", &signup_form(&ctx, &ret, None, "", ""))
380}
381
382/// `POST /signup`: a username, a password and (optionally) an email.
383pub async fn signup(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
384    if !ctx.cfg.signup {
385        return Err(ApiError::forbidden("this server doesn't take new accounts"));
386    }
387    let f = form(&body);
388    let ret = back_to(&ctx, f.get("return").map(String::as_str));
389    let name = f.get("name").map_or("", |s| s.trim()).to_owned();
390    let email = f.get("email").map_or("", |s| s.trim()).to_owned();
391    let password = f.get("password").cloned().unwrap_or_default();
392    let refuse = |why: &str| {
393        Ok((
394            StatusCode::BAD_REQUEST,
395            page("Make an account", &signup_form(&ctx, &ret, Some(why), &name, &email)),
396        )
397            .into_response())
398    };
399    if let Some(why) = name_problem(&name, &email) {
400        return refuse(why);
401    }
402    if password.chars().count() < 10 {
403        return refuse("The password needs 10 or more characters.");
404    }
405    let hash = hash_password(&password)?;
406    let mut c = ctx.db.conn().await?;
407    let tx = c.transaction().await?;
408    if let Some(why) = taken(&ctx, &tx, &name, &email).await? {
409        return refuse(why);
410    }
411    let id = make_account(&ctx, &tx, &name, &email, Some(&hash)).await?;
412    tx.commit().await?;
413    let cookie = start_session(&ctx, id, false).await?;
414    Ok(redirect_with_cookies(&ret, vec![cookie]))
415}
416
417/// What's wrong with a new account's name or email, if anything.
418fn name_problem(name: &str, email: &str) -> Option<&'static str> {
419    let valid = name.len() >= 2
420        && name.len() <= 20
421        && name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
422        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
423    if !valid {
424        return Some("A username is 2 to 20 letters, digits, _ and -, starting with a letter or digit.");
425    }
426    if name.to_lowercase().starts_with("ai-level-") {
427        return Some("That name is the computer's.");
428    }
429    if !email.is_empty() && !email.contains('@') {
430        return Some("That email doesn't look right.");
431    }
432    None
433}
434
435/// The name or email someone here already has, if so.
436async fn taken(
437    ctx: &Ctx,
438    c: &impl tokio_postgres::GenericClient,
439    name: &str,
440    email: &str,
441) -> ApiResult<Option<&'static str>> {
442    if model::account_by_name(&ctx.db, c, name).await?.is_some() {
443        return Ok(Some("That username is taken."));
444    }
445    if !email.is_empty()
446        && c.query_opt(&ctx.db.sql("SELECT 1 FROM chess.players WHERE lower(email) = lower($1)"), &[&email])
447            .await?
448            .is_some()
449    {
450        return Ok(Some("An account here already has that email."));
451    }
452    Ok(None)
453}
454
455fn hash_password(password: &str) -> ApiResult<String> {
456    Ok(Argon2::default()
457        .hash_password(password.as_bytes())
458        .map_err(|e| ApiError::from(anyhow::anyhow!("hashing the password: {e}")))?
459        .to_string())
460}
461
462/// A new player and their chess account.
463async fn make_account(
464    ctx: &Ctx,
465    c: &impl tokio_postgres::GenericClient,
466    name: &str,
467    email: &str,
468    password_hash: Option<&str>,
469) -> ApiResult<i64> {
470    let id: i64 = c
471        .query_one(
472            &ctx.db.sql(
473                "INSERT INTO chess.players (name, email, password_hash) VALUES ($1, $2, $3) RETURNING id",
474            ),
475            &[&name, &(!email.is_empty()).then_some(email), &password_hash],
476        )
477        .await?
478        .get(0);
479    c.execute(&ctx.db.sql("INSERT INTO chess.accounts (player_id, username) VALUES ($1, $2)"), &[&id, &name])
480        .await?;
481    Ok(id)
482}
483
484/// `POST /logout`.
485pub async fn logout(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
486    if let Some(t) = cookie(&h, SESSION_COOKIE) {
487        let c = ctx.db.conn().await?;
488        c.execute(
489            &ctx.db.sql("UPDATE chess.sessions SET revoked_at = now() WHERE token_hash = $1"),
490            &[&hash_key(&t)],
491        )
492        .await?;
493    }
494    let base = ctx.cfg.base_path().unwrap_or_default();
495    let gone = HeaderValue::from_str(&format!(
496        "{SESSION_COOKIE}=; Path={}; Max-Age=0; HttpOnly; SameSite=Lax",
497        cookie_path(&ctx)
498    ))
499    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
500    Ok(redirect_with_cookies(&format!("{base}/login"), vec![gone]))
501}
502
503/// The scopes a player may give a token (staff-only ones aside).
504pub async fn allowed(ctx: &Ctx, player: i64, wanted: &[String]) -> ApiResult<Vec<String>> {
505    let c = ctx.db.conn().await?;
506    let r =
507        c.query_one(&ctx.db.sql("SELECT role, roles FROM chess.players WHERE id = $1"), &[&player]).await?;
508    let role: String = r.get(0);
509    let roles: Vec<String> = r.get(1);
510    let staff = role == "admin" || roles.iter().any(|x| x == "manager");
511    Ok(wanted
512        .iter()
513        .filter(|s| SCOPES.iter().any(|(k, _)| k == s))
514        .filter(|s| staff || !matches!(s.as_str(), "chess:fairplay" | "bot:play"))
515        .cloned()
516        .collect())
517}
518
519/// Make a token; returns it (shown once) and when it expires.
520pub async fn issue(
521    ctx: &Ctx,
522    player: i64,
523    label: &str,
524    scopes: &[String],
525    days: i64,
526) -> ApiResult<(String, chrono::DateTime<Utc>)> {
527    issue_for(ctx, player, label, scopes, Duration::days(days.clamp(1, 365))).await
528}
529
530/// Make a token lasting `life`.
531pub async fn issue_for(
532    ctx: &Ctx,
533    player: i64,
534    label: &str,
535    scopes: &[String],
536    life: Duration,
537) -> ApiResult<(String, chrono::DateTime<Utc>)> {
538    let token = format!("{TOKEN_PREFIX}{}", random_token(36));
539    let prefix: String = token.chars().take(TOKEN_PREFIX.len() + 6).collect();
540    let expires = Utc::now() + life;
541    let label: String = label.trim().chars().take(80).collect();
542    let c = ctx.db.conn().await?;
543    c.execute(
544        &ctx.db.sql(
545            "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
546             VALUES ($1, $2, $3, $4, $5, $6)",
547        ),
548        &[
549            &player,
550            &if label.is_empty() { "token".to_owned() } else { label },
551            &hash_key(&token),
552            &prefix,
553            &scopes,
554            &expires,
555        ],
556    )
557    .await?;
558    Ok((token, expires))
559}
560
561async fn account_page(ctx: &Ctx, player: i64, session: &str, fresh: Option<&str>) -> ApiResult<Response> {
562    let base = ctx.cfg.base_path().unwrap_or_default();
563    let c = ctx.db.conn().await?;
564    let a = model::ensure_account(&ctx.db, &*c, player).await?;
565    let me = c
566        .query_one(
567            &ctx.db.sql(
568                "SELECT esports_id, role, roles, password_hash IS NOT NULL,
569                        coalesce(s.staff_verified_at > now() - make_interval(hours => $3), false)
570                   FROM chess.players p LEFT JOIN chess.sessions s ON s.token_hash = $2
571                  WHERE p.id = $1",
572            ),
573            &[&player, &hash_key(session), &ctx.cfg.staff_passkey_hours],
574        )
575        .await?;
576    let linked: Option<String> = me.get(0);
577    let (admin, manager) = crate::auth::staff_roles(&me.get::<_, String>(1), &me.get::<_, Vec<String>>(2));
578    let has_password: bool = me.get(3);
579    let checked: bool = me.get(4);
580    let keys = c
581        .query(
582            &ctx.db.sql(
583                "SELECT id, name, to_char(created_at, 'YYYY-MM-DD'), coalesce(to_char(last_used_at, 'YYYY-MM-DD'), 'never')
584                   FROM chess.passkeys WHERE player_id = $1 ORDER BY id",
585            ),
586            &[&player],
587        )
588        .await?;
589    let tokens = c
590        .query(
591            &ctx.db.sql(
592                "SELECT id, name, prefix, scopes, to_char(expires_at, 'YYYY-MM-DD'),
593                        coalesce(to_char(last_used_at, 'YYYY-MM-DD'), 'never')
594                   FROM chess.api_tokens
595                  WHERE player_id = $1 AND revoked_at IS NULL AND expires_at > now()
596                  ORDER BY created_at DESC",
597            ),
598            &[&player],
599        )
600        .await?;
601    drop(c);
602    let token_csrf = csrf(session, "tokens");
603    let pk_csrf = csrf(session, "passkeys");
604    let mut body = format!(
605        "<p>Signed in as <b>{}</b>{}.</p>",
606        html_escape(&a.username),
607        if linked.is_some() {
608            format!(" (linked to your {} account)", html_escape(ctx.cfg.host_name()))
609        } else {
610            String::new()
611        }
612    );
613    if admin || manager {
614        let what = if admin { "an admin" } else { "a manager" };
615        if checked {
616            let _ = write!(body, "<p class=ok>You're {what} here, with your passkey checked.</p>");
617        } else if keys.is_empty() {
618            let _ = write!(
619                body,
620                "<p>You're {what} here. Your rights need a passkey check: add a passkey below first.</p>"
621            );
622        } else {
623            let _ = write!(
624                body,
625                "<p>You're {what} here. Your rights need a passkey check: confirm it's you below.</p>"
626            );
627        }
628    }
629    // Changing passkeys or the password needs the passkey check first.
630    let locked = !keys.is_empty() && !checked;
631    if locked {
632        body.push_str(
633            "<p class=pk hidden><button type=button class=go id=pkStaff>Confirm it's you with your passkey</button> \
634             <span class=muted>to change your passkeys or password, or use an admin's or manager's rights.</span></p>",
635        );
636    }
637    if let Some(t) = fresh {
638        let _ = write!(
639            body,
640            "<p class=ok>Your new token, shown once: copy it now.</p><p><code class=token>{}</code></p>",
641            html_escape(t)
642        );
643    }
644
645    body.push_str("<h2>Passkeys</h2><p class=muted>Sign in with your fingerprint, face or device PIN instead of a password.</p>");
646    if keys.is_empty() {
647        body.push_str("<p class=muted>None yet.</p>");
648    } else {
649        body.push_str("<table><tr><th>Name</th><th>Added</th><th>Last used</th><th></th></tr>");
650        for k in &keys {
651            let id: i64 = k.get(0);
652            // The last passkey of an account with no password stays.
653            let removable = !locked && (has_password || keys.len() > 1);
654            let _ = write!(
655                body,
656                "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
657                html_escape(&k.get::<_, String>(1)),
658                k.get::<_, String>(2),
659                k.get::<_, String>(3),
660                if removable {
661                    format!(
662                        "<form method=post action=\"{base}/account/passkeys/remove\">{}{}<button>Remove</button></form>",
663                        hidden("csrf", &pk_csrf),
664                        hidden("id", &id.to_string())
665                    )
666                } else {
667                    String::new()
668                }
669            );
670        }
671        body.push_str("</table>");
672    }
673    if !locked {
674        body.push_str(
675            "<p class=pk hidden><label>Name for it<input id=pkName maxlength=60 placeholder=\"e.g. my phone\"></label>\
676             <button type=button id=pkAdd>Add a passkey</button></p>",
677        );
678    }
679    body.push_str("<p class=err id=pkErr></p><h2>Password</h2>");
680    if locked {
681        body.push_str("<p class=muted>Confirm it's you with your passkey (above) to change it.</p>");
682    } else {
683        let _ = write!(
684            body,
685            "<form method=post action=\"{base}/account/password\">{c}{current}\
686             <label>{label} (10 or more characters)<input name=password type=password autocomplete=new-password required minlength=10></label>\
687             <button>{verb}</button></form>",
688            c = hidden("csrf", &pk_csrf),
689            current = if has_password && keys.is_empty() {
690                "<label>Your current password<input name=current type=password autocomplete=current-password required></label>"
691            } else {
692                ""
693            },
694            label = if has_password { "New password" } else { "A password" },
695            verb = if has_password { "Change the password" } else { "Set a password" },
696        );
697    }
698    if has_password && !keys.is_empty() && !locked {
699        let _ = write!(
700            body,
701            "<form method=post action=\"{base}/account/password/remove\">{}<button>Sign in with passkeys only</button></form>",
702            hidden("csrf", &pk_csrf)
703        );
704    }
705
706    body.push_str("<h2>API tokens</h2>");
707    if tokens.is_empty() {
708        body.push_str("<p class=muted>None yet.</p>");
709    } else {
710        body.push_str("<table><tr><th>Name</th><th>Token</th><th>Scopes</th><th>Expires</th><th>Last used</th><th></th></tr>");
711        for t in &tokens {
712            let id: i64 = t.get(0);
713            let scopes: Vec<String> = t.get(3);
714            let _ = write!(
715                body,
716                "<tr><td>{}</td><td><code>{}…</code></td><td>{}</td><td>{}</td><td>{}</td>\
717                 <td><form method=post action=\"{base}/account/tokens/revoke\">{}{}<button>Revoke</button></form></td></tr>",
718                html_escape(&t.get::<_, String>(1)),
719                html_escape(&t.get::<_, String>(2)),
720                html_escape(&scopes.join(" ")),
721                t.get::<_, String>(4),
722                t.get::<_, String>(5),
723                hidden("csrf", &token_csrf),
724                hidden("id", &id.to_string()),
725            );
726        }
727        body.push_str("</table>");
728    }
729    let mut boxes = String::new();
730    for (k, d) in SCOPES {
731        let checked =
732            if matches!(*k, "board:play" | "challenge:read" | "challenge:write") { " checked" } else { "" };
733        let _ = write!(
734            boxes,
735            "<label class=check><input type=checkbox name=\"scope:{k}\"{checked}> {} <code>{k}</code></label>",
736            html_escape(d)
737        );
738    }
739    let _ = write!(
740        body,
741        "<h2>New token</h2><form method=post action=\"{base}/account/tokens\">{c}\
742         <label>Name<input name=label placeholder=\"e.g. my board\" maxlength=80></label>{boxes}\
743         <label>Days it lasts<input name=days type=number min=1 max=365 value=365></label>\
744         <button class=go>Make the token</button></form>\
745         <form method=post action=\"{base}/logout\"><button>Sign out</button></form>\
746         <script>{js}\
747         const csrf = {pk_csrf_js};\
748         if (pkWorks()) document.querySelectorAll('.pk').forEach((e) => (e.hidden = false));\
749         const add = document.getElementById('pkAdd');\
750         if (add) add.onclick = () => pkRun(async () => {{\
751           await pkCreate('{base}/account/passkeys/options', {{}}, '{base}/account/passkeys', {{ name: document.getElementById('pkName').value }}, csrf);\
752           location.reload();\
753         }});\
754         const staff = document.getElementById('pkStaff');\
755         if (staff) staff.onclick = () => pkRun(async () => {{\
756           await pkGet('{base}/account/staff/options', {{}}, '{base}/account/staff', {{}}, csrf);\
757           location.reload();\
758         }});</script>",
759        c = hidden("csrf", &token_csrf),
760        js = PASSKEY_JS,
761        pk_csrf_js = js_string(&pk_csrf),
762    );
763    Ok(page("Your chess account", &body))
764}
765
766async fn signed_in(ctx: &Ctx, h: &HeaderMap) -> ApiResult<Option<(i64, String)>> {
767    let session = cookie(h, SESSION_COOKIE).unwrap_or_default();
768    Ok(ctx.auth.session(h).await?.map(|p| (p, session)))
769}
770
771fn to_login(ctx: &Ctx, ret: &str) -> Response {
772    let base = ctx.cfg.base_path().unwrap_or_default();
773    Redirect::to(&format!("{base}/login?return={}", urlencoding::encode(ret))).into_response()
774}
775
776/// `GET /account`.
777pub async fn account(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
778    let base = ctx.cfg.base_path().unwrap_or_default();
779    let Some((player, session)) = signed_in(&ctx, &h).await? else {
780        return Ok(to_login(&ctx, &format!("{base}/account")));
781    };
782    account_page(&ctx, player, &session, None).await
783}
784
785/// `POST /account/tokens`.
786pub async fn new_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
787    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
788    let f = form(&body);
789    if f.get("csrf").map(String::as_str) != Some(csrf(&session, "tokens").as_str()) {
790        return Err(ApiError::forbidden("this form has expired: reload the page"));
791    }
792    let wanted: Vec<String> = f.keys().filter_map(|k| k.strip_prefix("scope:")).map(str::to_owned).collect();
793    let scopes = allowed(&ctx, player, &wanted).await?;
794    let days = f.get("days").and_then(|d| d.parse().ok()).unwrap_or(365);
795    let (token, _) = issue(&ctx, player, f.get("label").map_or("", String::as_str), &scopes, days).await?;
796    account_page(&ctx, player, &session, Some(&token)).await
797}
798
799/// `POST /account/tokens/revoke`.
800pub async fn revoke_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
801    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
802    let f = form(&body);
803    if f.get("csrf").map(String::as_str) != Some(csrf(&session, "tokens").as_str()) {
804        return Err(ApiError::forbidden("this form has expired: reload the page"));
805    }
806    let id: i64 = f.get("id").and_then(|i| i.parse().ok()).ok_or_else(|| ApiError::bad("which token?"))?;
807    let c = ctx.db.conn().await?;
808    c.execute(
809        &ctx.db.sql(
810            "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'chess: account page'
811              WHERE id = $1 AND player_id = $2 AND revoked_at IS NULL",
812        ),
813        &[&id, &player],
814    )
815    .await?;
816    let base = ctx.cfg.base_path().unwrap_or_default();
817    Ok(Redirect::to(&format!("{base}/account")).into_response())
818}
819
820// ─── passkeys ───
821
822/// The pages' passkey helpers: `pkWorks()`, `pkGet(optionsUrl, body,
823/// doneUrl, extra, csrf)` (sign in, or the staff check), `pkCreate(…)` (a
824/// new passkey) and `pkRun(fn)` (shows a failure in `#pkErr`).
825const PASSKEY_JS: &str = r"
826const b64u = {
827  enc: (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
828  dec: (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
829};
830const pkWorks = () => !!(window.PublicKeyCredential && navigator.credentials);
831async function pkPost(url, body, csrf) {
832  const r = await fetch(url, { method: 'POST', credentials: 'same-origin',
833    headers: Object.assign({ 'Content-Type': 'application/json' }, csrf ? { 'X-CSRF': csrf } : {}),
834    body: JSON.stringify(body || {}) });
835  const j = await r.json().catch(() => ({}));
836  if (!r.ok) throw new Error(j.error || ('error ' + r.status));
837  return j;
838}
839const ids = (l) => (l || []).map((c) => Object.assign({}, c, { id: b64u.dec(c.id) }));
840async function pkGet(optUrl, optBody, doneUrl, extra, csrf) {
841  const o = await pkPost(optUrl, optBody, csrf);
842  const pk = o.public_key;
843  const cred = await navigator.credentials.get({ publicKey: Object.assign({}, pk, {
844    challenge: b64u.dec(pk.challenge), allowCredentials: ids(pk.allowCredentials) }) });
845  const r = cred.response;
846  return pkPost(doneUrl, Object.assign({ challenge_id: o.challenge_id, id: b64u.enc(cred.rawId),
847    client_data_json: b64u.enc(r.clientDataJSON), authenticator_data: b64u.enc(r.authenticatorData),
848    signature: b64u.enc(r.signature), user_handle: r.userHandle ? b64u.enc(r.userHandle) : null }, extra), csrf);
849}
850async function pkCreate(optUrl, optBody, doneUrl, extra, csrf) {
851  const o = await pkPost(optUrl, optBody, csrf);
852  const pk = o.public_key;
853  const cred = await navigator.credentials.create({ publicKey: Object.assign({}, pk, {
854    challenge: b64u.dec(pk.challenge), user: Object.assign({}, pk.user, { id: b64u.dec(pk.user.id) }),
855    excludeCredentials: ids(pk.excludeCredentials) }) });
856  const r = cred.response;
857  return pkPost(doneUrl, Object.assign({ challenge_id: o.challenge_id,
858    client_data_json: b64u.enc(r.clientDataJSON), attestation_object: b64u.enc(r.attestationObject) }, extra), csrf);
859}
860async function pkRun(fn) {
861  const err = document.getElementById('pkErr');
862  err.textContent = '';
863  try { await fn(); } catch (e) {
864    err.textContent = e.name === 'NotAllowedError' ? 'The passkey request was cancelled or timed out.' : e.message;
865  }
866}
867";
868
869fn rp(ctx: &Ctx) -> ApiResult<passkeys::Rp> {
870    passkeys::Rp::from_public_url(ctx.cfg.public_url()).map_err(|e| ApiError::from(anyhow::anyhow!("{e:#}")))
871}
872
873/// A passkey that didn't pass: the reason, to the person.
874fn refused(e: &anyhow::Error) -> ApiError {
875    tracing::info!("passkey refused: {e:#}");
876    ApiError::forbidden(format!("{e:#}"))
877}
878
879/// The session's player, for the account page's passkey calls (with the
880/// page's CSRF header).
881async fn session_for(ctx: &Ctx, h: &HeaderMap) -> ApiResult<(i64, String)> {
882    let (player, session) = signed_in(ctx, h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
883    let sent = h.get("x-csrf").and_then(|v| v.to_str().ok()).unwrap_or_default();
884    if !same(sent.as_bytes(), csrf(&session, "passkeys").as_bytes()) {
885        return Err(ApiError::forbidden("this page has expired: reload it"));
886    }
887    Ok((player, session))
888}
889
890#[derive(serde::Deserialize, Default)]
891pub struct NewAccount {
892    #[serde(default)]
893    name: String,
894    #[serde(default)]
895    email: String,
896}
897
898/// `POST /auth/passkey/options`: a challenge to sign in with any of this
899/// server's passkeys.
900pub async fn passkey_signin_options(State(ctx): State<Ctx>) -> ApiResult<Response> {
901    let c = ctx.db.conn().await?;
902    let (id, challenge) = passkeys::new_challenge(&ctx.db, &*c, passkeys::SIGNIN, None, None).await?;
903    Ok(Json(
904        json!({"challenge_id": id, "public_key": passkeys::request_options(&rp(&ctx)?, &challenge, &[])}),
905    )
906    .into_response())
907}
908
909#[derive(serde::Deserialize)]
910pub struct SignIn {
911    #[serde(flatten)]
912    assertion: passkeys::AssertionJson,
913    #[serde(rename = "return")]
914    ret: Option<String>,
915}
916
917/// `POST /auth/passkey`: signed in with a passkey (which is also the staff
918/// passkey check). Answers where to go next.
919pub async fn passkey_signin(State(ctx): State<Ctx>, Json(b): Json<SignIn>) -> ApiResult<Response> {
920    let c = ctx.db.conn().await?;
921    let (player, _) =
922        passkeys::check_assertion(&ctx.db, &*c, &rp(&ctx)?, passkeys::SIGNIN, &b.assertion, None)
923            .await
924            .map_err(|e| refused(&e))?;
925    drop(c);
926    let cookie = start_session(&ctx, player, true).await?;
927    let mut r = Json(json!({"to": back_to(&ctx, b.ret.as_deref())})).into_response();
928    r.headers_mut().append(header::SET_COOKIE, cookie);
929    Ok(r)
930}
931
932/// `POST /signup/passkey/options` (`{name, email}`): a challenge to make an
933/// account with a passkey instead of a password.
934pub async fn signup_passkey_options(
935    State(ctx): State<Ctx>,
936    Json(b): Json<NewAccount>,
937) -> ApiResult<Response> {
938    if !ctx.cfg.signup {
939        return Err(ApiError::forbidden("this server doesn't take new accounts"));
940    }
941    let (name, email) = (b.name.trim(), b.email.trim());
942    if let Some(why) = name_problem(name, email) {
943        return Err(ApiError::bad(why));
944    }
945    let c = ctx.db.conn().await?;
946    if let Some(why) = taken(&ctx, &*c, name, email).await? {
947        return Err(ApiError::bad(why));
948    }
949    let handle: Vec<u8> = (0..16).map(|_| rand::random::<u8>()).collect();
950    let data = json!({"name": name, "email": email, "handle": B64.encode(&handle)});
951    let (id, challenge) = passkeys::new_challenge(&ctx.db, &*c, passkeys::SIGNUP, None, Some(&data)).await?;
952    Ok(Json(json!({
953        "challenge_id": id,
954        "public_key": passkeys::creation_options(&rp(&ctx)?, &handle, name, &challenge, &[]),
955    }))
956    .into_response())
957}
958
959#[derive(serde::Deserialize)]
960pub struct SignUp {
961    #[serde(flatten)]
962    attestation: passkeys::AttestationJson,
963    #[serde(rename = "return")]
964    ret: Option<String>,
965}
966
967/// `POST /signup/passkey`: the new account, with its passkey, signed in.
968pub async fn signup_passkey(State(ctx): State<Ctx>, Json(form): Json<SignUp>) -> ApiResult<Response> {
969    if !ctx.cfg.signup {
970        return Err(ApiError::forbidden("this server doesn't take new accounts"));
971    }
972    let att = &form.attestation;
973    let mut conn = ctx.db.conn().await?;
974    let tx = conn.transaction().await?;
975    let taken_ch = passkeys::take_challenge(&ctx.db, &tx, att.challenge_id, passkeys::SIGNUP)
976        .await
977        .map_err(|e| refused(&e))?;
978    let data = taken_ch.data.unwrap_or_default();
979    let field = |k: &str| data.get(k).and_then(Value::as_str).unwrap_or_default().to_owned();
980    let (name, email) = (field("name"), field("email"));
981    let handle = passkeys::b64("handle", &field("handle")).map_err(|e| refused(&e))?;
982    let key = passkeys::verify_registration(
983        &rp(&ctx)?,
984        &taken_ch.challenge,
985        &passkeys::b64("client_data_json", &att.client_data_json).map_err(|e| refused(&e))?,
986        &passkeys::b64("attestation_object", &att.attestation_object).map_err(|e| refused(&e))?,
987    )
988    .map_err(|e| refused(&e))?;
989    if let Some(why) = taken(&ctx, &tx, &name, &email).await? {
990        return Err(ApiError::bad(why));
991    }
992    let player = make_account(&ctx, &tx, &name, &email, None).await?;
993    passkeys::add(&ctx.db, &tx, player, &handle, "passkey", &key)
994        .await
995        .map_err(|e| ApiError::bad(format!("{e:#}")))?;
996    tx.commit().await?;
997    let cookie = start_session(&ctx, player, true).await?;
998    let mut r = Json(json!({"to": back_to(&ctx, form.ret.as_deref())})).into_response();
999    r.headers_mut().append(header::SET_COOKIE, cookie);
1000    Ok(r)
1001}
1002
1003/// `POST /account/passkeys/options`: a challenge to add a passkey.
1004pub async fn account_passkey_options(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
1005    let (player, session) = session_for(&ctx, &h).await?;
1006    may_change(&ctx, player, &session).await?;
1007    let c = ctx.db.conn().await?;
1008    let a = model::ensure_account(&ctx.db, &*c, player).await?;
1009    let exclude = passkeys::ids(&ctx.db, &*c, player).await?;
1010    let (id, challenge) =
1011        passkeys::new_challenge(&ctx.db, &*c, passkeys::REGISTER, Some(player), None).await?;
1012    Ok(Json(json!({
1013        "challenge_id": id,
1014        "public_key": passkeys::creation_options(&rp(&ctx)?, &passkeys::user_handle(player), &a.username, &challenge, &exclude),
1015    }))
1016    .into_response())
1017}
1018
1019/// `POST /account/passkeys`: the new passkey.
1020pub async fn account_passkey_add(
1021    State(ctx): State<Ctx>,
1022    h: HeaderMap,
1023    Json(att): Json<passkeys::AttestationJson>,
1024) -> ApiResult<Response> {
1025    let (player, session) = session_for(&ctx, &h).await?;
1026    may_change(&ctx, player, &session).await?;
1027    let c = ctx.db.conn().await?;
1028    let taken_ch = passkeys::take_challenge(&ctx.db, &*c, att.challenge_id, passkeys::REGISTER)
1029        .await
1030        .map_err(|e| refused(&e))?;
1031    if taken_ch.player != Some(player) {
1032        return Err(ApiError::forbidden("that passkey request was for someone else"));
1033    }
1034    let key = passkeys::verify_registration(
1035        &rp(&ctx)?,
1036        &taken_ch.challenge,
1037        &passkeys::b64("client_data_json", &att.client_data_json).map_err(|e| refused(&e))?,
1038        &passkeys::b64("attestation_object", &att.attestation_object).map_err(|e| refused(&e))?,
1039    )
1040    .map_err(|e| refused(&e))?;
1041    let name: String = att.name.as_deref().unwrap_or_default().trim().chars().take(60).collect();
1042    let name = if name.is_empty() { "passkey".to_owned() } else { name };
1043    passkeys::add(&ctx.db, &*c, player, &passkeys::user_handle(player), &name, &key)
1044        .await
1045        .map_err(|e| ApiError::bad(format!("{e:#}")))?;
1046    drop(c);
1047    grant_listed(&ctx, player).await?;
1048    Ok(Json(json!({"ok": true})).into_response())
1049}
1050
1051/// Changing how an account signs in (its passkeys, its password) needs a
1052/// fresh passkey check in this session when it has passkeys, so a stolen
1053/// password or session can't add the thief's own.
1054async fn may_change(ctx: &Ctx, player: i64, session: &str) -> ApiResult<()> {
1055    let c = ctx.db.conn().await?;
1056    let r = c
1057        .query_one(
1058            &ctx.db.sql(
1059                "SELECT EXISTS (SELECT 1 FROM chess.passkeys WHERE player_id = $1),
1060                        coalesce((SELECT s.staff_verified_at > now() - make_interval(hours => $3)
1061                                    FROM chess.sessions s WHERE s.token_hash = $2), false)",
1062            ),
1063            &[&player, &hash_key(session), &ctx.cfg.staff_passkey_hours],
1064        )
1065        .await?;
1066    let (has_keys, checked): (bool, bool) = (r.get(0), r.get(1));
1067    if has_keys && !checked {
1068        return Err(ApiError::forbidden("confirm it's you with one of your passkeys first"));
1069    }
1070    Ok(())
1071}
1072
1073/// Forget passkey checks made in this player's sessions and tokens (a
1074/// passkey went: a check made with it mustn't outlive it).
1075async fn forget_checks(ctx: &Ctx, player: i64) -> ApiResult<()> {
1076    let c = ctx.db.conn().await?;
1077    c.execute(
1078        &ctx.db.sql("UPDATE chess.sessions SET staff_verified_at = NULL WHERE player_id = $1"),
1079        &[&player],
1080    )
1081    .await?;
1082    c.execute(
1083        &ctx.db.sql("UPDATE chess.api_tokens SET staff_verified_at = NULL WHERE player_id = $1"),
1084        &[&player],
1085    )
1086    .await?;
1087    ctx.auth.forget_all();
1088    Ok(())
1089}
1090
1091fn form_csrf_ok(f: &HashMap<String, String>, session: &str) -> ApiResult<()> {
1092    if f.get("csrf").map(String::as_str) == Some(csrf(session, "passkeys").as_str()) {
1093        Ok(())
1094    } else {
1095        Err(ApiError::forbidden("this form has expired: reload the page"))
1096    }
1097}
1098
1099fn to_account(ctx: &Ctx) -> Response {
1100    let base = ctx.cfg.base_path().unwrap_or_default();
1101    Redirect::to(&format!("{base}/account")).into_response()
1102}
1103
1104/// `POST /account/passkeys/remove` (form `id`). The last passkey of an
1105/// account without a password stays.
1106pub async fn account_passkey_remove(
1107    State(ctx): State<Ctx>,
1108    h: HeaderMap,
1109    body: Bytes,
1110) -> ApiResult<Response> {
1111    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
1112    let f = form(&body);
1113    form_csrf_ok(&f, &session)?;
1114    let id: i64 = f.get("id").and_then(|i| i.parse().ok()).ok_or_else(|| ApiError::bad("which passkey?"))?;
1115    may_change(&ctx, player, &session).await?;
1116    let c = ctx.db.conn().await?;
1117    let n = c
1118        .execute(
1119            &ctx.db.sql(
1120                "DELETE FROM chess.passkeys k WHERE k.id = $1 AND k.player_id = $2
1121               AND ((SELECT password_hash IS NOT NULL FROM chess.players WHERE id = $2)
1122                    OR (SELECT count(*) FROM chess.passkeys WHERE player_id = $2) > 1)",
1123            ),
1124            &[&id, &player],
1125        )
1126        .await?;
1127    drop(c);
1128    if n > 0 {
1129        forget_checks(&ctx, player).await?;
1130    }
1131    Ok(to_account(&ctx))
1132}
1133
1134/// `POST /account/password` (form `password`, and `current`: the password
1135/// it replaces, unless a passkey confirmed it's you): set or change it.
1136pub async fn account_password(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
1137    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
1138    let f = form(&body);
1139    form_csrf_ok(&f, &session)?;
1140    may_change(&ctx, player, &session).await?;
1141    let password = f.get("password").cloned().unwrap_or_default();
1142    if password.chars().count() < 10 {
1143        return Err(ApiError::bad("The password needs 10 or more characters."));
1144    }
1145    let c = ctx.db.conn().await?;
1146    let r = c
1147        .query_one(
1148            &ctx.db.sql(
1149                "SELECT password_hash, EXISTS (SELECT 1 FROM chess.passkeys WHERE player_id = $1)
1150                   FROM chess.players WHERE id = $1",
1151            ),
1152            &[&player],
1153        )
1154        .await?;
1155    let (old, has_keys): (Option<String>, bool) = (r.get(0), r.get(1));
1156    // Without passkeys (so without a passkey check), the old password says
1157    // it's you.
1158    if let Some(old) = old.filter(|_| !has_keys) {
1159        let current = f.get("current").map_or("", String::as_str);
1160        let right = PasswordHash::new(&old)
1161            .is_ok_and(|h| Argon2::default().verify_password(current.as_bytes(), &h).is_ok());
1162        if !right {
1163            tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1164            return Err(ApiError::forbidden("your current password doesn't match"));
1165        }
1166    }
1167    let hash = hash_password(&password)?;
1168    c.execute(&ctx.db.sql("UPDATE chess.players SET password_hash = $2 WHERE id = $1"), &[&player, &hash])
1169        .await?;
1170    Ok(to_account(&ctx))
1171}
1172
1173/// `POST /account/password/remove`: passkeys only (with one, at least).
1174pub async fn account_password_remove(
1175    State(ctx): State<Ctx>,
1176    h: HeaderMap,
1177    body: Bytes,
1178) -> ApiResult<Response> {
1179    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
1180    form_csrf_ok(&form(&body), &session)?;
1181    may_change(&ctx, player, &session).await?;
1182    let c = ctx.db.conn().await?;
1183    c.execute(
1184        &ctx.db.sql(
1185            "UPDATE chess.players SET password_hash = NULL
1186              WHERE id = $1 AND EXISTS (SELECT 1 FROM chess.passkeys WHERE player_id = $1)",
1187        ),
1188        &[&player],
1189    )
1190    .await?;
1191    Ok(to_account(&ctx))
1192}
1193
1194/// A challenge for an admin's or manager's passkey check (their own keys).
1195async fn staff_options(ctx: &Ctx, player: i64) -> ApiResult<Response> {
1196    let c = ctx.db.conn().await?;
1197    let allow = passkeys::ids(&ctx.db, &*c, player).await?;
1198    if allow.is_empty() {
1199        return Err(ApiError::bad("add a passkey on your account page first"));
1200    }
1201    let (id, challenge) = passkeys::new_challenge(&ctx.db, &*c, passkeys::STAFF, Some(player), None).await?;
1202    Ok(Json(
1203        json!({"challenge_id": id, "public_key": passkeys::request_options(&rp(ctx)?, &challenge, &allow)}),
1204    )
1205    .into_response())
1206}
1207
1208/// `POST /account/staff/options`.
1209pub async fn account_staff_options(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
1210    let (player, _) = session_for(&ctx, &h).await?;
1211    staff_options(&ctx, player).await
1212}
1213
1214/// `POST /account/staff`: the passkey check, for this session.
1215pub async fn account_staff(
1216    State(ctx): State<Ctx>,
1217    h: HeaderMap,
1218    Json(a): Json<passkeys::AssertionJson>,
1219) -> ApiResult<Response> {
1220    let (player, session) = session_for(&ctx, &h).await?;
1221    let c = ctx.db.conn().await?;
1222    passkeys::check_assertion(&ctx.db, &*c, &rp(&ctx)?, passkeys::STAFF, &a, Some(player))
1223        .await
1224        .map_err(|e| refused(&e))?;
1225    c.execute(
1226        &ctx.db.sql("UPDATE chess.sessions SET staff_verified_at = now() WHERE token_hash = $1"),
1227        &[&hash_key(&session)],
1228    )
1229    .await?;
1230    Ok(Json(json!({"ok": true})).into_response())
1231}
1232
1233/// `POST /api/account/passkey/options` (a token): a challenge for the staff
1234/// passkey check, made in an app.
1235pub async fn api_staff_options(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
1236    staff_options(&ctx, who.0.player).await
1237}
1238
1239/// `POST /api/account/passkey`: the passkey check, for the token used.
1240pub async fn api_staff(
1241    State(ctx): State<Ctx>,
1242    who: Who,
1243    h: HeaderMap,
1244    Json(a): Json<passkeys::AssertionJson>,
1245) -> ApiResult<Response> {
1246    let c = ctx.db.conn().await?;
1247    passkeys::check_assertion(&ctx.db, &*c, &rp(&ctx)?, passkeys::STAFF, &a, Some(who.0.player))
1248        .await
1249        .map_err(|e| refused(&e))?;
1250    c.execute(
1251        &ctx.db.sql("UPDATE chess.api_tokens SET staff_verified_at = now() WHERE id = $1"),
1252        &[&who.0.token_id],
1253    )
1254    .await?;
1255    if let Some(t) = crate::auth::bearer(&h) {
1256        ctx.auth.forget(t);
1257    }
1258    Ok(Json(json!({"ok": true})).into_response())
1259}
1260
1261// ─── the host site ───
1262
1263/// `GET /auth/esports`: off to the site to sign in.
1264pub async fn esports_start(
1265    State(ctx): State<Ctx>,
1266    Query(q): Query<HashMap<String, String>>,
1267) -> ApiResult<Response> {
1268    if ctx.cfg.esports().is_none() {
1269        return Err(ApiError::not_found("signing in with the site"));
1270    }
1271    let ret = back_to(&ctx, q.get("return").map(String::as_str));
1272    let nonce = random_token(24);
1273    let back = format!("{}/auth/esports/back", ctx.cfg.public_url());
1274    let to = format!(
1275        "{}/chess/sso?return={}&nonce={nonce}",
1276        ctx.cfg.site_url.trim_end_matches('/'),
1277        urlencoding::encode(&back)
1278    );
1279    let c = HeaderValue::from_str(&format!(
1280        "{SSO_COOKIE}={nonce}.{}; Path={}; Max-Age=600; HttpOnly; SameSite=Lax{}",
1281        B64.encode(&ret),
1282        cookie_path(&ctx),
1283        secure(&ctx)
1284    ))
1285    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
1286    Ok(redirect_with_cookies(&to, vec![c]))
1287}
1288
1289/// `GET /auth/esports/back?a=…`: back from the site, signed in there.
1290pub async fn esports_back(
1291    State(ctx): State<Ctx>,
1292    h: HeaderMap,
1293    Query(q): Query<HashMap<String, String>>,
1294) -> ApiResult<Response> {
1295    let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("signing in with the site"))?.to_owned();
1296    let (nonce, ret) = cookie(&h, SSO_COOKIE)
1297        .and_then(|c| {
1298            let (n, r) = c.split_once('.')?;
1299            Some((n.to_owned(), String::from_utf8(B64.decode(r).ok()?).ok()?))
1300        })
1301        .ok_or_else(|| ApiError::forbidden("this sign-in has expired: start again"))?;
1302    let v = q
1303        .get("a")
1304        .and_then(|a| verify(&secret, a, Utc::now().timestamp()))
1305        .ok_or_else(|| ApiError::forbidden("the site's answer isn't valid or has expired: start again"))?;
1306    if v.get("nonce").and_then(Value::as_str) != Some(nonce.as_str()) {
1307        return Err(ApiError::forbidden("this sign-in was for another page: start again"));
1308    }
1309    let p = SitePlayer::from_json(&v).ok_or_else(|| ApiError::bad("the site didn't say who you are"))?;
1310    let (player, _) = link(&ctx, &p).await?;
1311    let session = start_session(&ctx, player, false).await?;
1312    let gone = HeaderValue::from_str(&format!("{SSO_COOKIE}=; Path={}; Max-Age=0", cookie_path(&ctx)))
1313        .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
1314    Ok(redirect_with_cookies(&back_to(&ctx, Some(&ret)), vec![session, gone]))
1315}
1316
1317/// `POST /api/esports/token`: the site's server asks for a token for one
1318/// of its players (who gets linked, and a chess account, on first use).
1319pub async fn esports_token(State(ctx): State<Ctx>, _site: Site, body: Bytes) -> ApiResult<Response> {
1320    let f = form(&body);
1321    let p = SitePlayer::from_fields(|k| f.get(k).cloned())
1322        .ok_or_else(|| ApiError::bad("sub (the site's player id) is required"))?;
1323    let (player, username) = link(&ctx, &p).await?;
1324    let wanted: Vec<String> = f.get("scopes").map_or_else(
1325        || ["board:play", "challenge:read", "challenge:write"].map(str::to_owned).to_vec(),
1326        |s| s.split([' ', ',']).filter(|x| !x.is_empty()).map(str::to_owned).collect(),
1327    );
1328    let scopes = allowed(&ctx, player, &wanted).await?;
1329    let label = f.get("label").map_or_else(|| format!("{} (site)", ctx.cfg.host_name()), Clone::clone);
1330    let (token, expires) = match f.get("hours").and_then(|h| h.parse::<i64>().ok()) {
1331        Some(hours) => {
1332            issue_for(&ctx, player, &label, &scopes, Duration::hours(hours.clamp(1, 24 * 365))).await?
1333        }
1334        None => {
1335            issue(&ctx, player, &label, &scopes, f.get("days").and_then(|d| d.parse().ok()).unwrap_or(30))
1336                .await?
1337        }
1338    };
1339    Ok(Json(json!({
1340        "token": token,
1341        "username": username,
1342        "scopes": scopes,
1343        "expires": expires.timestamp_millis(),
1344    }))
1345    .into_response())
1346}
1347
1348/// `GET /api/esports/players` (bearer the secret): the site's players
1349/// linked here, `{"players": [{"sub", "username"}]}`, so the site can tell
1350/// whose games the feed shows.
1351pub async fn esports_players(State(ctx): State<Ctx>, _site: Site) -> ApiResult<Response> {
1352    let c = ctx.db.conn().await?;
1353    let rows = c
1354        .query(
1355            &ctx.db.sql(
1356                "SELECT p.esports_id, a.username FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
1357                  WHERE p.esports_id IS NOT NULL ORDER BY p.id",
1358            ),
1359            &[],
1360        )
1361        .await?;
1362    let players: Vec<Value> = rows
1363        .iter()
1364        .map(|r| json!({"sub": r.get::<_, String>(0), "username": r.get::<_, String>(1)}))
1365        .collect();
1366    Ok(Json(json!({ "players": players })).into_response())
1367}
1368
1369/// `POST /api/esports/revoke` (bearer the secret; form `label`, and
1370/// optionally `sub`): revoke the tokens of that name the site asked for
1371/// (all its players', or one's). Answers how many.
1372pub async fn esports_revoke(State(ctx): State<Ctx>, _site: Site, body: Bytes) -> ApiResult<Response> {
1373    let f = form(&body);
1374    let label = f.get("label").filter(|l| !l.is_empty()).ok_or_else(|| ApiError::bad("label is required"))?;
1375    let sub = f.get("sub").filter(|s| !s.is_empty());
1376    let c = ctx.db.conn().await?;
1377    let n = c
1378        .execute(
1379            &ctx.db.sql(
1380                "UPDATE chess.api_tokens t SET revoked_at = now(), revoked_by = 'site: ' || $1
1381                   FROM chess.players p
1382                  WHERE p.id = t.player_id AND p.esports_id IS NOT NULL AND ($2::text IS NULL OR p.esports_id = $2)
1383                    AND t.name = $1 AND t.revoked_at IS NULL",
1384            ),
1385            &[label, &sub],
1386        )
1387        .await?;
1388    Ok(Json(json!({ "revoked": n })).into_response())
1389}
1390
1391/// `POST /api/esports/link` (bearer the secret; form `sub`, `username`):
1392/// a manager on the site says the account `username` here is their player
1393/// `sub`'s, made before they signed in with the site. The site player's
1394/// link moves to it (an account it was linked to before stays, unlinked,
1395/// with its games). Refused (409) when that account is another site
1396/// player's. An empty `username` unlinks. Answers `{"username"}`.
1397pub async fn esports_link(State(ctx): State<Ctx>, _site: Site, body: Bytes) -> ApiResult<Response> {
1398    let f = form(&body);
1399    let sub = f.get("sub").filter(|s| !s.is_empty()).ok_or_else(|| ApiError::bad("sub is required"))?;
1400    let username = f.get("username").map(|u| u.trim()).unwrap_or_default();
1401    let mut c = ctx.db.conn().await?;
1402    let tx = c.transaction().await?;
1403    if username.is_empty() {
1404        tx.execute(&ctx.db.sql("UPDATE chess.players SET esports_id = NULL WHERE esports_id = $1"), &[sub])
1405            .await?;
1406        tx.commit().await?;
1407        return Ok(Json(json!({ "username": null })).into_response());
1408    }
1409    let row = tx
1410        .query_opt(
1411            &ctx.db.sql(
1412                "SELECT p.id, p.esports_id, a.username FROM chess.accounts a JOIN chess.players p ON p.id = a.player_id
1413                  WHERE lower(a.username) = lower($1)",
1414            ),
1415            &[&username],
1416        )
1417        .await?
1418        .ok_or_else(|| ApiError::not_found("no account of that name here"))?;
1419    let (player, linked, name): (i64, Option<String>, String) = (row.get(0), row.get(1), row.get(2));
1420    if linked.as_deref().is_some_and(|l| l != sub) {
1421        return Err(ApiError::new(
1422            axum::http::StatusCode::CONFLICT,
1423            format!("{name} is another of the site's players' account"),
1424        ));
1425    }
1426    tx.execute(
1427        &ctx.db.sql("UPDATE chess.players SET esports_id = NULL WHERE esports_id = $1 AND id <> $2"),
1428        &[sub, &player],
1429    )
1430    .await?;
1431    tx.execute(&ctx.db.sql("UPDATE chess.players SET esports_id = $1 WHERE id = $2"), &[sub, &player])
1432        .await?;
1433    tx.commit().await?;
1434    Ok(Json(json!({ "username": name })).into_response())
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439    use super::*;
1440
1441    #[test]
1442    fn hmac_matches_rfc_4231() {
1443        // Test case 2.
1444        assert_eq!(
1445            hex::encode(hmac(b"Jefe", b"what do ya want for nothing?")),
1446            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1447        );
1448        // Test case 6: a key longer than the block.
1449        assert_eq!(
1450            hex::encode(hmac(&[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
1451            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
1452        );
1453    }
1454
1455    #[test]
1456    fn signed_statements() {
1457        let secret = "s".repeat(32);
1458        let v = json!({"sub": "42", "name": "Mark", "roles": ["manager"], "nonce": "n", "exp": 2000});
1459        let s = sign(&secret, &v);
1460        assert_eq!(verify(&secret, &s, 1000), Some(v.clone()));
1461        assert_eq!(verify(&secret, &s, 3000), None, "expired");
1462        assert_eq!(verify(&"t".repeat(32), &s, 1000), None, "another secret");
1463        let (body, mac) = s.split_once('.').unwrap();
1464        let forged = format!("{}x.{mac}", &body[..body.len() - 1]);
1465        assert_eq!(verify(&secret, &forged, 1000), None, "changed");
1466        let p = SitePlayer::from_json(&v).unwrap();
1467        assert_eq!(
1468            (p.sub.as_str(), p.name.as_str(), p.roles.as_slice()),
1469            ("42", "Mark", &["manager".to_owned()][..])
1470        );
1471    }
1472}