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;
40use crate::auth::{SCOPES, SESSION_COOKIE, TOKEN_PREFIX, bearer, cookie, hash_key, random_token};
41use crate::error::{ApiError, ApiResult};
42use crate::http::oauth::page;
43use crate::http::{form, html_escape};
44use crate::model;
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
64fn same(a: &[u8], b: &[u8]) -> bool {
65 a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
66}
67
68pub fn sign(secret: &str, v: &Value) -> String {
70 let body = B64.encode(v.to_string());
71 let mac = B64.encode(hmac(secret.as_bytes(), body.as_bytes()));
72 format!("{body}.{mac}")
73}
74
75pub fn verify(secret: &str, s: &str, now_secs: i64) -> Option<Value> {
77 let (body, mac) = s.split_once('.')?;
78 let want = hmac(secret.as_bytes(), body.as_bytes());
79 if !same(&B64.decode(mac).ok()?, &want) {
80 return None;
81 }
82 let v: Value = serde_json::from_slice(&B64.decode(body).ok()?).ok()?;
83 (v.get("exp")?.as_i64()? > now_secs).then_some(v)
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct SitePlayer {
89 pub sub: String,
90 pub name: String,
91 pub email: Option<String>,
92 pub role: String,
93 pub roles: Vec<String>,
94}
95
96impl SitePlayer {
97 fn from_fields(get: impl Fn(&str) -> Option<String>) -> Option<SitePlayer> {
98 let sub = get("sub").filter(|s| !s.is_empty() && s.len() <= 64)?;
99 Some(SitePlayer {
100 name: get("name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("player{sub}")),
101 email: get("email").filter(|e| !e.is_empty()),
102 role: get("role").unwrap_or_else(|| "player".into()),
103 roles: get("roles")
104 .map(|r| r.split([',', ' ']).filter(|x| !x.is_empty()).map(str::to_owned).collect())
105 .unwrap_or_default(),
106 sub,
107 })
108 }
109
110 fn from_json(v: &Value) -> Option<SitePlayer> {
111 SitePlayer::from_fields(|k| match v.get(k)? {
112 Value::String(s) => Some(s.clone()),
113 Value::Number(n) => Some(n.to_string()),
114 Value::Array(a) => Some(a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(",")),
115 _ => None,
116 })
117 }
118}
119
120pub 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, role, roles, esports_id, key_hash)
128 VALUES ($1, $2, $3, $4, $5, $6)
129 ON CONFLICT (esports_id) DO UPDATE
130 SET email = EXCLUDED.email, role = EXCLUDED.role, roles = EXCLUDED.roles
131 RETURNING id",
132 ),
133 &[&p.name, &p.email, &p.role, &p.roles, &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 Ok((id, a.username))
139}
140
141fn secure(ctx: &Ctx) -> &'static str {
144 if ctx.cfg.public_url().starts_with("https://") { "; Secure" } else { "" }
145}
146
147fn cookie_path(ctx: &Ctx) -> String {
148 let base = ctx.cfg.base_path().unwrap_or_default();
149 if base.is_empty() { "/".into() } else { base }
150}
151
152async fn start_session(ctx: &Ctx, player: i64) -> ApiResult<HeaderValue> {
154 let token = random_token(40);
155 let c = ctx.db.conn().await?;
156 c.execute(
157 &ctx.db.sql(
158 "INSERT INTO chess.sessions (token_hash, player_id, expires_at) VALUES ($1, $2, now() + make_interval(days => $3))",
159 ),
160 &[&hash_key(&token), &player, &i32::try_from(SESSION_DAYS).unwrap_or(30)],
161 )
162 .await?;
163 HeaderValue::from_str(&format!(
164 "{SESSION_COOKIE}={token}; Path={}; HttpOnly; SameSite=Lax; Max-Age={}{}",
165 cookie_path(ctx),
166 SESSION_DAYS * 86_400,
167 secure(ctx)
168 ))
169 .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))
170}
171
172fn back_to(ctx: &Ctx, want: Option<&str>) -> String {
174 let base = ctx.cfg.base_path().unwrap_or_default();
175 match want {
176 Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') => p.to_owned(),
177 _ => format!("{base}/account"),
178 }
179}
180
181fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response {
182 let mut r = Redirect::to(to).into_response();
183 for c in cookies {
184 r.headers_mut().append(header::SET_COOKIE, c);
185 }
186 r
187}
188
189fn csrf(session: &str, what: &str) -> String {
191 hash_key(&format!("{session}|{what}"))
192}
193
194fn hidden(k: &str, v: &str) -> String {
197 format!("<input type=hidden name=\"{k}\" value=\"{}\">", html_escape(v))
198}
199
200fn sign_in_form(ctx: &Ctx, ret: &str, error: Option<&str>) -> String {
201 let base = ctx.cfg.base_path().unwrap_or_default();
202 let mut body = String::new();
203 if let Some(e) = error {
204 let _ = write!(body, "<p class=err>{}</p>", html_escape(e));
205 }
206 if ctx.cfg.esports().is_some() {
207 let _ = write!(
208 body,
209 "<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>",
210 r = urlencoding::encode(ret),
211 site = html_escape(&ctx.cfg.site_name)
212 );
213 }
214 let _ = write!(
215 body,
216 "<form method=post action=\"{base}/login\">{h}\
217 <label>Username or email<input name=name autocomplete=username required></label>\
218 <label>Password<input name=password type=password autocomplete=current-password required></label>\
219 <button class=go>Sign in</button></form>",
220 h = hidden("return", ret)
221 );
222 if ctx.cfg.signup {
223 let _ = write!(
224 body,
225 "<p class=muted>No account? <a href=\"{base}/signup?return={}\">Make one</a>.</p>",
226 urlencoding::encode(ret)
227 );
228 }
229 body
230}
231
232pub async fn login_page(State(ctx): State<Ctx>, Query(q): Query<HashMap<String, String>>) -> Response {
234 let ret = back_to(&ctx, q.get("return").map(String::as_str));
235 page("Sign in", &sign_in_form(&ctx, &ret, q.get("error").map(String::as_str)))
236}
237
238pub async fn login(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
240 let f = form(&body);
241 let ret = back_to(&ctx, f.get("return").map(String::as_str));
242 let name = f.get("name").map_or("", |s| s.trim());
243 let password = f.get("password").map_or("", String::as_str);
244 let c = ctx.db.conn().await?;
245 let row = c
246 .query_opt(
247 &ctx.db.sql(
248 "SELECT p.id, p.password_hash FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
249 WHERE (lower(a.username) = lower($1) OR lower(p.email) = lower($1))
250 AND p.disabled_at IS NULL AND p.password_hash IS NOT NULL
251 LIMIT 1",
252 ),
253 &[&name],
254 )
255 .await?;
256 drop(c);
257 let ok = row.as_ref().is_some_and(|r| {
258 let stored: String = r.get(1);
259 PasswordHash::new(&stored)
260 .is_ok_and(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
261 });
262 let Some(row) = row.filter(|_| ok) else {
263 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
265 return Ok((
266 StatusCode::UNAUTHORIZED,
267 page(
268 "Sign in",
269 &sign_in_form(&ctx, &ret, Some("That name and password don't match an account here.")),
270 ),
271 )
272 .into_response());
273 };
274 let cookie = start_session(&ctx, row.get(0)).await?;
275 Ok(redirect_with_cookies(&ret, vec![cookie]))
276}
277
278fn signup_form(ctx: &Ctx, ret: &str, error: Option<&str>, name: &str, email: &str) -> String {
279 let base = ctx.cfg.base_path().unwrap_or_default();
280 let mut body = String::new();
281 if let Some(e) = error {
282 let _ = write!(body, "<p class=err>{}</p>", html_escape(e));
283 }
284 let _ = write!(
285 body,
286 "<form method=post action=\"{base}/signup\">{h}\
287 <label>Username (letters, digits, _ and -)<input name=name value=\"{n}\" autocomplete=username required minlength=2 maxlength=20></label>\
288 <label>Email (optional, to sign in with)<input name=email type=email value=\"{e}\" autocomplete=email></label>\
289 <label>Password (10 or more characters)<input name=password type=password autocomplete=new-password required minlength=10></label>\
290 <button class=go>Make the account</button></form>\
291 <p class=muted>Already have one? <a href=\"{base}/login?return={r}\">Sign in</a>.</p>",
292 h = hidden("return", ret),
293 n = html_escape(name),
294 e = html_escape(email),
295 r = urlencoding::encode(ret),
296 );
297 body
298}
299
300pub async fn signup_page(State(ctx): State<Ctx>, Query(q): Query<HashMap<String, String>>) -> Response {
302 if !ctx.cfg.signup {
303 return (
304 StatusCode::FORBIDDEN,
305 page("No sign-ups here", "<p>This server doesn't take new accounts.</p>"),
306 )
307 .into_response();
308 }
309 let ret = back_to(&ctx, q.get("return").map(String::as_str));
310 page("Make an account", &signup_form(&ctx, &ret, None, "", ""))
311}
312
313pub async fn signup(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
315 if !ctx.cfg.signup {
316 return Err(ApiError::forbidden("this server doesn't take new accounts"));
317 }
318 let f = form(&body);
319 let ret = back_to(&ctx, f.get("return").map(String::as_str));
320 let name = f.get("name").map_or("", |s| s.trim()).to_owned();
321 let email = f.get("email").map_or("", |s| s.trim()).to_owned();
322 let password = f.get("password").cloned().unwrap_or_default();
323 let refuse = |why: &str| {
324 Ok((
325 StatusCode::BAD_REQUEST,
326 page("Make an account", &signup_form(&ctx, &ret, Some(why), &name, &email)),
327 )
328 .into_response())
329 };
330 let valid = name.len() >= 2
331 && name.len() <= 20
332 && name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
333 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
334 if !valid {
335 return refuse("A username is 2 to 20 letters, digits, _ and -, starting with a letter or digit.");
336 }
337 if name.to_lowercase().starts_with("ai-level-") {
338 return refuse("That name is the computer's.");
339 }
340 if password.chars().count() < 10 {
341 return refuse("The password needs 10 or more characters.");
342 }
343 if !email.is_empty() && !email.contains('@') {
344 return refuse("That email doesn't look right.");
345 }
346 let hash = Argon2::default()
347 .hash_password(password.as_bytes())
348 .map_err(|e| ApiError::from(anyhow::anyhow!("hashing the password: {e}")))?
349 .to_string();
350 let mut c = ctx.db.conn().await?;
351 let tx = c.transaction().await?;
352 if model::account_by_name(&ctx.db, &tx, &name).await?.is_some() {
353 return refuse("That username is taken.");
354 }
355 if !email.is_empty()
356 && tx
357 .query_opt(&ctx.db.sql("SELECT 1 FROM chess.players WHERE lower(email) = lower($1)"), &[&email])
358 .await?
359 .is_some()
360 {
361 return refuse("An account here already has that email.");
362 }
363 let id: i64 = tx
364 .query_one(
365 &ctx.db.sql(
366 "INSERT INTO chess.players (name, email, password_hash) VALUES ($1, $2, $3) RETURNING id",
367 ),
368 &[&name, &(!email.is_empty()).then_some(&email), &hash],
369 )
370 .await?
371 .get(0);
372 tx.execute(
373 &ctx.db.sql("INSERT INTO chess.accounts (player_id, username) VALUES ($1, $2)"),
374 &[&id, &name],
375 )
376 .await?;
377 tx.commit().await?;
378 let cookie = start_session(&ctx, id).await?;
379 Ok(redirect_with_cookies(&ret, vec![cookie]))
380}
381
382pub async fn logout(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
384 if let Some(t) = cookie(&h, SESSION_COOKIE) {
385 let c = ctx.db.conn().await?;
386 c.execute(
387 &ctx.db.sql("UPDATE chess.sessions SET revoked_at = now() WHERE token_hash = $1"),
388 &[&hash_key(&t)],
389 )
390 .await?;
391 }
392 let base = ctx.cfg.base_path().unwrap_or_default();
393 let gone = HeaderValue::from_str(&format!(
394 "{SESSION_COOKIE}=; Path={}; Max-Age=0; HttpOnly; SameSite=Lax",
395 cookie_path(&ctx)
396 ))
397 .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
398 Ok(redirect_with_cookies(&format!("{base}/login"), vec![gone]))
399}
400
401pub async fn allowed(ctx: &Ctx, player: i64, wanted: &[String]) -> ApiResult<Vec<String>> {
403 let c = ctx.db.conn().await?;
404 let r =
405 c.query_one(&ctx.db.sql("SELECT role, roles FROM chess.players WHERE id = $1"), &[&player]).await?;
406 let role: String = r.get(0);
407 let roles: Vec<String> = r.get(1);
408 let staff = role == "admin" || roles.iter().any(|x| x == "manager");
409 Ok(wanted
410 .iter()
411 .filter(|s| SCOPES.iter().any(|(k, _)| k == s))
412 .filter(|s| staff || !matches!(s.as_str(), "chess:fairplay" | "bot:play"))
413 .cloned()
414 .collect())
415}
416
417pub async fn issue(
419 ctx: &Ctx,
420 player: i64,
421 label: &str,
422 scopes: &[String],
423 days: i64,
424) -> ApiResult<(String, chrono::DateTime<Utc>)> {
425 issue_for(ctx, player, label, scopes, Duration::days(days.clamp(1, 365))).await
426}
427
428pub async fn issue_for(
430 ctx: &Ctx,
431 player: i64,
432 label: &str,
433 scopes: &[String],
434 life: Duration,
435) -> ApiResult<(String, chrono::DateTime<Utc>)> {
436 let token = format!("{TOKEN_PREFIX}{}", random_token(36));
437 let prefix: String = token.chars().take(TOKEN_PREFIX.len() + 6).collect();
438 let expires = Utc::now() + life;
439 let label: String = label.trim().chars().take(80).collect();
440 let c = ctx.db.conn().await?;
441 c.execute(
442 &ctx.db.sql(
443 "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
444 VALUES ($1, $2, $3, $4, $5, $6)",
445 ),
446 &[
447 &player,
448 &if label.is_empty() { "token".to_owned() } else { label },
449 &hash_key(&token),
450 &prefix,
451 &scopes,
452 &expires,
453 ],
454 )
455 .await?;
456 Ok((token, expires))
457}
458
459async fn account_page(ctx: &Ctx, player: i64, session: &str, fresh: Option<&str>) -> ApiResult<Response> {
460 let base = ctx.cfg.base_path().unwrap_or_default();
461 let c = ctx.db.conn().await?;
462 let a = model::ensure_account(&ctx.db, &*c, player).await?;
463 let linked: Option<String> = c
464 .query_one(&ctx.db.sql("SELECT esports_id FROM chess.players WHERE id = $1"), &[&player])
465 .await?
466 .get(0);
467 let tokens = c
468 .query(
469 &ctx.db.sql(
470 "SELECT id, name, prefix, scopes, to_char(expires_at, 'YYYY-MM-DD'),
471 coalesce(to_char(last_used_at, 'YYYY-MM-DD'), 'never')
472 FROM chess.api_tokens
473 WHERE player_id = $1 AND revoked_at IS NULL AND expires_at > now()
474 ORDER BY created_at DESC",
475 ),
476 &[&player],
477 )
478 .await?;
479 drop(c);
480 let token_csrf = csrf(session, "tokens");
481 let mut body = format!(
482 "<p>Signed in as <b>{}</b>{}.</p>",
483 html_escape(&a.username),
484 if linked.is_some() {
485 format!(" (linked to your {} account)", html_escape(&ctx.cfg.site_name))
486 } else {
487 String::new()
488 }
489 );
490 if let Some(t) = fresh {
491 let _ = write!(
492 body,
493 "<p class=ok>Your new token, shown once: copy it now.</p><p><code class=token>{}</code></p>",
494 html_escape(t)
495 );
496 }
497 body.push_str("<h2>API tokens</h2>");
498 if tokens.is_empty() {
499 body.push_str("<p class=muted>None yet.</p>");
500 } else {
501 body.push_str("<table><tr><th>Name</th><th>Token</th><th>Scopes</th><th>Expires</th><th>Last used</th><th></th></tr>");
502 for t in &tokens {
503 let id: i64 = t.get(0);
504 let scopes: Vec<String> = t.get(3);
505 let _ = write!(
506 body,
507 "<tr><td>{}</td><td><code>{}…</code></td><td>{}</td><td>{}</td><td>{}</td>\
508 <td><form method=post action=\"{base}/account/tokens/revoke\">{}{}<button>Revoke</button></form></td></tr>",
509 html_escape(&t.get::<_, String>(1)),
510 html_escape(&t.get::<_, String>(2)),
511 html_escape(&scopes.join(" ")),
512 t.get::<_, String>(4),
513 t.get::<_, String>(5),
514 hidden("csrf", &token_csrf),
515 hidden("id", &id.to_string()),
516 );
517 }
518 body.push_str("</table>");
519 }
520 let mut boxes = String::new();
521 for (k, d) in SCOPES {
522 let checked =
523 if matches!(*k, "board:play" | "challenge:read" | "challenge:write") { " checked" } else { "" };
524 let _ = write!(
525 boxes,
526 "<label class=check><input type=checkbox name=\"scope:{k}\"{checked}> {} <code>{k}</code></label>",
527 html_escape(d)
528 );
529 }
530 let _ = write!(
531 body,
532 "<h2>New token</h2><form method=post action=\"{base}/account/tokens\">{c}\
533 <label>Name<input name=label placeholder=\"e.g. my board\" maxlength=80></label>{boxes}\
534 <label>Days it lasts<input name=days type=number min=1 max=365 value=365></label>\
535 <button class=go>Make the token</button></form>\
536 <form method=post action=\"{base}/logout\"><button>Sign out</button></form>",
537 c = hidden("csrf", &token_csrf)
538 );
539 Ok(page("Your chess account", &body))
540}
541
542async fn signed_in(ctx: &Ctx, h: &HeaderMap) -> ApiResult<Option<(i64, String)>> {
543 let session = cookie(h, SESSION_COOKIE).unwrap_or_default();
544 Ok(ctx.auth.session(h).await?.map(|p| (p, session)))
545}
546
547fn to_login(ctx: &Ctx, ret: &str) -> Response {
548 let base = ctx.cfg.base_path().unwrap_or_default();
549 Redirect::to(&format!("{base}/login?return={}", urlencoding::encode(ret))).into_response()
550}
551
552pub async fn account(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
554 let base = ctx.cfg.base_path().unwrap_or_default();
555 let Some((player, session)) = signed_in(&ctx, &h).await? else {
556 return Ok(to_login(&ctx, &format!("{base}/account")));
557 };
558 account_page(&ctx, player, &session, None).await
559}
560
561pub async fn new_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
563 let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
564 let f = form(&body);
565 if f.get("csrf").map(String::as_str) != Some(csrf(&session, "tokens").as_str()) {
566 return Err(ApiError::forbidden("this form has expired: reload the page"));
567 }
568 let wanted: Vec<String> = f.keys().filter_map(|k| k.strip_prefix("scope:")).map(str::to_owned).collect();
569 let scopes = allowed(&ctx, player, &wanted).await?;
570 let days = f.get("days").and_then(|d| d.parse().ok()).unwrap_or(365);
571 let (token, _) = issue(&ctx, player, f.get("label").map_or("", String::as_str), &scopes, days).await?;
572 account_page(&ctx, player, &session, Some(&token)).await
573}
574
575pub async fn revoke_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
577 let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
578 let f = form(&body);
579 if f.get("csrf").map(String::as_str) != Some(csrf(&session, "tokens").as_str()) {
580 return Err(ApiError::forbidden("this form has expired: reload the page"));
581 }
582 let id: i64 = f.get("id").and_then(|i| i.parse().ok()).ok_or_else(|| ApiError::bad("which token?"))?;
583 let c = ctx.db.conn().await?;
584 c.execute(
585 &ctx.db.sql(
586 "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'chess: account page'
587 WHERE id = $1 AND player_id = $2 AND revoked_at IS NULL",
588 ),
589 &[&id, &player],
590 )
591 .await?;
592 let base = ctx.cfg.base_path().unwrap_or_default();
593 Ok(Redirect::to(&format!("{base}/account")).into_response())
594}
595
596pub async fn esports_start(
600 State(ctx): State<Ctx>,
601 Query(q): Query<HashMap<String, String>>,
602) -> ApiResult<Response> {
603 if ctx.cfg.esports().is_none() {
604 return Err(ApiError::not_found("signing in with the site"));
605 }
606 let ret = back_to(&ctx, q.get("return").map(String::as_str));
607 let nonce = random_token(24);
608 let back = format!("{}/auth/esports/back", ctx.cfg.public_url());
609 let to = format!(
610 "{}/chess/sso?return={}&nonce={nonce}",
611 ctx.cfg.site_url.trim_end_matches('/'),
612 urlencoding::encode(&back)
613 );
614 let c = HeaderValue::from_str(&format!(
615 "{SSO_COOKIE}={nonce}.{}; Path={}; Max-Age=600; HttpOnly; SameSite=Lax{}",
616 B64.encode(&ret),
617 cookie_path(&ctx),
618 secure(&ctx)
619 ))
620 .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
621 Ok(redirect_with_cookies(&to, vec![c]))
622}
623
624pub async fn esports_back(
626 State(ctx): State<Ctx>,
627 h: HeaderMap,
628 Query(q): Query<HashMap<String, String>>,
629) -> ApiResult<Response> {
630 let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("signing in with the site"))?.to_owned();
631 let (nonce, ret) = cookie(&h, SSO_COOKIE)
632 .and_then(|c| {
633 let (n, r) = c.split_once('.')?;
634 Some((n.to_owned(), String::from_utf8(B64.decode(r).ok()?).ok()?))
635 })
636 .ok_or_else(|| ApiError::forbidden("this sign-in has expired: start again"))?;
637 let v = q
638 .get("a")
639 .and_then(|a| verify(&secret, a, Utc::now().timestamp()))
640 .ok_or_else(|| ApiError::forbidden("the site's answer isn't valid or has expired: start again"))?;
641 if v.get("nonce").and_then(Value::as_str) != Some(nonce.as_str()) {
642 return Err(ApiError::forbidden("this sign-in was for another page: start again"));
643 }
644 let p = SitePlayer::from_json(&v).ok_or_else(|| ApiError::bad("the site didn't say who you are"))?;
645 let (player, _) = link(&ctx, &p).await?;
646 let session = start_session(&ctx, player).await?;
647 let gone = HeaderValue::from_str(&format!("{SSO_COOKIE}=; Path={}; Max-Age=0", cookie_path(&ctx)))
648 .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
649 Ok(redirect_with_cookies(&back_to(&ctx, Some(&ret)), vec![session, gone]))
650}
651
652pub async fn esports_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
655 let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("the site's token endpoint"))?;
656 if !bearer(&h).is_some_and(|b| same(b.as_bytes(), secret.as_bytes())) {
657 return Err(ApiError::unauthorized());
658 }
659 let f = form(&body);
660 let p = SitePlayer::from_fields(|k| f.get(k).cloned())
661 .ok_or_else(|| ApiError::bad("sub (the site's player id) is required"))?;
662 let (player, username) = link(&ctx, &p).await?;
663 let wanted: Vec<String> = f.get("scopes").map_or_else(
664 || ["board:play", "challenge:read", "challenge:write"].map(str::to_owned).to_vec(),
665 |s| s.split([' ', ',']).filter(|x| !x.is_empty()).map(str::to_owned).collect(),
666 );
667 let scopes = allowed(&ctx, player, &wanted).await?;
668 let label = f.get("label").map_or_else(|| format!("{} (site)", ctx.cfg.site_name), Clone::clone);
669 let (token, expires) = match f.get("hours").and_then(|h| h.parse::<i64>().ok()) {
670 Some(hours) => {
671 issue_for(&ctx, player, &label, &scopes, Duration::hours(hours.clamp(1, 24 * 365))).await?
672 }
673 None => {
674 issue(&ctx, player, &label, &scopes, f.get("days").and_then(|d| d.parse().ok()).unwrap_or(30))
675 .await?
676 }
677 };
678 Ok(Json(json!({
679 "token": token,
680 "username": username,
681 "scopes": scopes,
682 "expires": expires.timestamp_millis(),
683 }))
684 .into_response())
685}
686
687pub async fn esports_players(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
691 let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("the site's token endpoint"))?;
692 if !bearer(&h).is_some_and(|b| same(b.as_bytes(), secret.as_bytes())) {
693 return Err(ApiError::unauthorized());
694 }
695 let c = ctx.db.conn().await?;
696 let rows = c
697 .query(
698 &ctx.db.sql(
699 "SELECT p.esports_id, a.username FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
700 WHERE p.esports_id IS NOT NULL ORDER BY p.id",
701 ),
702 &[],
703 )
704 .await?;
705 let players: Vec<Value> = rows
706 .iter()
707 .map(|r| json!({"sub": r.get::<_, String>(0), "username": r.get::<_, String>(1)}))
708 .collect();
709 Ok(Json(json!({ "players": players })).into_response())
710}
711
712pub async fn esports_revoke(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
716 let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("the site's token endpoint"))?;
717 if !bearer(&h).is_some_and(|b| same(b.as_bytes(), secret.as_bytes())) {
718 return Err(ApiError::unauthorized());
719 }
720 let f = form(&body);
721 let label = f.get("label").filter(|l| !l.is_empty()).ok_or_else(|| ApiError::bad("label is required"))?;
722 let sub = f.get("sub").filter(|s| !s.is_empty());
723 let c = ctx.db.conn().await?;
724 let n = c
725 .execute(
726 &ctx.db.sql(
727 "UPDATE chess.api_tokens t SET revoked_at = now(), revoked_by = 'site: ' || $1
728 FROM chess.players p
729 WHERE p.id = t.player_id AND p.esports_id IS NOT NULL AND ($2::text IS NULL OR p.esports_id = $2)
730 AND t.name = $1 AND t.revoked_at IS NULL",
731 ),
732 &[label, &sub],
733 )
734 .await?;
735 Ok(Json(json!({ "revoked": n })).into_response())
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
743 fn hmac_matches_rfc_4231() {
744 assert_eq!(
746 hex::encode(hmac(b"Jefe", b"what do ya want for nothing?")),
747 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
748 );
749 assert_eq!(
751 hex::encode(hmac(&[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
752 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
753 );
754 }
755
756 #[test]
757 fn signed_statements() {
758 let secret = "s".repeat(32);
759 let v = json!({"sub": "42", "name": "Mark", "roles": ["manager"], "nonce": "n", "exp": 2000});
760 let s = sign(&secret, &v);
761 assert_eq!(verify(&secret, &s, 1000), Some(v.clone()));
762 assert_eq!(verify(&secret, &s, 3000), None, "expired");
763 assert_eq!(verify(&"t".repeat(32), &s, 1000), None, "another secret");
764 let (body, mac) = s.split_once('.').unwrap();
765 let forged = format!("{}x.{mac}", &body[..body.len() - 1]);
766 assert_eq!(verify(&secret, &forged, 1000), None, "changed");
767 let p = SitePlayer::from_json(&v).unwrap();
768 assert_eq!(
769 (p.sub.as_str(), p.name.as_str(), p.roles.as_slice()),
770 ("42", "Mark", &["manager".to_owned()][..])
771 );
772 }
773}