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
143pub(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
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
180pub(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
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
214pub(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
238pub(crate) fn csrf(session: &str, what: &str) -> String {
240 hash_key(&format!("{session}|{what}"))
241}
242
243pub(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
297pub 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
303pub 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 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
383pub 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
396pub 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
431pub(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
449pub(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
476pub(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
498pub 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
517pub 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
533pub 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
544pub 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 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 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
791pub 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
800pub 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
814pub 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
835const 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
888fn refused(e: &anyhow::Error) -> ApiError {
890 tracing::info!("passkey refused: {e:#}");
891 ApiError::forbidden(format!("{e:#}"))
892}
893
894async 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
913pub 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
932pub 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
947pub 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
982pub 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
1018pub 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
1034pub 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
1066pub(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
1088async 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
1119pub 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
1149pub 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 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
1188pub 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
1209async 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
1223pub 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
1229pub 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
1248pub async fn api_staff_options(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
1251 staff_options(&ctx, who.0.player).await
1252}
1253
1254pub 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
1276pub 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
1304pub 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
1332pub 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
1363pub 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
1384pub 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
1406pub 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 assert_eq!(
1460 hex::encode(hmac(b"Jefe", b"what do ya want for nothing?")),
1461 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1462 );
1463 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}