1use 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
49pub 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
66pub 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
73pub 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#[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
118pub 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
143fn 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
154pub 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
180async 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
203fn 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
214fn 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
238fn csrf(session: &str, what: &str) -> String {
240 hash_key(&format!("{session}|{what}"))
241}
242
243fn 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
290pub 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
296pub 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 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
369pub 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
382pub 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
417fn 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
435async 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
462async 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
484pub 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
503pub 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
519pub 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
530pub 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 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 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
776pub 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
785pub 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
799pub 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
820const 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
873fn refused(e: &anyhow::Error) -> ApiError {
875 tracing::info!("passkey refused: {e:#}");
876 ApiError::forbidden(format!("{e:#}"))
877}
878
879async 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
898pub 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
917pub 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
932pub 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
967pub 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
1003pub 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
1019pub 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
1051async 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
1073async 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
1104pub 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
1134pub 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 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
1173pub 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
1194async 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
1208pub 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
1214pub 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
1233pub async fn api_staff_options(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
1236 staff_options(&ctx, who.0.player).await
1237}
1238
1239pub 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
1261pub 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
1289pub 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
1317pub 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
1348pub 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
1369pub 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
1391pub 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 assert_eq!(
1445 hex::encode(hmac(b"Jefe", b"what do ya want for nothing?")),
1446 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1447 );
1448 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}