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