From 99b45141fc859e366ddd8e87cb91b792a8782b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:57:07 +0000 Subject: [PATCH] Panel: Yggdrasil auth server (authlib-injector), skins and capes - Yggdrasil API per the authlib-injector spec: metadata with signing key and skin domains, authenticate/refresh/validate/invalidate/signout, join/hasJoined, profile lookup, texture upload, and the minecraftservices endpoints (chat certificates, publickeys, attributes, blocklist) - 4096-bit signing key generated once into the data volume; textures and chat certificates signed SHA1withRSA (verified with Java's own crypto) - Skins/capes stored content-addressed after validation and re-encoding; cape library with public/group/private visibility; head avatars API - Launcher login returns a game session; launcher sessions recorded for launcher-only servers; authlib-injector download mirror - Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens, sessions, chat keys, game server tables - Remove Microsoft sign-in from the engine and panel Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS --- Cargo.lock | 166 +++++++ Cargo.toml | 3 +- crates/core/Cargo.toml | 2 + crates/core/src/authlib.rs | 130 +++++ crates/core/src/launch.rs | 13 +- crates/core/src/lib.rs | 2 +- crates/core/src/msa.rs | 212 --------- crates/core/tests/online.rs | 13 +- crates/shared/src/lib.rs | 50 +- docs/microsoft-auth.md | 25 - panel/server/Cargo.toml | 4 + panel/server/src/auth.rs | 46 +- panel/server/src/config.rs | 8 + panel/server/src/db.rs | 115 +++++ panel/server/src/lib.rs | 26 +- panel/server/src/main.rs | 4 +- panel/server/src/net.rs | 68 +++ panel/server/src/routes/account.rs | 189 ++++++++ panel/server/src/routes/admin.rs | 214 ++++++++- panel/server/src/routes/mod.rs | 21 +- panel/server/src/routes/public.rs | 67 ++- panel/server/src/state.rs | 3 + panel/server/src/store.rs | 3 + panel/server/src/textures.rs | 124 +++++ panel/server/src/yggdrasil/keys.rs | 74 +++ panel/server/src/yggdrasil/mod.rs | 742 +++++++++++++++++++++++++++++ panel/server/tests/api.rs | 93 +--- panel/server/tests/common/mod.rs | 98 ++++ panel/server/tests/yggdrasil.rs | 321 +++++++++++++ 29 files changed, 2436 insertions(+), 400 deletions(-) create mode 100644 crates/core/src/authlib.rs delete mode 100644 crates/core/src/msa.rs delete mode 100644 docs/microsoft-auth.md create mode 100644 panel/server/src/net.rs create mode 100644 panel/server/src/routes/account.rs create mode 100644 panel/server/src/textures.rs create mode 100644 panel/server/src/yggdrasil/keys.rs create mode 100644 panel/server/src/yggdrasil/mod.rs create mode 100644 panel/server/tests/common/mod.rs create mode 100644 panel/server/tests/yggdrasil.rs diff --git a/Cargo.lock b/Cargo.lock index e1b40d7..f4a80b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -698,6 +704,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.2" @@ -946,6 +958,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -994,6 +1017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -2108,6 +2132,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2378,6 +2415,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libappindicator" @@ -2428,6 +2468,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.25" @@ -2579,6 +2625,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.20.0" @@ -2678,6 +2734,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2693,6 +2765,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2700,6 +2782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3035,6 +3118,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3111,6 +3203,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3288,6 +3401,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.42.0" @@ -3618,6 +3737,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha1", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3764,6 +3904,7 @@ name = "scopenet-core" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "fastnbt", "futures", "hex", @@ -3772,6 +3913,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "sha2", "tempfile", "tokio", "tracing", @@ -3812,18 +3954,22 @@ dependencies = [ "anyhow", "argon2", "axum", + "base64 0.22.1", "chrono", "futures", "hex", + "image", "jsonwebtoken", "percent-encoding", "rand 0.8.8", "reqwest 0.12.28", + "rsa", "scopenet-core", "scopenet-shared", "serde", "serde_json", "sha1", + "sha2", "sqlx", "tempfile", "thiserror 2.0.21", @@ -4149,6 +4295,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4258,6 +4414,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.8.6" diff --git a/Cargo.toml b/Cargo.toml index a7f4fa4..279d7d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] } futures = "0.3" -sha1 = "0.10" +sha1 = { version = "0.10", features = ["oid"] } +sha2 = "0.10" md-5 = "0.10" hex = "0.4" uuid = { version = "1", features = ["v4", "serde"] } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b6ebcf9..cd3199a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -13,6 +13,8 @@ tokio.workspace = true reqwest.workspace = true futures.workspace = true sha1.workspace = true +sha2.workspace = true +base64.workspace = true hex.workspace = true zip.workspace = true tracing.workspace = true diff --git a/crates/core/src/authlib.rs b/crates/core/src/authlib.rs new file mode 100644 index 0000000..a890399 --- /dev/null +++ b/crates/core/src/authlib.rs @@ -0,0 +1,130 @@ +//! authlib-injector: a small Java agent that points the game's +//! authentication (sessions, skins, profile signatures) at the panel's +//! Yggdrasil server instead of Mojang's. +//! +//! The launcher fetches it from the panel's mirror first and falls back to +//! the official download, verifying the SHA-256 either way. + +use crate::http::{self, Download}; +use crate::paths::Layout; +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +pub const OFFICIAL_LATEST: &str = "https://authlib-injector.yushi.moe/artifact/latest.json"; + +/// Shape of `latest.json` (the panel mirror uses the same format). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Artifact { + pub build_number: u64, + pub version: String, + pub download_url: String, + pub checksums: Checksums, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Checksums { + pub sha256: String, +} + +pub fn sha256_file(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + Ok(hex::encode(Sha256::digest(&bytes))) +} + +fn cache_dir(layout: &Layout) -> PathBuf { + layout.cache().join("authlib-injector") +} + +/// Newest jar already on disk (used when offline). +fn newest_cached(layout: &Layout) -> Option { + std::fs::read_dir(cache_dir(layout)) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "jar")) + .max_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()) +} + +async fn from_source(client: &reqwest::Client, layout: &Layout, index_url: &str, base: Option<&str>) -> Result { + let artifact: Artifact = http::get_json(client, index_url).await?; + let url = match base { + Some(b) => crate::sync::resolve_url(b, &artifact.download_url), + None => artifact.download_url.clone(), + }; + let dest = cache_dir(layout).join(format!("authlib-injector-{}.jar", artifact.version)); + let expected = artifact.checksums.sha256.to_ascii_lowercase(); + if dest.exists() && sha256_file(&dest)? == expected { + return Ok(dest); + } + http::download_one(client, &Download::new(url, dest.clone(), None, None), &|_| {}).await?; + let got = sha256_file(&dest)?; + if got != expected { + std::fs::remove_file(&dest).ok(); + bail!("authlib-injector checksum mismatch (expected {expected}, got {got})"); + } + Ok(dest) +} + +/// Make sure authlib-injector is available locally and return its path. +pub async fn ensure(client: &reqwest::Client, layout: &Layout, panel_base: Option<&str>) -> Result { + let mut errors = Vec::new(); + if let Some(base) = panel_base.filter(|b| !b.is_empty()) { + let index = format!("{}/api/v1/launcher/authlib-injector.json", base.trim_end_matches('/')); + match from_source(client, layout, &index, Some(base)).await { + Ok(p) => return Ok(p), + Err(e) => errors.push(format!("panel mirror: {e:#}")), + } + } + match from_source(client, layout, OFFICIAL_LATEST, None).await { + Ok(p) => return Ok(p), + Err(e) => errors.push(format!("official download: {e:#}")), + } + if let Some(cached) = newest_cached(layout) { + tracing::warn!("using cached authlib-injector ({})", errors.join("; ")); + return Ok(cached); + } + Err(anyhow!("couldn't download authlib-injector: {}", errors.join("; "))) +} + +/// Fetch the Yggdrasil metadata so it can be handed to the agent up front +/// (saves the game a network round-trip at startup). +pub async fn prefetch_metadata(client: &reqwest::Client, api_url: &str) -> Result { + let resp = client.get(api_url).send().await?.error_for_status().context("fetching auth server metadata")?; + let bytes = resp.bytes().await?; + // Must be valid JSON, or the agent would refuse to start. + serde_json::from_slice::(&bytes).context("auth server metadata isn't JSON")?; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + +/// JVM arguments that load the agent. They must come before the main class. +pub fn jvm_args(jar: &Path, api_url: &str, prefetched: Option<&str>) -> Vec { + let mut args = vec![format!("-javaagent:{}={}", jar.display(), api_url)]; + if let Some(p) = prefetched { + args.push(format!("-Dauthlibinjector.yggdrasil.prefetched={p}")); + } + args +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_agent_args() { + let args = jvm_args(Path::new("/c/ai.jar"), "https://panel.example/api/yggdrasil", Some("e30=")); + assert_eq!(args[0], "-javaagent:/c/ai.jar=https://panel.example/api/yggdrasil"); + assert_eq!(args[1], "-Dauthlibinjector.yggdrasil.prefetched=e30="); + } + + #[test] + fn parses_latest_json() { + let a: Artifact = serde_json::from_str( + r#"{"build_number":53,"version":"1.2.5","release_time":"x","download_url":"https://x/a.jar","checksums":{"sha256":"ab"}}"#, + ) + .unwrap(); + assert_eq!(a.version, "1.2.5"); + } +} diff --git a/crates/core/src/launch.rs b/crates/core/src/launch.rs index bac1fc2..8e3dbda 100644 --- a/crates/core/src/launch.rs +++ b/crates/core/src/launch.rs @@ -27,14 +27,15 @@ pub struct Auth { /// Dashed or undashed UUID. pub uuid: String, pub access_token: String, - /// "msa" for Microsoft accounts, "legacy" for offline. + /// "mojang" for panel (Yggdrasil) accounts, "legacy" for offline. pub user_type: String, - pub xuid: Option, } #[derive(Debug, Clone)] pub struct LaunchOptions { pub auth: Auth, + /// JVM arguments that must come first (the authlib-injector agent). + pub agent_args: Vec, pub game_dir: PathBuf, pub memory_min_mb: u32, pub memory_max_mb: u32, @@ -187,7 +188,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co vars.insert("auth_access_token", opts.auth.access_token.clone()); vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid)); vars.insert("clientid", String::new()); - vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default()); + vars.insert("auth_xuid", String::new()); vars.insert("user_type", opts.auth.user_type.clone()); vars.insert("user_properties", "{}".into()); vars.insert("version_type", opts.version_label.clone()); @@ -203,7 +204,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co vars.insert("quickPlayMultiplayer", format!("{host}:{port}")); } - let mut args = Vec::new(); + let mut args = opts.agent_args.clone(); args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb))); args.push(format!("-Xmx{}M", opts.memory_max_mb)); args.extend(gc_args(opts.gc, installed.java_major)); @@ -336,8 +337,8 @@ mod tests { uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), access_token: "0".into(), user_type: "legacy".into(), - xuid: None, }, + agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()], game_dir: "/g".into(), memory_min_mb: 1024, memory_max_mb: 4096, @@ -362,7 +363,7 @@ mod tests { let layout = Layout::new("/root"); let cmd = build(&installed(json), &layout, &opts(true)); let a = cmd.args.join(" "); - assert!(a.starts_with("-Xms1024M -Xmx4096M")); + assert!(a.starts_with("-javaagent:/a.jar=https://p/api/yggdrasil -Xms1024M -Xmx4096M"), "agent must come first"); assert!(a.contains("-Dcustom=1")); assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f")); assert!(a.contains("--width 1280 --height 720")); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 517d024..649b83c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,6 +5,7 @@ //! tested without a window. pub mod assets; +pub mod authlib; pub mod http; pub mod install; pub mod java; @@ -13,7 +14,6 @@ pub mod libraries; pub mod loaders; pub mod maven; pub mod meta; -pub mod msa; pub mod options; pub mod paths; pub mod ping; diff --git a/crates/core/src/msa.rs b/crates/core/src/msa.rs deleted file mode 100644 index 533ef06..0000000 --- a/crates/core/src/msa.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft). -//! -//! Requires an Azure app registration ("client id") that Mojang has approved -//! for the Minecraft API. The admin configures it in the panel. - -use anyhow::{anyhow, bail, Context, Result}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::time::Duration; - -const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; -const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; -const SCOPE: &str = "XboxLive.signin offline_access"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceCode { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub expires_in: u64, - #[serde(default = "default_interval")] - pub interval: u64, - #[serde(default)] - pub message: String, -} - -fn default_interval() -> u64 { - 5 -} - -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: Option, - refresh_token: Option, - error: Option, - error_description: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MsaSession { - pub refresh_token: String, - pub mc_access_token: String, - /// Unix seconds. - pub mc_expires_at: i64, - pub profile: McProfile, - pub xuid: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McProfile { - pub id: String, - pub name: String, - #[serde(default)] - pub skins: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McSkin { - pub url: String, - #[serde(default)] - pub state: String, - #[serde(default)] - pub variant: Option, -} - -pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result { - let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?; - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - bail!("Microsoft rejected the sign-in request: {body}"); - } - Ok(resp.json().await?) -} - -/// Poll until the user finishes signing in, then complete the full chain. -pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result { - let mut interval = code.interval.max(1); - let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in); - loop { - if std::time::Instant::now() > deadline { - bail!("the sign-in code expired, please try again"); - } - tokio::time::sleep(Duration::from_secs(interval)).await; - let resp: TokenResponse = client - .post(TOKEN_URL) - .form(&[ - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ("client_id", client_id), - ("device_code", code.device_code.as_str()), - ]) - .send() - .await? - .json() - .await?; - match resp.error.as_deref() { - None => { - let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?; - let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?; - return complete(client, &access, refresh).await; - } - Some("authorization_pending") => continue, - Some("slow_down") => interval += 5, - Some("authorization_declined") => bail!("sign-in was cancelled"), - Some("expired_token") => bail!("the sign-in code expired, please try again"), - Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()), - } - } -} - -/// Refresh a saved session (used before each launch when the Minecraft -/// token is close to expiring). -pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result { - let resp: TokenResponse = client - .post(TOKEN_URL) - .form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)]) - .send() - .await? - .json() - .await?; - if let Some(err) = resp.error { - bail!("your Microsoft session expired ({err}); please sign in again"); - } - let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?; - let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string()); - complete(client, &access, refresh).await -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct XboxResponse { - token: String, - display_claims: DisplayClaims, -} -#[derive(Deserialize)] -struct DisplayClaims { - xui: Vec, -} -#[derive(Deserialize)] -struct Xui { - uhs: String, - #[serde(default)] - xid: Option, -} - -#[derive(Deserialize)] -struct McLogin { - access_token: String, - expires_in: i64, -} - -async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result { - // Xbox Live - let xbl: XboxResponse = client - .post("https://user.auth.xboxlive.com/user/authenticate") - .json(&json!({ - "Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")}, - "RelyingParty": "http://auth.xboxlive.com", - "TokenType": "JWT" - })) - .send() - .await? - .error_for_status() - .context("Xbox Live authentication failed")? - .json() - .await?; - - // XSTS - let resp = client - .post("https://xsts.auth.xboxlive.com/xsts/authorize") - .json(&json!({ - "Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]}, - "RelyingParty": "rp://api.minecraftservices.com/", - "TokenType": "JWT" - })) - .send() - .await?; - if resp.status().as_u16() == 401 { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - let msg = match body.get("XErr").and_then(|v| v.as_u64()) { - Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again", - Some(2148916235) => "Xbox Live isn't available in your country", - Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com", - Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first", - _ => "Xbox Live refused the sign-in", - }; - bail!("{msg}"); - } - let xsts: XboxResponse = resp.error_for_status()?.json().await?; - let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?; - let uhs = claims.uhs.clone(); - let xuid = claims.xid.clone(); - - // Minecraft - let mc: McLogin = client - .post("https://api.minecraftservices.com/authentication/login_with_xbox") - .json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) })) - .send() - .await? - .error_for_status() - .context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")? - .json() - .await?; - - let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?; - if resp.status().as_u16() == 404 { - bail!("this Microsoft account doesn't own Minecraft: Java Edition"); - } - let profile: McProfile = resp.error_for_status()?.json().await?; - - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; - Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid }) -} diff --git a/crates/core/tests/online.rs b/crates/core/tests/online.rs index 880e13f..870bff2 100644 --- a/crates/core/tests/online.rs +++ b/crates/core/tests/online.rs @@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) { uuid: scopenet_shared::offline_uuid("CiBot"), access_token: "0".into(), user_type: "legacy".into(), - xuid: None, }, + agent_args: vec![], game_dir, memory_min_mb: 512, memory_max_mb: 2048, @@ -94,3 +94,14 @@ async fn forge_1_20_1() { async fn neoforge_1_21_1() { run("1.21.1", Loader::NeoForge).await; } + +#[tokio::test] +#[ignore] +async fn authlib_injector_official_download() { + let dir = tempfile::tempdir().unwrap(); + let layout = Layout::new(dir.path()); + let jar = scopenet_core::authlib::ensure(&scopenet_core::http::client(), &layout, None).await.unwrap(); + let zip = zip::ZipArchive::new(std::fs::File::open(&jar).unwrap()).unwrap(); + assert!(zip.file_names().any(|n| n == "META-INF/MANIFEST.MF"), "not a jar: {}", jar.display()); + println!("authlib-injector: {}", jar.display()); +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index aea8358..57bd258 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -175,23 +175,48 @@ pub enum RegistrationMode { pub struct AuthConfig { pub panel_accounts: bool, pub registration: RegistrationMode, - pub microsoft: bool, - pub microsoft_client_id: Option, pub offline_local: bool, + /// Absolute URL of the panel's Yggdrasil (authlib-injector) API root. + /// Filled in by the panel; the launcher falls back to `{panel}/api/yggdrasil`. + pub yggdrasil_url: Option, } impl Default for AuthConfig { fn default() -> Self { - Self { - panel_accounts: true, - registration: RegistrationMode::Closed, - microsoft: false, - microsoft_client_id: None, - offline_local: true, - } + Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None } } } +/// Yggdrasil session tokens handed to the launcher at sign-in. The access +/// token is what the game (via authlib-injector) uses to join servers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct YggdrasilTokens { + pub access_token: String, + pub client_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct CapeInfo { + pub id: i64, + pub name: String, + pub url: String, +} + +/// A player's in-game identity: UUID, skin and cape. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct PlayerProfile { + pub uuid: String, + pub name: String, + pub skin_url: Option, + /// "classic" (Steve arms) or "slim" (Alex arms). + pub skin_model: String, + pub cape: Option, + /// Capes this player is allowed to pick. + pub available_capes: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginRequest { pub username: String, @@ -210,7 +235,8 @@ pub struct RegisterRequest { pub struct PublicUser { pub id: i64, pub username: String, - /// Offline-mode UUID (dashed), identical to what an offline server computes. + /// The player's UUID (dashed). New accounts get the offline-mode UUID for + /// their name, so offline and authenticated servers agree. pub uuid: String, pub role: String, pub groups: Vec, @@ -218,11 +244,15 @@ pub struct PublicUser { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthResponse { + /// Panel session token (manifest, skins, account API). pub token: String, pub user: PublicUser, /// Set when the account exists but is waiting for admin approval. #[serde(default)] pub pending: bool, + /// Game session for authlib-injector; absent for pending accounts. + #[serde(default)] + pub yggdrasil: Option, } // --------------------------------------------------------------------------- diff --git a/docs/microsoft-auth.md b/docs/microsoft-auth.md deleted file mode 100644 index aff3913..0000000 --- a/docs/microsoft-auth.md +++ /dev/null @@ -1,25 +0,0 @@ -# Microsoft sign-in - -Microsoft accounts give players their real skin and let them join online-mode servers. It needs an Azure app registration that Mojang has approved for the Minecraft API. - -## 1. Register an app - -1. Go to [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID → App registrations → New registration**. -2. Name: your launcher's name. Supported account types: **Personal Microsoft accounts only**. -3. Redirect URI: leave empty. -4. After creating it, open **Authentication** → enable **Allow public client flows** (needed for the device-code flow) → Save. -5. Copy the **Application (client) ID**. - -## 2. Request Minecraft API access - -New apps can't call the Minecraft services API until Mojang approves them. Submit the form at with your client ID. Approval can take a while; until then sign-in fails with *"Minecraft services rejected the Xbox token"*. - -## 3. Enable it in the panel - -**Settings → Microsoft accounts** → turn on *Sign in with Microsoft* and paste the client ID. Launchers show the Microsoft tab on their next refresh. - -## How it works - -The launcher uses the OAuth **device-code flow**: it shows a short code, opens `microsoft.com/link`, and waits. The chain is Microsoft → Xbox Live → XSTS → Minecraft services → profile. The refresh token is stored encrypted on the player's PC and renewed silently before launches. The panel never sees Microsoft credentials. - -Common errors are translated for players (no Xbox profile yet, child account, game not owned). diff --git a/panel/server/Cargo.toml b/panel/server/Cargo.toml index ec4096b..20c731b 100644 --- a/panel/server/Cargo.toml +++ b/panel/server/Cargo.toml @@ -29,6 +29,10 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", argon2 = "0.5" jsonwebtoken = "9" percent-encoding = "2" +sha2.workspace = true +base64.workspace = true +rsa = { version = "0.9", features = ["sha1", "pem"] } +image = { version = "0.25", default-features = false, features = ["png"] } [dev-dependencies] tempfile = "3" diff --git a/panel/server/src/auth.rs b/panel/server/src/auth.rs index 1503910..d9f939d 100644 --- a/panel/server/src/auth.rs +++ b/panel/server/src/auth.rs @@ -9,7 +9,7 @@ use argon2::Argon2; use axum::extract::FromRequestParts; use axum::http::request::Parts; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use scopenet_shared::{offline_uuid, PublicUser}; +use scopenet_shared::PublicUser; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Mutex; @@ -81,6 +81,13 @@ pub struct UserRow { pub status: String, pub created_at: String, pub last_login: Option, + /// Dashed player UUID. + pub uuid: String, + pub skin_hash: Option, + pub skin_model: String, + pub cape_id: Option, + /// Why the account is disabled (shown to the player when they're refused). + pub status_reason: Option, } impl UserRow { @@ -100,13 +107,46 @@ pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult Option<&str> { +/// Insert an account. Every account gets its offline-mode UUID, so offline +/// and panel-authenticated servers identify players the same way. +pub async fn create_user( + state: &AppState, + username: &str, + password: &str, + email: Option<&str>, + role: &str, + status: &str, +) -> AppResult { + let hash = hash_password(password)?; + sqlx::query_scalar( + "INSERT INTO users (username, password_hash, email, role, status, created_at, uuid) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(username) + .bind(hash) + .bind(email.map(str::trim).filter(|e| !e.is_empty())) + .bind(role) + .bind(status) + .bind(crate::db::now()) + .bind(scopenet_shared::offline_uuid(username)) + .fetch_one(&state.db) + .await + .map_err(|e| match e { + sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"), + e => e.into(), + }) +} + +pub async fn find_user_by_name(state: &AppState, name: &str) -> AppResult> { + Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(name.trim()).fetch_optional(&state.db).await?) +} + +pub fn bearer(parts: &Parts) -> Option<&str> { parts .headers .get(axum::http::header::AUTHORIZATION) diff --git a/panel/server/src/config.rs b/panel/server/src/config.rs index 536a634..c4bdb3a 100644 --- a/panel/server/src/config.rs +++ b/panel/server/src/config.rs @@ -12,6 +12,7 @@ pub struct Config { pub jwt_secret: Option, pub curseforge_api_key: Option, pub max_upload_mb: usize, + pub public_url: Option, } fn var(name: &str) -> Option { @@ -29,6 +30,7 @@ impl Config { jwt_secret: var("JWT_SECRET"), curseforge_api_key: var("CURSEFORGE_API_KEY"), max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048), + public_url: var("PUBLIC_URL"), } } @@ -38,4 +40,10 @@ impl Config { pub fn uploads_dir(&self) -> PathBuf { self.data_dir.join("uploads") } + pub fn textures_dir(&self) -> PathBuf { + self.data_dir.join("textures") + } + pub fn signing_key_path(&self) -> PathBuf { + self.data_dir.join("yggdrasil-signing.pem") + } } diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 7bd2ad2..508bb1e 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -77,6 +77,107 @@ const MIGRATIONS: &[&str] = &[ ); CREATE INDEX events_created ON events(created_at); "#, + // 2: Yggdrasil auth server (UUIDs, skins, capes, sessions) and game + // server integration (plugin/mod tracking) + r#" + ALTER TABLE users ADD COLUMN uuid TEXT NOT NULL DEFAULT ''; + ALTER TABLE users ADD COLUMN skin_hash TEXT; + ALTER TABLE users ADD COLUMN skin_model TEXT NOT NULL DEFAULT 'classic'; + ALTER TABLE users ADD COLUMN cape_id INTEGER; + ALTER TABLE users ADD COLUMN status_reason TEXT; + + CREATE TABLE capes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + hash TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'public', + allowed_groups TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL + ); + CREATE TABLE ygg_tokens ( + access_token TEXT PRIMARY KEY, + client_token TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + CREATE INDEX ygg_tokens_user ON ygg_tokens(user_id); + CREATE TABLE ygg_sessions ( + server_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE player_keys ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + private_pem TEXT NOT NULL, + public_pem TEXT NOT NULL, + signature_v1 TEXT NOT NULL, + signature_v2 TEXT NOT NULL, + expires_at TEXT NOT NULL, + refreshed_after TEXT NOT NULL + ); + CREATE TABLE launcher_sessions ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at); + + CREATE TABLE game_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_hint TEXT NOT NULL, + access TEXT NOT NULL DEFAULT 'all', + allowed_groups TEXT NOT NULL DEFAULT '[]', + require_launcher INTEGER NOT NULL DEFAULT 0, + software TEXT, + mc_version TEXT, + plugin_version TEXT, + online_mode INTEGER, + max_players INTEGER NOT NULL DEFAULT 0, + online_count INTEGER NOT NULL DEFAULT 0, + tps REAL, + last_seen TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE server_online ( + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + uuid TEXT NOT NULL, + name TEXT NOT NULL, + joined_at TEXT NOT NULL, + PRIMARY KEY (server_id, uuid) + ); + CREATE TABLE player_stats ( + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + uuid TEXT NOT NULL, + name TEXT NOT NULL, + playtime_secs INTEGER NOT NULL DEFAULT 0, + joins INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + player_kills INTEGER NOT NULL DEFAULT 0, + mob_kills INTEGER NOT NULL DEFAULT 0, + blocks_broken INTEGER NOT NULL DEFAULT 0, + blocks_placed INTEGER NOT NULL DEFAULT 0, + messages INTEGER NOT NULL DEFAULT 0, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + PRIMARY KEY (server_id, uuid) + ); + CREATE INDEX player_stats_uuid ON player_stats(uuid); + CREATE TABLE server_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + uuid TEXT, + name TEXT, + kind TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX server_events_server ON server_events(server_id, id); + CREATE INDEX server_events_uuid ON server_events(uuid, id); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { @@ -112,6 +213,20 @@ async fn migrate(pool: &SqlitePool) -> Result<()> { tx.commit().await?; tracing::info!("applied database migration {version}"); } + backfill_uuids(pool).await?; + Ok(()) +} + +/// Accounts created before the auth server existed get the offline-mode UUID +/// for their name — the one offline servers already knew them by. +async fn backfill_uuids(pool: &SqlitePool) -> Result<()> { + let missing: Vec<(i64, String)> = sqlx::query_as("SELECT id, username FROM users WHERE uuid = ''").fetch_all(pool).await?; + for (id, name) in missing { + sqlx::query("UPDATE users SET uuid = ? WHERE id = ?").bind(scopenet_shared::offline_uuid(&name)).bind(id).execute(pool).await?; + } + if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE name = 'users_uuid'").fetch_one(pool).await? == 0 { + sqlx::raw_sql("CREATE UNIQUE INDEX users_uuid ON users(uuid)").execute(pool).await?; + } Ok(()) } diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 50035cc..99e7043 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -4,10 +4,13 @@ pub mod auth; pub mod config; pub mod db; pub mod error; +pub mod net; pub mod packs; pub mod routes; pub mod state; pub mod store; +pub mod textures; +pub mod yggdrasil; use axum::http::{header, HeaderValue}; use axum::Router; @@ -38,9 +41,17 @@ pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result> { } pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result { + let path = cfg.signing_key_path(); + let ygg = tokio::task::spawn_blocking(move || yggdrasil::keys::Keys::load_or_create(&path)).await??; + build_state_with_keys(cfg, db, Arc::new(ygg)).await +} + +/// Like [`build_state`] with a given auth-server key (tests reuse one key). +pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, ygg: Arc) -> anyhow::Result { let secret = jwt_secret(&cfg)?; Ok(AppState { db, + ygg, keys: Arc::new(auth::Keys::new(&secret)), http: scopenet_core::http::client(), login_guard: Arc::new(auth::LoginGuard::default()), @@ -62,13 +73,9 @@ pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> { (hex::encode(bytes), true) } }; - let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?; - sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)") - .bind(&state.cfg.admin_username) - .bind(hash) - .bind(db::now()) - .execute(&state.db) - .await?; + auth::create_user(state, &state.cfg.admin_username, &password, None, "admin", "active") + .await + .map_err(|e| anyhow::anyhow!(e.message))?; if generated { tracing::warn!("============================================================"); tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username); @@ -91,6 +98,11 @@ pub fn app(state: AppState) -> Router { .nest_service("/files", ServeDir::new(state.cfg.files_dir())) .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir()))) .fallback_service(spa) + // Lets authlib-injector users enter just the panel URL (API Location Indication). + .layer(SetResponseHeaderLayer::if_not_present( + header::HeaderName::from_static("x-authlib-injector-api-location"), + HeaderValue::from_static("/api/yggdrasil/"), + )) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) .with_state(state) diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs index 947107a..6d66b60 100644 --- a/panel/server/src/main.rs +++ b/panel/server/src/main.rs @@ -25,7 +25,9 @@ async fn main() -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(&bind).await?; tracing::info!("SCOPENET panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION")); - axum::serve(listener, app(state)).with_graceful_shutdown(shutdown()).await?; + axum::serve(listener, app(state).into_make_service_with_connect_info::()) + .with_graceful_shutdown(shutdown()) + .await?; Ok(()) } diff --git a/panel/server/src/net.rs b/panel/server/src/net.rs new file mode 100644 index 0000000..d6a1815 --- /dev/null +++ b/panel/server/src/net.rs @@ -0,0 +1,68 @@ +//! Request helpers: the panel's public URL and the client's IP address. + +use crate::error::AppError; +use crate::state::AppState; +use crate::store; +use axum::extract::{ConnectInfo, FromRequestParts}; +use axum::http::request::Parts; +use axum::http::HeaderMap; +use std::net::SocketAddr; + +fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|v| v.to_str().ok()).map(str::trim).filter(|v| !v.is_empty()) +} + +/// The URL players reach the panel at, without a trailing slash. +/// +/// Order: the admin's setting → `PUBLIC_URL` → what the request says +/// (honouring `X-Forwarded-*` from a reverse proxy). +pub async fn public_base(state: &AppState, headers: &HeaderMap) -> String { + if let Ok(s) = store::settings(state).await { + if let Some(u) = s.public_url.filter(|u| !u.is_empty()) { + return u.trim_end_matches('/').to_string(); + } + } + if let Some(u) = &state.cfg.public_url { + return u.trim_end_matches('/').to_string(); + } + let host = header(headers, "x-forwarded-host").or_else(|| header(headers, "host")).unwrap_or("localhost:8080"); + let proto = header(headers, "x-forwarded-proto").map(|p| p.split(',').next().unwrap_or(p).trim()).unwrap_or("http"); + format!("{proto}://{}", host.split(',').next().unwrap_or(host).trim()) +} + +/// Host part of a URL (`https://a.b:8443/x` → `a.b`), used for authlib's +/// skin-domain allow-list. +pub fn host_of(url: &str) -> String { + let rest = url.split("://").nth(1).unwrap_or(url); + let authority = rest.split('/').next().unwrap_or(rest); + let host = authority.rsplit('@').next().unwrap_or(authority); + if host.starts_with('[') { + return host.split(']').next().unwrap_or(host).trim_start_matches('[').to_string(); + } + host.split(':').next().unwrap_or(host).to_string() +} + +/// Client IP: the first `X-Forwarded-For` hop, `X-Real-IP`, or the socket. +pub struct ClientIp(pub Option); + +impl FromRequestParts for ClientIp { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let forwarded = header(&parts.headers, "x-forwarded-for").and_then(|v| v.split(',').next()).map(|v| v.trim().to_string()); + let real = header(&parts.headers, "x-real-ip").map(String::from); + let socket = parts.extensions.get::>().map(|c| c.0.ip().to_string()); + Ok(ClientIp(forwarded.or(real).or(socket))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts() { + assert_eq!(host_of("https://panel.example.com/api"), "panel.example.com"); + assert_eq!(host_of("http://localhost:8080"), "localhost"); + assert_eq!(host_of("https://[::1]:8443/"), "::1"); + } +} diff --git a/panel/server/src/routes/account.rs b/panel/server/src/routes/account.rs new file mode 100644 index 0000000..0b2a0b4 --- /dev/null +++ b/panel/server/src/routes/account.rs @@ -0,0 +1,189 @@ +//! Player-facing account API used by the launcher: profile, skin, cape, +//! avatars and the authlib-injector mirror. + +use crate::auth::{AuthUser, UserRow}; +use crate::error::{AppError, AppResult}; +use crate::net; +use crate::state::AppState; +use crate::textures; +use crate::yggdrasil; +use axum::body::Body; +use axum::extract::{Multipart, Path, Query, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use scopenet_shared::PlayerProfile; +use serde::Deserialize; +use std::time::Duration; + +pub async fn profile(State(state): State, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult> { + let base = net::public_base(&state, &headers).await; + Ok(Json(yggdrasil::player_profile(&state, &base, &user).await?)) +} + +/// Read a `file` (+ optional `model`) multipart upload. +pub async fn read_texture_form(form: &mut Multipart) -> AppResult<(Vec, String)> { + let mut model = String::from("classic"); + let mut file = None; + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "model" => model = field.text().await?, + "file" => file = Some(field.bytes().await?.to_vec()), + _ => {} + } + } + Ok((file.ok_or_else(|| AppError::bad_request("no file uploaded"))?, model)) +} + +async fn reload(state: &AppState, id: i64) -> AppResult { + Ok(sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?) +} + +pub async fn upload_skin( + State(state): State, + headers: HeaderMap, + AuthUser(user): AuthUser, + mut form: Multipart, +) -> AppResult> { + let (bytes, model) = read_texture_form(&mut form).await?; + yggdrasil::set_skin(&state, user.id, &bytes, &model).await?; + let base = net::public_base(&state, &headers).await; + Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) +} + +#[derive(Deserialize)] +pub struct ModelInput { + model: String, +} + +pub async fn set_model( + State(state): State, + headers: HeaderMap, + AuthUser(user): AuthUser, + Json(input): Json, +) -> AppResult> { + let model = if input.model == "slim" { "slim" } else { "classic" }; + sqlx::query("UPDATE users SET skin_model = ? WHERE id = ?").bind(model).bind(user.id).execute(&state.db).await?; + let base = net::public_base(&state, &headers).await; + Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) +} + +pub async fn delete_skin(State(state): State, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult> { + sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?; + let base = net::public_base(&state, &headers).await; + Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) +} + +#[derive(Deserialize)] +pub struct CapeInput { + cape_id: Option, +} + +pub async fn set_cape( + State(state): State, + headers: HeaderMap, + AuthUser(user): AuthUser, + Json(input): Json, +) -> AppResult> { + if let Some(id) = input.cape_id { + let allowed = yggdrasil::available_capes(&state, &user).await?; + if !allowed.iter().any(|c| c.id == id) { + return Err(AppError::forbidden("that cape isn't available to you")); + } + } + sqlx::query("UPDATE users SET cape_id = ? WHERE id = ?").bind(input.cape_id).bind(user.id).execute(&state.db).await?; + let base = net::public_base(&state, &headers).await; + Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) +} + +#[derive(Deserialize)] +pub struct AvatarQuery { + #[serde(default)] + size: Option, +} + +/// Player head render by UUID or name. 404 when the player has no skin +/// (callers show their own placeholder). +pub async fn avatar(State(state): State, Path(id): Path, Query(q): Query) -> AppResult { + let user = match yggdrasil::user_by_uuid(&state, &id).await? { + Some(u) => Some(u), + None => crate::auth::find_user_by_name(&state, &id).await?, + }; + let hash = user.and_then(|u| u.skin_hash).ok_or_else(|| AppError::not_found("no skin"))?; + let path = textures::path(&state.cfg.textures_dir(), &hash).ok_or_else(|| AppError::not_found("no skin"))?; + let bytes = tokio::fs::read(path).await.map_err(|_| AppError::not_found("no skin"))?; + let size = q.size.unwrap_or(64); + let png = tokio::task::spawn_blocking(move || textures::render_head(&bytes, size)) + .await + .map_err(|e| AppError::bad_request(e.to_string()))??; + Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=300")], Body::from(png)).into_response()) +} + +// --------------------------------------------------------------------------- +// authlib-injector mirror +// --------------------------------------------------------------------------- + +fn mirror_dir(state: &AppState) -> std::path::PathBuf { + state.cfg.data_dir.join("authlib-injector") +} + +/// Refresh the cached authlib-injector from the official source at most +/// every six hours; serve the cache when the official site is unreachable. +async fn mirror_artifact(state: &AppState) -> AppResult { + use scopenet_core::authlib::{sha256_file, Artifact, OFFICIAL_LATEST}; + let dir = mirror_dir(state); + let index = dir.join("latest.json"); + let fresh = std::fs::metadata(&index) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age < Duration::from_secs(6 * 3600)); + let cached: Option = std::fs::read(&index).ok().and_then(|b| serde_json::from_slice(&b).ok()); + let jar_ok = |a: &Artifact| { + sha256_file(&dir.join("authlib-injector.jar")).map(|h| h == a.checksums.sha256.to_ascii_lowercase()).unwrap_or(false) + }; + if let Some(a) = cached.as_ref().filter(|a| fresh && jar_ok(a)) { + return Ok(a.clone()); + } + let fetched: anyhow::Result = async { + let artifact: Artifact = scopenet_core::http::get_json(&state.http, OFFICIAL_LATEST).await?; + if !jar_ok(&artifact) { + let tmp = dir.join("authlib-injector.jar.download"); + scopenet_core::http::download_one( + &state.http, + &scopenet_core::http::Download::new(artifact.download_url.clone(), tmp.clone(), None, None), + &|_| {}, + ) + .await?; + if sha256_file(&tmp)? != artifact.checksums.sha256.to_ascii_lowercase() { + std::fs::remove_file(&tmp).ok(); + anyhow::bail!("checksum mismatch"); + } + std::fs::rename(&tmp, dir.join("authlib-injector.jar"))?; + } + std::fs::create_dir_all(&dir)?; + std::fs::write(&index, serde_json::to_vec(&artifact)?)?; + Ok(artifact) + } + .await; + match (fetched, cached) { + (Ok(a), _) => Ok(a), + (Err(e), Some(a)) if jar_ok(&a) => { + tracing::warn!("authlib-injector refresh failed, serving cached {}: {e:#}", a.version); + Ok(a) + } + (Err(e), _) => Err(AppError::new(StatusCode::BAD_GATEWAY, format!("authlib-injector unavailable: {e:#}"))), + } +} + +pub async fn authlib_index(State(state): State) -> AppResult> { + let mut a = mirror_artifact(&state).await?; + a.download_url = "/api/v1/launcher/authlib-injector.jar".into(); + Ok(Json(a)) +} + +pub async fn authlib_jar(State(state): State) -> AppResult { + mirror_artifact(&state).await?; + let bytes = tokio::fs::read(mirror_dir(&state).join("authlib-injector.jar")).await?; + Ok(([(header::CONTENT_TYPE, "application/java-archive")], Body::from(bytes)).into_response()) +} diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index 3153b94..18d575b 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -69,13 +69,28 @@ pub async fn stats(_: AdminUser, State(state): State) -> AppResult, + /// Panel-relative texture URL. + skin_url: Option, + /// Across all game servers reporting to the panel. + playtime_secs: i64, + last_seen_ingame: Option, } async fn view(state: &AppState, user: UserRow) -> AppResult { let groups = auth::user_groups(state, user.id).await?; - Ok(AdminUserView { uuid: scopenet_shared::offline_uuid(&user.username), groups, user }) + let (playtime, last_seen): (Option, Option) = + sqlx::query_as("SELECT SUM(playtime_secs), MAX(last_seen) FROM player_stats WHERE uuid = ?") + .bind(&user.uuid) + .fetch_one(&state.db) + .await?; + Ok(AdminUserView { + skin_url: user.skin_hash.as_deref().map(|h| format!("/textures/{h}")), + playtime_secs: playtime.unwrap_or(0), + last_seen_ingame: last_seen, + groups, + user, + }) } pub async fn list_users(_: AdminUser, State(state): State) -> AppResult>> { @@ -95,6 +110,7 @@ pub struct UserInput { email: Option, role: Option, status: Option, + status_reason: Option, groups: Option>, } @@ -135,21 +151,7 @@ pub async fn create_user(_: AdminUser, State(state): State, Json(input check_role(&role)?; let status = input.status.unwrap_or_else(|| "active".into()); check_status(&status)?; - let id: i64 = sqlx::query_scalar( - "INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", - ) - .bind(username) - .bind(auth::hash_password(&password)?) - .bind(input.email.filter(|e| !e.trim().is_empty())) - .bind(&role) - .bind(&status) - .bind(crate::db::now()) - .fetch_one(&state.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"), - e => e.into(), - })?; + let id = auth::create_user(&state, username, &password, input.email.as_deref(), &role, &status).await?; if let Some(groups) = input.groups { set_groups(&state, id, &groups).await?; } @@ -190,6 +192,13 @@ pub async fn update_user( } sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?; } + if let Some(reason) = input.status_reason { + sqlx::query("UPDATE users SET status_reason = ? WHERE id = ?") + .bind(Some(reason.trim()).filter(|r| !r.is_empty())) + .bind(id) + .execute(&state.db) + .await?; + } if let Some(status) = input.status { check_status(&status)?; if me.id == id && status != "active" { @@ -299,9 +308,13 @@ pub async fn put_settings(_: AdminUser, State(state): State, Json(mut Some("-") => None, Some(k) => Some(k.to_string()), }; - if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() { - return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID")); + s.public_url = s.public_url.map(|u| u.trim().trim_end_matches('/').to_string()).filter(|u| !u.is_empty()); + if let Some(u) = &s.public_url { + if !u.starts_with("http://") && !u.starts_with("https://") { + return Err(AppError::bad_request("the public URL must start with https:// (or http://)")); + } } + s.auth.yggdrasil_url = None; // derived, never stored store::kv_set(&state, "settings", &s).await?; settings_view(&state).await } @@ -670,3 +683,166 @@ pub async fn upload_media(_: AdminUser, State(state): State, mut form: } Err(AppError::bad_request("no file uploaded")) } + +// --------------------------------------------------------------------------- +// Skins & capes +// --------------------------------------------------------------------------- + +pub async fn admin_set_skin( + _: AdminUser, + State(state): State, + Path(id): Path, + mut form: Multipart, +) -> AppResult> { + let (bytes, model) = crate::routes::account::read_texture_form(&mut form).await?; + crate::yggdrasil::set_skin(&state, id, &bytes, &model).await?; + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +pub async fn admin_delete_skin(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(id).execute(&state.db).await?; + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +#[derive(Deserialize)] +pub struct AdminCapeInput { + cape_id: Option, +} + +/// Admins can give any cape to anyone (including "private" ones). +pub async fn admin_set_cape( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + if let Some(cape) = input.cape_id { + let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM capes WHERE id = ?").bind(cape).fetch_one(&state.db).await?; + if exists == 0 { + return Err(AppError::not_found("cape not found")); + } + } + sqlx::query("UPDATE users SET cape_id = ? WHERE id = ?").bind(input.cape_id).bind(id).execute(&state.db).await?; + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +#[derive(Serialize)] +pub struct CapeView { + id: i64, + name: String, + url: String, + visibility: String, + allowed_groups: Vec, + wearers: i64, + created_at: String, +} + +async fn cape_views(state: &AppState) -> AppResult> { + let rows: Vec = + sqlx::query_as("SELECT * FROM capes ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; + let mut out = Vec::new(); + for c in rows { + let wearers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE cape_id = ?").bind(c.id).fetch_one(&state.db).await?; + out.push(CapeView { + id: c.id, + url: format!("/textures/{}", c.hash), + allowed_groups: serde_json::from_str(&c.allowed_groups).unwrap_or_default(), + name: c.name, + visibility: c.visibility, + wearers, + created_at: c.created_at, + }); + } + Ok(out) +} + +pub async fn list_capes(_: AdminUser, State(state): State) -> AppResult>> { + Ok(Json(cape_views(&state).await?)) +} + +fn check_visibility(v: &str) -> AppResult<()> { + if !matches!(v, "public" | "groups" | "private") { + return Err(AppError::bad_request("visibility must be public, groups or private")); + } + Ok(()) +} + +/// Multipart: `name`, `visibility`, `allowed_groups` (JSON array), `file`. +pub async fn create_cape(_: AdminUser, State(state): State, mut form: Multipart) -> AppResult>> { + let (mut name, mut visibility, mut groups, mut file) = (String::new(), String::from("public"), String::from("[]"), None); + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "name" => name = field.text().await?.trim().to_string(), + "visibility" => visibility = field.text().await?, + "allowed_groups" => groups = field.text().await?, + "file" => file = Some(field.bytes().await?.to_vec()), + _ => {} + } + } + if name.is_empty() || name.len() > 40 { + return Err(AppError::bad_request("give the cape a name (up to 40 characters)")); + } + check_visibility(&visibility)?; + let groups: Vec = serde_json::from_str(&groups).map_err(|_| AppError::bad_request("allowed_groups must be a JSON list"))?; + let bytes = file.ok_or_else(|| AppError::bad_request("no image uploaded"))?; + let dir = state.cfg.textures_dir(); + let hash = tokio::task::spawn_blocking(move || crate::textures::store(&dir, crate::textures::Kind::Cape, &bytes)) + .await + .map_err(|e| AppError::bad_request(e.to_string()))??; + sqlx::query("INSERT INTO capes (name, hash, visibility, allowed_groups, created_at) VALUES (?, ?, ?, ?, ?)") + .bind(&name) + .bind(hash) + .bind(&visibility) + .bind(serde_json::to_string(&groups)?) + .bind(crate::db::now()) + .execute(&state.db) + .await?; + Ok(Json(cape_views(&state).await?)) +} + +#[derive(Deserialize)] +pub struct CapeUpdate { + name: String, + visibility: String, + #[serde(default)] + allowed_groups: Vec, +} + +pub async fn update_cape( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult>> { + check_visibility(&input.visibility)?; + if input.name.trim().is_empty() { + return Err(AppError::bad_request("the cape needs a name")); + } + sqlx::query("UPDATE capes SET name = ?, visibility = ?, allowed_groups = ? WHERE id = ?") + .bind(input.name.trim()) + .bind(&input.visibility) + .bind(serde_json::to_string(&input.allowed_groups)?) + .bind(id) + .execute(&state.db) + .await?; + Ok(Json(cape_views(&state).await?)) +} + +pub async fn delete_cape(_: AdminUser, State(state): State, Path(id): Path) -> AppResult>> { + sqlx::query("UPDATE users SET cape_id = NULL WHERE cape_id = ?").bind(id).execute(&state.db).await?; + sqlx::query("DELETE FROM capes WHERE id = ?").bind(id).execute(&state.db).await?; + Ok(Json(cape_views(&state).await?)) +} + +/// Auth server details for the Settings page (what to put on game servers). +pub async fn auth_server_info(_: AdminUser, State(state): State, headers: axum::http::HeaderMap) -> AppResult> { + let base = crate::net::public_base(&state, &headers).await; + Ok(Json(json!({ + "public_url": base, + "yggdrasil_url": format!("{base}{}", crate::yggdrasil::ROOT), + "public_key": state.ygg.public_pem, + }))) +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index f866887..5cd7206 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod admin; pub mod meta; pub mod public; @@ -16,7 +17,15 @@ pub fn api(state: &AppState) -> Router { .route("/launcher/events", post(public::event)) .route("/auth/login", post(public::login)) .route("/auth/register", post(public::register)) - .route("/auth/me", get(public::me)); + .route("/auth/me", get(public::me)) + .route("/account/profile", get(account::profile)) + .route("/account/skin", post(account::upload_skin).delete(account::delete_skin)) + .route("/account/skin/model", axum::routing::put(account::set_model)) + .route("/account/cape", axum::routing::put(account::set_cape)) + .route("/avatar/{id}", get(account::avatar)) + .route("/launcher/authlib-injector.json", get(account::authlib_index)) + .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) + .layer(DefaultBodyLimit::max(4 * 1024 * 1024)); let admin = Router::new() .route("/stats", get(admin::stats)) @@ -34,6 +43,11 @@ pub fn api(state: &AppState) -> Router { .route("/instances/{id}/import/upload", post(admin::import_upload)) .route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file)) .route("/uploads", post(admin::upload_media)) + .route("/users/{id}/skin", post(admin::admin_set_skin).delete(admin::admin_delete_skin)) + .route("/users/{id}/cape", axum::routing::put(admin::admin_set_cape)) + .route("/capes", get(admin::list_capes).post(admin::create_cape)) + .route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape)) + .route("/auth-server", get(admin::auth_server_info)) .route("/meta/minecraft", get(meta::minecraft)) .route("/meta/loaders/{loader}", get(meta::loaders)) .route("/modrinth/search", get(meta::modrinth_search)) @@ -42,5 +56,8 @@ pub fn api(state: &AppState) -> Router { .route("/curseforge/mod/{id}/files", get(meta::curseforge_files)) .layer(DefaultBodyLimit::max(upload_limit)); - Router::new().nest("/api/v1", launcher).nest("/api/admin", admin) + Router::new() + .nest("/api/v1", launcher) + .nest("/api/admin", admin) + .merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024))) } diff --git a/panel/server/src/routes/public.rs b/panel/server/src/routes/public.rs index 919223b..bb2721b 100644 --- a/panel/server/src/routes/public.rs +++ b/panel/server/src/routes/public.rs @@ -2,9 +2,12 @@ use crate::auth::{self, AuthUser, MaybeUser, UserRow}; use crate::error::{AppError, AppResult}; +use crate::net::{self, ClientIp}; use crate::state::AppState; use crate::store; +use crate::yggdrasil; use axum::extract::{Path, State}; +use axum::http::HeaderMap; use axum::Json; use scopenet_shared::*; @@ -12,7 +15,7 @@ pub async fn health() -> &'static str { "ok" } -pub async fn manifest(State(state): State, MaybeUser(user): MaybeUser) -> AppResult> { +pub async fn manifest(State(state): State, headers: HeaderMap, MaybeUser(user): MaybeUser) -> AppResult> { let settings = store::settings(&state).await?; let groups = match &user { Some(u) => auth::user_groups(&state, u.id).await?, @@ -30,9 +33,7 @@ pub async fn manifest(State(state): State, MaybeUser(user): MaybeUser) None => None, }; let mut auth_cfg = settings.auth; - if !auth_cfg.microsoft { - auth_cfg.microsoft_client_id = None; - } + auth_cfg.yggdrasil_url = Some(format!("{}{}", net::public_base(&state, &headers).await, yggdrasil::ROOT)); Ok(Json(LauncherManifest { api_version: API_VERSION, panel_version: env!("CARGO_PKG_VERSION").into(), @@ -62,7 +63,18 @@ pub async fn instance_manifest( } async fn find_user(state: &AppState, username: &str) -> AppResult> { - Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(username.trim()).fetch_optional(&state.db).await?) + auth::find_user_by_name(state, username).await +} + +/// Panel token + a fresh game session for authlib-injector. +async fn signed_in(state: &AppState, user: &UserRow) -> AppResult { + let (access_token, client_token) = yggdrasil::issue_token(state, user.id, None).await?; + Ok(AuthResponse { + token: state.keys.issue(user)?, + user: auth::public_user(state, user).await?, + pending: false, + yggdrasil: Some(YggdrasilTokens { access_token, client_token }), + }) } pub async fn login(State(state): State, Json(req): Json) -> AppResult> { @@ -77,14 +89,16 @@ pub async fn login(State(state): State, Json(req): Json) state.login_guard.succeed(&username); match user.status.as_str() { "pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")), - "disabled" => return Err(AppError::forbidden("this account has been disabled")), + "disabled" => { + return Err(AppError::forbidden(user.status_reason.clone().unwrap_or_else(|| "this account has been disabled".into()))) + } _ => {} } if !settings.auth.panel_accounts && !user.is_admin() { return Err(AppError::forbidden("account sign-in is currently disabled")); } sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?; - Ok(Json(AuthResponse { token: state.keys.issue(&user)?, user: auth::public_user(&state, &user).await?, pending: false })) + Ok(Json(signed_in(&state, &user).await?)) } pub async fn register(State(state): State, Json(req): Json) -> AppResult> { @@ -101,23 +115,17 @@ pub async fn register(State(state): State, Json(req): Json, AuthUser(user): AuthUser) -> AppResult> { @@ -127,9 +135,22 @@ pub async fn me(State(state): State, AuthUser(user): AuthUser) -> AppR pub async fn event( State(state): State, MaybeUser(user): MaybeUser, + ClientIp(ip): ClientIp, Json(ev): Json, ) -> AppResult> { let kind = if ev.kind == "launch" { "launch" } else { "other" }; + // Remember where signed-in players launch from, so game servers can + // require "joined through the launcher" (see game server settings). + if let (Some(u), Some(ip), "launch") = (&user, &ip, kind) { + sqlx::query("INSERT INTO launcher_sessions (user_id, ip, created_at) VALUES (?, ?, ?)") + .bind(u.id) + .bind(ip) + .bind(crate::db::now()) + .execute(&state.db) + .await?; + let cutoff = (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + sqlx::query("DELETE FROM launcher_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?; + } let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::()); sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)") .bind(ev.instance_id.chars().take(64).collect::()) diff --git a/panel/server/src/state.rs b/panel/server/src/state.rs index aeb078c..f6ab705 100644 --- a/panel/server/src/state.rs +++ b/panel/server/src/state.rs @@ -7,7 +7,10 @@ use std::sync::Arc; pub struct AppState { pub db: SqlitePool, pub cfg: Arc, + /// Panel session tokens (JWT). pub keys: Arc, + /// Auth server signing key (textures, chat certificates). + pub ygg: Arc, pub http: reqwest::Client, pub login_guard: Arc, } diff --git a/panel/server/src/store.rs b/panel/server/src/store.rs index 0dc7da8..e93f15f 100644 --- a/panel/server/src/store.rs +++ b/panel/server/src/store.rs @@ -27,6 +27,9 @@ pub struct Settings { pub curseforge_api_key: Option, /// Where players can download the launcher (shown on the dashboard). pub launcher_download_url: Option, + /// Public address of the panel (e.g. https://panel.example.com). Used in + /// skin URLs and the auth server metadata. Falls back to the request. + pub public_url: Option, } pub async fn settings(state: &AppState) -> AppResult { diff --git a/panel/server/src/textures.rs b/panel/server/src/textures.rs new file mode 100644 index 0000000..35debd2 --- /dev/null +++ b/panel/server/src/textures.rs @@ -0,0 +1,124 @@ +//! Skins and capes: validation, storage (content-addressed PNGs under +//! `data/textures/`) and rendering of player heads for avatars. + +use crate::error::{AppError, AppResult}; +use image::{imageops, ImageFormat, RgbaImage}; +use sha2::{Digest, Sha256}; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Skin, + Cape, +} + +fn decode(bytes: &[u8]) -> AppResult { + if bytes.len() > 2 * 1024 * 1024 { + return Err(AppError::bad_request("image too large (2 MB max)")); + } + let img = + image::load_from_memory_with_format(bytes, ImageFormat::Png).map_err(|_| AppError::bad_request("that isn't a valid PNG image"))?; + Ok(img.to_rgba8()) +} + +fn check_size(kind: Kind, w: u32, h: u32) -> AppResult<()> { + let ok = match kind { + // The vanilla client only accepts these two layouts. + Kind::Skin => (w, h) == (64, 64) || (w, h) == (64, 32), + // 64x32 is standard; HD capes are multiples of it; 22x17 is the old format. + Kind::Cape => (w % 64 == 0 && h * 2 == w && w <= 1024) || (w, h) == (22, 17), + }; + if ok { + Ok(()) + } else { + Err(AppError::bad_request(match kind { + Kind::Skin => format!("skins must be 64×64 (or legacy 64×32) pixels — this one is {w}×{h}"), + Kind::Cape => format!("capes must be 64×32 pixels — this one is {w}×{h}"), + })) + } +} + +/// Validate, re-encode (strips metadata and anything smuggled inside the +/// file) and store a texture. Returns its hash. +pub fn store(dir: &Path, kind: Kind, bytes: &[u8]) -> AppResult { + let img = decode(bytes)?; + check_size(kind, img.width(), img.height())?; + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .map_err(|e| AppError::bad_request(format!("couldn't process image: {e}")))?; + let hash = hex::encode(Sha256::digest(&out)); + std::fs::create_dir_all(dir)?; + let path = dir.join(format!("{hash}.png")); + if !path.exists() { + std::fs::write(&path, &out)?; + } + Ok(hash) +} + +pub fn path(dir: &Path, hash: &str) -> Option { + // Hashes are hex only — never let a request escape the directory. + (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| dir.join(format!("{hash}.png"))) +} + +/// Front view of the head (face + hat layer), scaled with nearest-neighbour +/// so pixels stay crisp. +pub fn render_head(skin_png: &[u8], size: u32) -> AppResult> { + let skin = decode(skin_png)?; + let mut face = imageops::crop_imm(&skin, 8, 8, 8, 8).to_image(); + if skin.height() >= 16 && skin.width() >= 48 { + let hat = imageops::crop_imm(&skin, 40, 8, 8, 8).to_image(); + // Some skins fill the hat layer with an opaque colour; ignore it then. + let opaque_hat = hat.pixels().all(|p| p[3] == 255); + if !opaque_hat { + imageops::overlay(&mut face, &hat, 0, 0); + } + } + let size = size.clamp(8, 512); + let scaled = imageops::resize(&face, size, size, imageops::FilterType::Nearest); + let mut out = Vec::new(); + scaled.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| AppError::bad_request(e.to_string()))?; + Ok(out) +} + +#[cfg(test)] +pub mod tests { + use super::*; + + pub fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let img = RgbaImage::from_pixel(w, h, image::Rgba(rgba)); + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap(); + out + } + + #[test] + fn validates_and_stores() { + let dir = tempfile::tempdir().unwrap(); + let h1 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap(); + let h2 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap(); + assert_eq!(h1, h2, "content-addressed"); + assert!(path(dir.path(), &h1).unwrap().exists()); + assert!(store(dir.path(), Kind::Skin, &png(32, 32, [0, 0, 0, 255])).is_err()); + assert!(store(dir.path(), Kind::Cape, &png(64, 32, [0, 0, 0, 255])).is_ok()); + assert!(store(dir.path(), Kind::Skin, b"not a png").is_err()); + assert!(path(dir.path(), "../../etc/passwd").is_none()); + } + + #[test] + fn renders_heads() { + let mut skin = RgbaImage::from_pixel(64, 64, image::Rgba([0, 0, 0, 0])); + for x in 8..16 { + for y in 8..16 { + skin.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); + } + } + skin.put_pixel(40, 8, image::Rgba([0, 0, 255, 255])); // one hat pixel + let mut bytes = Vec::new(); + skin.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap(); + let head = image::load_from_memory(&render_head(&bytes, 64).unwrap()).unwrap().to_rgba8(); + assert_eq!(head.dimensions(), (64, 64)); + assert_eq!(head.get_pixel(0, 0).0, [0, 0, 255, 255], "hat overlays the face"); + assert_eq!(head.get_pixel(63, 63).0, [255, 0, 0, 255]); + } +} diff --git a/panel/server/src/yggdrasil/keys.rs b/panel/server/src/yggdrasil/keys.rs new file mode 100644 index 0000000..bb04f2f --- /dev/null +++ b/panel/server/src/yggdrasil/keys.rs @@ -0,0 +1,74 @@ +//! The auth server's RSA key. It signs skin/cape data ("textures") and +//! player chat certificates; game clients and servers learn the public half +//! from the Yggdrasil metadata via authlib-injector. + +use anyhow::{Context, Result}; +use base64::Engine; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey, LineEnding}; +use rsa::signature::{SignatureEncoding, Signer}; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use sha1::Sha1; +use std::path::Path; + +pub const KEY_BITS: usize = 4096; + +pub struct Keys { + signer: SigningKey, + /// `-----BEGIN PUBLIC KEY-----` (X.509 SubjectPublicKeyInfo). + pub public_pem: String, + /// DER of the same, base64 — the format of Mojang's `/publickeys`. + pub public_der_b64: String, +} + +impl Keys { + pub fn from_private(key: RsaPrivateKey) -> Result { + let public = RsaPublicKey::from(&key); + let public_pem = public.to_public_key_pem(LineEnding::LF)?; + let public_der_b64 = base64::engine::general_purpose::STANDARD.encode(public.to_public_key_der()?.as_bytes()); + Ok(Self { signer: SigningKey::::new(key), public_pem, public_der_b64 }) + } + + /// Load `path`, or generate and save a new key if it doesn't exist. + pub fn load_or_create(path: &Path) -> Result { + if let Ok(pem) = std::fs::read_to_string(path) { + let key = RsaPrivateKey::from_pkcs8_pem(&pem).with_context(|| format!("reading {}", path.display()))?; + return Self::from_private(key); + } + tracing::info!("generating the auth server signing key ({KEY_BITS}-bit RSA, one-time)…"); + let key = RsaPrivateKey::new(&mut rand::thread_rng(), KEY_BITS)?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, key.to_pkcs8_pem(LineEnding::LF)?.as_bytes())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).ok(); + } + Self::from_private(key) + } + + /// SHA1withRSA, base64 — what Mojang uses for every Yggdrasil signature. + pub fn sign_b64(&self, data: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(self.signer.sign(data).to_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rsa::pkcs1v15::{Signature, VerifyingKey}; + use rsa::pkcs8::DecodePublicKey; + use rsa::signature::Verifier; + + #[test] + fn signs_verifiably() { + let key = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap(); + let keys = Keys::from_private(key).unwrap(); + let sig = base64::engine::general_purpose::STANDARD.decode(keys.sign_b64(b"hello")).unwrap(); + let public = RsaPublicKey::from_public_key_pem(&keys.public_pem).unwrap(); + VerifyingKey::::new(public).verify(b"hello", &Signature::try_from(sig.as_slice()).unwrap()).unwrap(); + assert!(keys.public_pem.starts_with("-----BEGIN PUBLIC KEY-----")); + } +} diff --git a/panel/server/src/yggdrasil/mod.rs b/panel/server/src/yggdrasil/mod.rs new file mode 100644 index 0000000..0970df9 --- /dev/null +++ b/panel/server/src/yggdrasil/mod.rs @@ -0,0 +1,742 @@ +//! A Yggdrasil-compatible authentication server, following the +//! authlib-injector specification: +//! +//! +//! Game clients and servers run authlib-injector pointed at +//! `{panel}/api/yggdrasil`. Sign-in, server joins, skins and capes then all +//! come from the panel — no Mojang or Microsoft account needed. + +pub mod keys; + +use crate::auth::{self, UserRow}; +use crate::error::AppResult; +use crate::net::{self, ClientIp}; +use crate::state::AppState; +use crate::store; +use crate::textures; +use axum::body::Body; +use axum::extract::{Multipart, Path, Query, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use base64::Engine; +use rand::RngCore; +use scopenet_shared::{CapeInfo, PlayerProfile}; +use serde::Deserialize; +use serde_json::{json, Value}; + +pub const ROOT: &str = "/api/yggdrasil"; +const TOKEN_DAYS: i64 = 30; +const MAX_TOKENS_PER_USER: i64 = 10; +const SESSION_SECS: i64 = 60; + +// --------------------------------------------------------------------------- +// Errors (Yggdrasil has its own error shape) +// --------------------------------------------------------------------------- + +pub struct YggError { + status: StatusCode, + error: &'static str, + message: String, +} + +impl YggError { + fn forbidden(message: impl Into) -> Self { + Self { status: StatusCode::FORBIDDEN, error: "ForbiddenOperationException", message: message.into() } + } + fn bad_request(message: impl Into) -> Self { + Self { status: StatusCode::BAD_REQUEST, error: "IllegalArgumentException", message: message.into() } + } + fn invalid_token() -> Self { + Self::forbidden("Invalid token.") + } +} + +impl IntoResponse for YggError { + fn into_response(self) -> Response { + (self.status, Json(json!({ "error": self.error, "errorMessage": self.message }))).into_response() + } +} + +impl From for YggError { + fn from(e: crate::error::AppError) -> Self { + Self { status: e.status, error: "InternalServerError", message: e.message } + } +} + +impl From for YggError { + fn from(e: sqlx::Error) -> Self { + crate::error::AppError::from(e).into() + } +} + +type YggResult = Result; + +fn no_content() -> Response { + StatusCode::NO_CONTENT.into_response() +} + +// --------------------------------------------------------------------------- +// Helpers shared with the launcher/admin APIs +// --------------------------------------------------------------------------- + +pub fn undashed(uuid: &str) -> String { + uuid.replace('-', "").to_ascii_lowercase() +} + +pub fn dashed(uuid: &str) -> Option { + let u = undashed(uuid); + (u.len() == 32 && u.chars().all(|c| c.is_ascii_hexdigit())) + .then(|| format!("{}-{}-{}-{}-{}", &u[0..8], &u[8..12], &u[12..16], &u[16..20], &u[20..32])) +} + +fn random_token() -> String { + let mut b = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut b); + hex::encode(b) +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct CapeRow { + pub id: i64, + pub name: String, + pub hash: String, + pub visibility: String, + pub allowed_groups: String, + pub created_at: String, +} + +impl CapeRow { + pub fn info(&self, base: &str) -> CapeInfo { + CapeInfo { id: self.id, name: self.name.clone(), url: texture_url(base, &self.hash) } + } +} + +pub fn texture_url(base: &str, hash: &str) -> String { + format!("{base}/textures/{hash}") +} + +pub async fn user_by_uuid(state: &AppState, uuid: &str) -> AppResult> { + let Some(d) = dashed(uuid) else { return Ok(None) }; + Ok(sqlx::query_as("SELECT * FROM users WHERE uuid = ?").bind(d).fetch_optional(&state.db).await?) +} + +pub async fn cape_of(state: &AppState, user: &UserRow) -> AppResult> { + let Some(id) = user.cape_id else { return Ok(None) }; + Ok(sqlx::query_as("SELECT * FROM capes WHERE id = ?").bind(id).fetch_optional(&state.db).await?) +} + +/// Capes the player may choose themselves. +pub async fn available_capes(state: &AppState, user: &UserRow) -> AppResult> { + let groups = auth::user_groups(state, user.id).await?; + let all: Vec = sqlx::query_as("SELECT * FROM capes ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; + Ok(all + .into_iter() + .filter(|c| { + user.is_admin() + || c.visibility == "public" + || (c.visibility == "groups" && { + let allowed: Vec = serde_json::from_str(&c.allowed_groups).unwrap_or_default(); + allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g))) + }) + }) + .collect()) +} + +pub async fn player_profile(state: &AppState, base: &str, user: &UserRow) -> AppResult { + Ok(PlayerProfile { + uuid: user.uuid.clone(), + name: user.username.clone(), + skin_url: user.skin_hash.as_deref().map(|h| texture_url(base, h)), + skin_model: user.skin_model.clone(), + cape: cape_of(state, user).await?.map(|c| c.info(base)), + available_capes: available_capes(state, user).await?.iter().map(|c| c.info(base)).collect(), + }) +} + +/// The base64 `textures` property value. +async fn textures_value(state: &AppState, base: &str, user: &UserRow) -> AppResult { + let mut textures = serde_json::Map::new(); + if let Some(hash) = &user.skin_hash { + let mut skin = json!({ "url": texture_url(base, hash) }); + if user.skin_model == "slim" { + skin["metadata"] = json!({ "model": "slim" }); + } + textures.insert("SKIN".into(), skin); + } + if let Some(cape) = cape_of(state, user).await? { + textures.insert("CAPE".into(), json!({ "url": texture_url(base, &cape.hash) })); + } + let value = json!({ + "timestamp": chrono::Utc::now().timestamp_millis(), + "profileId": undashed(&user.uuid), + "profileName": user.username, + "textures": textures, + }); + Ok(base64::engine::general_purpose::STANDARD.encode(value.to_string())) +} + +/// A full game profile, optionally with signed properties. +pub async fn profile_json(state: &AppState, base: &str, user: &UserRow, signed: bool) -> AppResult { + let value = textures_value(state, base, user).await?; + let mut textures = json!({ "name": "textures", "value": value }); + let mut uploadable = json!({ "name": "uploadableTextures", "value": "skin" }); + if signed { + textures["signature"] = json!(state.ygg.sign_b64(value.as_bytes())); + uploadable["signature"] = json!(state.ygg.sign_b64(b"skin")); + } + Ok(json!({ "id": undashed(&user.uuid), "name": user.username, "properties": [textures, uploadable] })) +} + +fn short_profile(user: &UserRow) -> Value { + json!({ "id": undashed(&user.uuid), "name": user.username }) +} + +/// Issue a game access token for `user_id`. +pub async fn issue_token(state: &AppState, user_id: i64, client_token: Option) -> AppResult<(String, String)> { + let access = random_token(); + let client = client_token.filter(|c| !c.is_empty() && c.len() <= 128).unwrap_or_else(random_token); + let now = chrono::Utc::now(); + sqlx::query("DELETE FROM ygg_tokens WHERE expires_at < ?").bind(crate::db::now()).execute(&state.db).await?; + sqlx::query("INSERT INTO ygg_tokens (access_token, client_token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)") + .bind(&access) + .bind(&client) + .bind(user_id) + .bind(crate::db::now()) + .bind((now + chrono::Duration::days(TOKEN_DAYS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .execute(&state.db) + .await?; + // Keep only the newest few sessions per account. + sqlx::query( + "DELETE FROM ygg_tokens WHERE user_id = ? AND access_token NOT IN + (SELECT access_token FROM ygg_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT ?)", + ) + .bind(user_id) + .bind(user_id) + .bind(MAX_TOKENS_PER_USER) + .execute(&state.db) + .await?; + Ok((access, client)) +} + +/// The active account behind a valid token. +pub async fn token_user(state: &AppState, access: &str, client: Option<&str>) -> AppResult> { + let row: Option<(i64, String)> = + sqlx::query_as("SELECT user_id, client_token FROM ygg_tokens WHERE access_token = ? AND expires_at > ?") + .bind(access) + .bind(crate::db::now()) + .fetch_optional(&state.db) + .await?; + let Some((user_id, client_token)) = row else { return Ok(None) }; + if client.is_some_and(|c| !c.is_empty() && c != client_token) { + return Ok(None); + } + let user: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(user_id).fetch_optional(&state.db).await?; + Ok(user.filter(|u| u.status == "active")) +} + +async fn check_password(state: &AppState, username: &str, password: &str) -> YggResult { + state.login_guard.check(username).map_err(|e| YggError::forbidden(e.message))?; + // Email or username (`feature.non_email_login`). + let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?)") + .bind(username.trim()) + .bind(username.trim()) + .fetch_optional(&state.db) + .await?; + let Some(user) = user.filter(|u| auth::verify_password(password, &u.password_hash)) else { + state.login_guard.fail(username); + return Err(YggError::forbidden("Invalid credentials. Invalid username or password.")); + }; + state.login_guard.succeed(username); + match user.status.as_str() { + "active" => Ok(user), + "pending" => Err(YggError::forbidden("Your account is waiting for an admin to approve it.")), + _ => Err(YggError::forbidden(user.status_reason.clone().unwrap_or_else(|| "This account has been disabled.".into()))), + } +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +async fn metadata(State(state): State, headers: HeaderMap) -> AppResult> { + let base = net::public_base(&state, &headers).await; + let branding = store::branding(&state).await?; + Ok(Json(json!({ + "meta": { + "serverName": branding.name, + "implementationName": "SCOPENET", + "implementationVersion": env!("CARGO_PKG_VERSION"), + "links": { "homepage": base, "register": base }, + "feature.non_email_login": true, + "feature.enable_profile_key": true, + "feature.no_mojang_namespace": true, + }, + "skinDomains": [net::host_of(&base)], + "signaturePublickey": state.ygg.public_pem, + }))) +} + +// --------------------------------------------------------------------------- +// authserver +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthenticateReq { + username: String, + password: String, + #[serde(default)] + client_token: Option, + #[serde(default)] + request_user: bool, +} + +fn user_json(user: &UserRow) -> Value { + json!({ "id": undashed(&user.uuid), "properties": [{ "name": "preferredLanguage", "value": "en" }] }) +} + +async fn authenticate(State(state): State, Json(req): Json) -> YggResult> { + let user = check_password(&state, &req.username, &req.password).await?; + let (access, client) = issue_token(&state, user.id, req.client_token).await?; + let mut body = json!({ + "accessToken": access, + "clientToken": client, + "availableProfiles": [short_profile(&user)], + "selectedProfile": short_profile(&user), + }); + if req.request_user { + body["user"] = user_json(&user); + } + Ok(Json(body)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RefreshReq { + access_token: String, + #[serde(default)] + client_token: Option, + #[serde(default)] + request_user: bool, +} + +async fn refresh(State(state): State, Json(req): Json) -> YggResult> { + let client_token: Option = sqlx::query_scalar("SELECT client_token FROM ygg_tokens WHERE access_token = ?") + .bind(&req.access_token) + .fetch_optional(&state.db) + .await?; + let user = token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?; + sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?; + let (access, client) = issue_token(&state, user.id, client_token).await?; + let mut body = json!({ "accessToken": access, "clientToken": client, "selectedProfile": short_profile(&user) }); + if req.request_user { + body["user"] = user_json(&user); + } + Ok(Json(body)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenReq { + access_token: String, + #[serde(default)] + client_token: Option, +} + +async fn validate(State(state): State, Json(req): Json) -> YggResult { + token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?; + Ok(no_content()) +} + +async fn invalidate(State(state): State, Json(req): Json) -> YggResult { + sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?; + Ok(no_content()) +} + +#[derive(Deserialize)] +struct SignoutReq { + username: String, + password: String, +} + +async fn signout(State(state): State, Json(req): Json) -> YggResult { + let user = check_password(&state, &req.username, &req.password).await?; + sqlx::query("DELETE FROM ygg_tokens WHERE user_id = ?").bind(user.id).execute(&state.db).await?; + Ok(no_content()) +} + +// --------------------------------------------------------------------------- +// sessionserver +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JoinReq { + access_token: String, + selected_profile: String, + server_id: String, +} + +async fn join(State(state): State, ClientIp(ip): ClientIp, Json(req): Json) -> YggResult { + let user = token_user(&state, &req.access_token, None).await?.ok_or_else(YggError::invalid_token)?; + if undashed(&req.selected_profile) != undashed(&user.uuid) { + return Err(YggError::forbidden("Invalid token.")); + } + if req.server_id.is_empty() || req.server_id.len() > 64 { + return Err(YggError::bad_request("invalid serverId")); + } + let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + sqlx::query("DELETE FROM ygg_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?; + sqlx::query("INSERT OR REPLACE INTO ygg_sessions (server_id, user_id, ip, created_at) VALUES (?, ?, ?, ?)") + .bind(&req.server_id) + .bind(user.id) + .bind(ip) + .bind(crate::db::now()) + .execute(&state.db) + .await?; + Ok(no_content()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HasJoinedQuery { + username: String, + server_id: String, + #[serde(default)] + ip: Option, +} + +async fn has_joined(State(state): State, headers: HeaderMap, Query(q): Query) -> YggResult { + let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let row: Option<(i64, Option)> = sqlx::query_as("SELECT user_id, ip FROM ygg_sessions WHERE server_id = ? AND created_at >= ?") + .bind(&q.server_id) + .bind(cutoff) + .fetch_optional(&state.db) + .await?; + let Some((user_id, joined_ip)) = row else { return Ok(no_content()) }; + let Some(user): Option = + sqlx::query_as("SELECT * FROM users WHERE id = ? AND status = 'active'").bind(user_id).fetch_optional(&state.db).await? + else { + return Ok(no_content()); + }; + if !user.username.eq_ignore_ascii_case(&q.username) { + return Ok(no_content()); + } + if let (Some(expected), Some(actual)) = (q.ip.as_deref().filter(|i| !i.is_empty()), joined_ip.as_deref()) { + if expected != actual { + return Ok(no_content()); + } + } + let base = net::public_base(&state, &headers).await; + Ok(Json(profile_json(&state, &base, &user, true).await?).into_response()) +} + +#[derive(Deserialize)] +struct ProfileQuery { + #[serde(default)] + unsigned: Option, +} + +async fn session_profile( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Query(q): Query, +) -> YggResult { + let Some(user) = user_by_uuid(&state, &uuid).await? else { return Ok(no_content()) }; + let signed = q.unsigned.as_deref() == Some("false"); + let base = net::public_base(&state, &headers).await; + Ok(Json(profile_json(&state, &base, &user, signed).await?).into_response()) +} + +// --------------------------------------------------------------------------- +// Mojang-style profile API +// --------------------------------------------------------------------------- + +async fn profiles_by_names(State(state): State, Json(names): Json>) -> YggResult>> { + let mut out = Vec::new(); + for name in names.iter().take(100) { + if let Some(user) = auth::find_user_by_name(&state, name).await? { + if !out.iter().any(|v: &Value| v["id"] == undashed(&user.uuid)) { + out.push(short_profile(&user)); + } + } + } + Ok(Json(out)) +} + +async fn profile_by_name(State(state): State, Path(name): Path) -> YggResult { + match auth::find_user_by_name(&state, &name).await? { + Some(user) => Ok(Json(short_profile(&user)).into_response()), + None => Ok(no_content()), + } +} + +fn bearer(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))) +} + +async fn bearer_user(state: &AppState, headers: &HeaderMap) -> YggResult { + let token = bearer(headers).ok_or_else(|| YggError { + status: StatusCode::UNAUTHORIZED, + error: "Unauthorized", + message: "Missing token.".into(), + })?; + token_user(state, token, None).await?.ok_or_else(|| YggError { + status: StatusCode::UNAUTHORIZED, + error: "Unauthorized", + message: "Invalid token.".into(), + }) +} + +/// `PUT /api/user/profile/{uuid}/skin` (multipart: `model`, `file`). +async fn upload_texture( + State(state): State, + headers: HeaderMap, + Path((uuid, kind)): Path<(String, String)>, + mut form: Multipart, +) -> YggResult { + let user = bearer_user(&state, &headers).await?; + if undashed(&uuid) != undashed(&user.uuid) { + return Err(YggError::forbidden("You can only change your own skin.")); + } + if kind != "skin" { + return Err(YggError::forbidden("Capes are assigned by the server admins.")); + } + let mut model = String::from("classic"); + let mut file = None; + while let Some(field) = form.next_field().await.map_err(|e| YggError::bad_request(e.to_string()))? { + match field.name().unwrap_or_default() { + "model" => model = field.text().await.map_err(|e| YggError::bad_request(e.to_string()))?, + "file" => file = Some(field.bytes().await.map_err(|e| YggError::bad_request(e.to_string()))?), + _ => {} + } + } + let bytes = file.ok_or_else(|| YggError::bad_request("no file"))?; + set_skin(&state, user.id, &bytes, &model).await?; + Ok(no_content()) +} + +async fn delete_texture( + State(state): State, + headers: HeaderMap, + Path((uuid, kind)): Path<(String, String)>, +) -> YggResult { + let user = bearer_user(&state, &headers).await?; + if undashed(&uuid) != undashed(&user.uuid) || kind != "skin" { + return Err(YggError::forbidden("Not allowed.")); + } + sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?; + Ok(no_content()) +} + +/// Store a skin for a user (validated PNG, "classic" or "slim"). +pub async fn set_skin(state: &AppState, user_id: i64, bytes: &[u8], model: &str) -> AppResult<()> { + let model = if model == "slim" { "slim" } else { "classic" }; + let dir = state.cfg.textures_dir(); + let data = bytes.to_vec(); + let hash = tokio::task::spawn_blocking(move || textures::store(&dir, textures::Kind::Skin, &data)) + .await + .map_err(|e| crate::error::AppError::bad_request(e.to_string()))??; + sqlx::query("UPDATE users SET skin_hash = ?, skin_model = ? WHERE id = ?") + .bind(hash) + .bind(model) + .bind(user_id) + .execute(&state.db) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// minecraftservices (1.19+ chat signing, social features) +// --------------------------------------------------------------------------- + +fn iso_millis(t: chrono::DateTime) -> String { + t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +/// PEM exactly as Minecraft's `Crypt.rsaPublicKeyToString` writes it (MIME +/// base64: 76-char lines, CRLF) — the V1 signature covers this text. +fn mojang_pem(label: &str, der: &[u8]) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let lines: Vec<&str> = b64.as_bytes().chunks(76).map(|c| std::str::from_utf8(c).unwrap()).collect(); + format!("-----BEGIN {label}-----\n{}\n-----END {label}-----\n", lines.join("\r\n")) +} + +#[derive(sqlx::FromRow)] +struct PlayerKeyRow { + private_pem: String, + public_pem: String, + signature_v1: String, + signature_v2: String, + expires_at: String, + refreshed_after: String, +} + +async fn player_certificates(State(state): State, headers: HeaderMap) -> YggResult> { + let user = bearer_user(&state, &headers).await?; + let existing: Option = + sqlx::query_as("SELECT * FROM player_keys WHERE user_id = ?").bind(user.id).fetch_optional(&state.db).await?; + let row = match existing.filter(|k| k.refreshed_after > iso_millis(chrono::Utc::now())) { + Some(k) => k, + None => { + let row = generate_player_key(&state, &user).await?; + sqlx::query( + "INSERT OR REPLACE INTO player_keys (user_id, private_pem, public_pem, signature_v1, signature_v2, expires_at, refreshed_after) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(user.id) + .bind(&row.private_pem) + .bind(&row.public_pem) + .bind(&row.signature_v1) + .bind(&row.signature_v2) + .bind(&row.expires_at) + .bind(&row.refreshed_after) + .execute(&state.db) + .await?; + row + } + }; + Ok(Json(json!({ + "keyPair": { "privateKey": row.private_pem, "publicKey": row.public_pem }, + "publicKeySignature": row.signature_v1, + "publicKeySignatureV2": row.signature_v2, + "expiresAt": row.expires_at, + "refreshedAfter": row.refreshed_after, + }))) +} + +async fn generate_player_key(state: &AppState, user: &UserRow) -> YggResult { + use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey}; + let key = tokio::task::spawn_blocking(|| rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048)) + .await + .map_err(|e| YggError::bad_request(e.to_string()))? + .map_err(|e| YggError::bad_request(e.to_string()))?; + let public_der = rsa::RsaPublicKey::from(&key).to_public_key_der().map_err(|e| YggError::bad_request(e.to_string()))?; + let private_der = key.to_pkcs8_der().map_err(|e| YggError::bad_request(e.to_string()))?; + let now = chrono::Utc::now(); + let expires = now + chrono::Duration::hours(48); + let refreshed_after = now + chrono::Duration::hours(40); + let public_pem = mojang_pem("RSA PUBLIC KEY", public_der.as_bytes()); + + // V2 (1.19.1+): uuid msb, uuid lsb, expiry millis (big-endian), key DER. + let uuid = uuid::Uuid::parse_str(&user.uuid).map_err(|e| YggError::bad_request(e.to_string()))?; + let mut v2 = Vec::with_capacity(24 + public_der.as_bytes().len()); + v2.extend_from_slice(uuid.as_bytes()); + v2.extend_from_slice(&expires.timestamp_millis().to_be_bytes()); + v2.extend_from_slice(public_der.as_bytes()); + // V1 (1.19.0): expiry millis as text + the PEM text. + let v1 = format!("{}{}", expires.timestamp_millis(), public_pem); + + Ok(PlayerKeyRow { + private_pem: mojang_pem("RSA PRIVATE KEY", private_der.as_bytes()), + signature_v1: state.ygg.sign_b64(v1.as_bytes()), + signature_v2: state.ygg.sign_b64(&v2), + public_pem, + expires_at: iso_millis(expires), + refreshed_after: iso_millis(refreshed_after), + }) +} + +async fn player_attributes(State(state): State, headers: HeaderMap) -> YggResult> { + bearer_user(&state, &headers).await?; + Ok(Json(json!({ + "privileges": { + "onlineChat": { "enabled": true }, + "multiplayerServer": { "enabled": true }, + "multiplayerRealms": { "enabled": false }, + "telemetry": { "enabled": false }, + }, + "profanityFilterPreferences": { "profanityFilterOn": false }, + }))) +} + +async fn blocklist(State(state): State, headers: HeaderMap) -> YggResult> { + bearer_user(&state, &headers).await?; + Ok(Json(json!({ "blockedProfiles": [] }))) +} + +async fn public_keys(State(state): State) -> Json { + let key = json!([{ "publicKey": state.ygg.public_der_b64 }]); + Json(json!({ "profilePropertyKeys": key, "playerCertificateKeys": key })) +} + +async fn services_profile(State(state): State, headers: HeaderMap) -> YggResult> { + let user = bearer_user(&state, &headers).await?; + let base = net::public_base(&state, &headers).await; + let skins: Vec = user + .skin_hash + .iter() + .map(|h| json!({ "id": h, "state": "ACTIVE", "url": texture_url(&base, h), "variant": if user.skin_model == "slim" { "SLIM" } else { "CLASSIC" } })) + .collect(); + let capes: Vec = cape_of(&state, &user) + .await? + .iter() + .map(|c| json!({ "id": c.id.to_string(), "state": "ACTIVE", "url": texture_url(&base, &c.hash), "alias": c.name })) + .collect(); + Ok(Json(json!({ "id": undashed(&user.uuid), "name": user.username, "skins": skins, "capes": capes }))) +} + +// --------------------------------------------------------------------------- +// Texture files +// --------------------------------------------------------------------------- + +pub async fn texture_file(State(state): State, Path(hash): Path) -> Response { + let Some(path) = textures::path(&state.cfg.textures_dir(), &hash) else { return StatusCode::NOT_FOUND.into_response() }; + match tokio::fs::read(path).await { + Ok(bytes) => { + ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], Body::from(bytes)) + .into_response() + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +pub fn routes() -> Router { + let p = |path: &str| format!("{ROOT}{path}"); + Router::new() + .route(ROOT, get(metadata)) + .route(&p("/"), get(metadata)) + .route(&p("/authserver/authenticate"), post(authenticate)) + .route(&p("/authserver/refresh"), post(refresh)) + .route(&p("/authserver/validate"), post(validate)) + .route(&p("/authserver/invalidate"), post(invalidate)) + .route(&p("/authserver/signout"), post(signout)) + .route(&p("/sessionserver/session/minecraft/join"), post(join)) + .route(&p("/sessionserver/session/minecraft/hasJoined"), get(has_joined)) + .route(&p("/sessionserver/session/minecraft/profile/{uuid}"), get(session_profile)) + .route(&p("/api/profiles/minecraft"), post(profiles_by_names)) + .route(&p("/api/users/profiles/minecraft/{name}"), get(profile_by_name)) + .route(&p("/api/user/profile/{uuid}/{kind}"), put(upload_texture).delete(delete_texture)) + .route(&p("/minecraftservices/player/certificates"), post(player_certificates)) + .route(&p("/minecraftservices/player/attributes"), get(player_attributes)) + .route(&p("/minecraftservices/privacy/blocklist"), get(blocklist)) + .route(&p("/minecraftservices/publickeys"), get(public_keys)) + .route(&p("/minecraftservices/minecraft/profile"), get(services_profile)) + .route("/textures/{hash}", get(texture_file)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uuid_forms() { + assert_eq!(undashed("B50AD385-829D-3141-A216-7E7D7539BA7F"), "b50ad385829d3141a2167e7d7539ba7f"); + assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f").as_deref(), Some("b50ad385-829d-3141-a216-7e7d7539ba7f")); + assert!(dashed("nope").is_none()); + } + + #[test] + fn pem_matches_java_mime_layout() { + let pem = mojang_pem("RSA PUBLIC KEY", &[7u8; 100]); + assert!(pem.starts_with("-----BEGIN RSA PUBLIC KEY-----\n")); + assert!(pem.ends_with("\n-----END RSA PUBLIC KEY-----\n")); + assert!(pem.contains("\r\n"), "76-column MIME lines separated by CRLF"); + } +} diff --git a/panel/server/tests/api.rs b/panel/server/tests/api.rs index 5cd8159..bb69135 100644 --- a/panel/server/tests/api.rs +++ b/panel/server/tests/api.rs @@ -1,92 +1,7 @@ //! End-to-end tests against the real router with an in-memory database. -use axum::body::Body; -use axum::http::{Request, StatusCode}; -use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db}; -use serde_json::{json, Value}; -use std::io::Write; -use tower::ServiceExt; - -struct TestApp { - router: axum::Router, - _dir: tempfile::TempDir, -} - -async fn setup() -> TestApp { - let dir = tempfile::tempdir().unwrap(); - let cfg = Config { - bind: "127.0.0.1:0".into(), - data_dir: dir.path().to_path_buf(), - web_dir: dir.path().join("web"), - admin_username: "admin".into(), - admin_password: Some("supersecret".into()), - jwt_secret: Some("test-secret-test-secret-test-secret".into()), - curseforge_api_key: None, - max_upload_mb: 64, - }; - let pool = db::connect_memory().await.unwrap(); - let state = build_state(cfg, pool).await.unwrap(); - bootstrap_admin(&state).await.unwrap(); - TestApp { router: app(state), _dir: dir } -} - -impl TestApp { - async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option) -> (StatusCode, Value) { - let mut req = Request::builder().method(method).uri(uri); - if let Some(t) = token { - req = req.header("authorization", format!("Bearer {t}")); - } - let req = match body { - Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(), - None => req.body(Body::empty()).unwrap(), - }; - self.send(req).await - } - - async fn send(&self, req: Request) -> (StatusCode, Value) { - let resp = self.router.clone().oneshot(req).await.unwrap(); - let status = resp.status(); - let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); - (status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into()))) - } - - async fn login(&self, user: &str, pass: &str) -> String { - let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await; - assert_eq!(s, StatusCode::OK, "{v}"); - v["token"].as_str().unwrap().to_string() - } -} - -fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec) { - let boundary = "----scopenettest"; - let mut body = Vec::new(); - for (k, v) in fields { - write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap(); - } - write!( - body, - "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", - file.0 - ) - .unwrap(); - body.extend_from_slice(file.1); - write!(body, "\r\n--{boundary}--\r\n").unwrap(); - (format!("multipart/form-data; boundary={boundary}"), body) -} - -fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec { - let mut buf = std::io::Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let opts = zip::write::SimpleFileOptions::default(); - for (name, data) in entries { - z.start_file(*name, opts).unwrap(); - z.write_all(data).unwrap(); - } - z.finish().unwrap(); - } - buf.into_inner() -} +mod common; +use common::*; #[tokio::test] async fn health_and_default_manifest() { @@ -305,6 +220,6 @@ async fn settings_never_leak_curseforge_key() { // Saving again with an empty key keeps it. let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await; assert_eq!(v["curseforge_key_set"], true); - let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await; - assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id"); + let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"public_url": "ftp://nope"}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "public URL must be http(s)"); } diff --git a/panel/server/tests/common/mod.rs b/panel/server/tests/common/mod.rs new file mode 100644 index 0000000..4bed47f --- /dev/null +++ b/panel/server/tests/common/mod.rs @@ -0,0 +1,98 @@ +//! Shared helpers for the API tests. +#![allow(dead_code)] + +pub use axum::body::Body; +pub use axum::http::{Request, StatusCode}; +use scopenet_panel::{app, bootstrap_admin, build_state_with_keys, config::Config, db, yggdrasil::keys::Keys}; +pub use serde_json::{json, Value}; +use std::io::Write; +use std::sync::{Arc, OnceLock}; +pub use tower::ServiceExt; + +/// One auth-server key for the whole test run (key generation is slow). +pub fn test_keys() -> Arc { + static KEYS: OnceLock> = OnceLock::new(); + KEYS.get_or_init(|| Arc::new(Keys::from_private(rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048).unwrap()).unwrap())).clone() +} + +pub struct TestApp { + pub router: axum::Router, + _dir: tempfile::TempDir, +} + +pub async fn setup() -> TestApp { + let dir = tempfile::tempdir().unwrap(); + let cfg = Config { + bind: "127.0.0.1:0".into(), + data_dir: dir.path().to_path_buf(), + web_dir: dir.path().join("web"), + admin_username: "admin".into(), + admin_password: Some("supersecret".into()), + jwt_secret: Some("test-secret-test-secret-test-secret".into()), + curseforge_api_key: None, + max_upload_mb: 64, + public_url: Some("https://panel.test".into()), + }; + let pool = db::connect_memory().await.unwrap(); + let state = build_state_with_keys(cfg, pool, test_keys()).await.unwrap(); + bootstrap_admin(&state).await.unwrap(); + TestApp { router: app(state), _dir: dir } +} + +impl TestApp { + pub async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + let req = match body { + Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(), + None => req.body(Body::empty()).unwrap(), + }; + self.send(req).await + } + + pub async fn send(&self, req: Request) -> (StatusCode, Value) { + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into()))) + } + + pub async fn login(&self, user: &str, pass: &str) -> String { + let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v["token"].as_str().unwrap().to_string() + } +} + +pub fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec) { + let boundary = "----scopenettest"; + let mut body = Vec::new(); + for (k, v) in fields { + write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap(); + } + write!( + body, + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", + file.0 + ) + .unwrap(); + body.extend_from_slice(file.1); + write!(body, "\r\n--{boundary}--\r\n").unwrap(); + (format!("multipart/form-data; boundary={boundary}"), body) +} + +pub fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default(); + for (name, data) in entries { + z.start_file(*name, opts).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap(); + } + buf.into_inner() +} diff --git a/panel/server/tests/yggdrasil.rs b/panel/server/tests/yggdrasil.rs new file mode 100644 index 0000000..33b6476 --- /dev/null +++ b/panel/server/tests/yggdrasil.rs @@ -0,0 +1,321 @@ +//! The authlib-injector flow end to end: sign-in, server join, signed +//! skins/capes, token lifecycle and chat certificates. + +mod common; +use base64::Engine; +use common::*; +use rsa::pkcs1v15::{Signature, VerifyingKey}; +use rsa::pkcs8::DecodePublicKey; +use rsa::signature::Verifier; +use rsa::RsaPublicKey; + +const Y: &str = "/api/yggdrasil"; + +fn b64(s: &str) -> Vec { + base64::engine::general_purpose::STANDARD.decode(s).unwrap() +} + +fn verify(public_pem: &str, data: &[u8], sig_b64: &str) -> bool { + let key = RsaPublicKey::from_public_key_pem(public_pem).unwrap(); + let sig = Signature::try_from(b64(sig_b64).as_slice()).unwrap(); + VerifyingKey::::new(key).verify(data, &sig).is_ok() +} + +fn skin_png() -> Vec { + let img = image::RgbaImage::from_pixel(64, 64, image::Rgba([30, 120, 200, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +fn cape_png() -> Vec { + let img = image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +async fn with_player(t: &TestApp) -> String { + let admin = t.login("admin", "supersecret").await; + let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + admin +} + +fn steve_id() -> String { + scopenet_shared::offline_uuid("Steve").replace('-', "") +} + +#[tokio::test] +async fn metadata_advertises_key_and_skin_domain() { + let t = setup().await; + for path in [Y, "/api/yggdrasil/"] { + let (s, v) = t.call("GET", path, None, None).await; + assert_eq!(s, StatusCode::OK, "{path}"); + assert_eq!(v["skinDomains"], json!(["panel.test"])); + assert!(v["signaturePublickey"].as_str().unwrap().starts_with("-----BEGIN PUBLIC KEY-----")); + assert_eq!(v["meta"]["feature.non_email_login"], true); + assert_eq!(v["meta"]["feature.enable_profile_key"], true); + } + // API Location Indication header on every response. + let resp = t.router.clone().oneshot(Request::get("/healthz").body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.headers()["x-authlib-injector-api-location"], "/api/yggdrasil/"); + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(m["auth"]["yggdrasil_url"], "https://panel.test/api/yggdrasil"); +} + +#[tokio::test] +async fn full_authlib_flow() { + let t = setup().await; + with_player(&t).await; + let (_, meta) = t.call("GET", Y, None, None).await; + let public_pem = meta["signaturePublickey"].as_str().unwrap().to_string(); + + // Sign in (username, not email) exactly as authlib does. + let (s, auth) = t + .call( + "POST", + &format!("{Y}/authserver/authenticate"), + None, + Some(json!({"agent": {"name": "Minecraft", "version": 1}, "username": "Steve", "password": "password123", "clientToken": "ct1", "requestUser": true})), + ) + .await; + assert_eq!(s, StatusCode::OK, "{auth}"); + assert_eq!(auth["clientToken"], "ct1"); + assert_eq!(auth["selectedProfile"]["id"], steve_id()); + assert_eq!(auth["selectedProfile"]["name"], "Steve"); + assert_eq!(auth["availableProfiles"].as_array().unwrap().len(), 1); + assert!(auth["user"]["id"].is_string()); + let token = auth["accessToken"].as_str().unwrap().to_string(); + + let (s, v) = + t.call("POST", &format!("{Y}/authserver/authenticate"), None, Some(json!({"username": "Steve", "password": "nope"}))).await; + assert_eq!(s, StatusCode::FORBIDDEN); + assert_eq!(v["error"], "ForbiddenOperationException"); + + // Validate. + let (s, _) = t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": token}))).await; + assert_eq!(s, StatusCode::NO_CONTENT); + let (s, _) = + t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": token, "clientToken": "other"}))).await; + assert_eq!(s, StatusCode::FORBIDDEN, "client token must match"); + + // Upload a skin through the launcher API, pick a cape. + let panel = t.login("Steve", "password123").await; + let (ct, body) = multipart(&[("model", "slim")], ("skin.png", &skin_png())); + let req = Request::post("/api/v1/account/skin") + .header("authorization", format!("Bearer {panel}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, profile) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{profile}"); + let skin_url = profile["skin_url"].as_str().unwrap().to_string(); + assert!(skin_url.starts_with("https://panel.test/textures/")); + assert_eq!(profile["skin_model"], "slim"); + + let admin = t.login("admin", "supersecret").await; + let (ct, body) = multipart(&[("name", "Founder"), ("visibility", "public"), ("allowed_groups", "[]")], ("cape.png", &cape_png())); + let req = Request::post("/api/admin/capes") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, capes) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{capes}"); + let cape_id = capes[0]["id"].as_i64().unwrap(); + let (s, profile) = t.call("PUT", "/api/v1/account/cape", Some(&panel), Some(json!({"cape_id": cape_id}))).await; + assert_eq!(s, StatusCode::OK, "{profile}"); + assert_eq!(profile["cape"]["name"], "Founder"); + + // Texture file is served as PNG. + let path = skin_url.trim_start_matches("https://panel.test"); + let resp = t.router.clone().oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers()["content-type"], "image/png"); + + // Client joins; server verifies. + let (s, _) = t + .call( + "POST", + &format!("{Y}/sessionserver/session/minecraft/join"), + None, + Some(json!({"accessToken": token, "selectedProfile": steve_id(), "serverId": "-4b1d2f"})), + ) + .await; + assert_eq!(s, StatusCode::NO_CONTENT); + let (s, _) = t + .call( + "POST", + &format!("{Y}/sessionserver/session/minecraft/join"), + None, + Some(json!({"accessToken": token, "selectedProfile": "0".repeat(32), "serverId": "x"})), + ) + .await; + assert_eq!(s, StatusCode::FORBIDDEN, "can't join as someone else"); + + let (s, joined) = + t.call("GET", &format!("{Y}/sessionserver/session/minecraft/hasJoined?username=Steve&serverId=-4b1d2f"), None, None).await; + assert_eq!(s, StatusCode::OK, "{joined}"); + assert_eq!(joined["id"], steve_id()); + let textures = joined["properties"].as_array().unwrap().iter().find(|p| p["name"] == "textures").unwrap(); + let value = textures["value"].as_str().unwrap(); + assert!( + verify(&public_pem, value.as_bytes(), textures["signature"].as_str().unwrap()), + "textures signature must verify with the metadata key" + ); + let decoded: Value = serde_json::from_slice(&b64(value)).unwrap(); + assert_eq!(decoded["profileName"], "Steve"); + assert_eq!(decoded["textures"]["SKIN"]["url"], skin_url); + assert_eq!(decoded["textures"]["SKIN"]["metadata"]["model"], "slim"); + assert!(decoded["textures"]["CAPE"]["url"].as_str().unwrap().starts_with("https://panel.test/textures/")); + + let (s, _) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/hasJoined?username=Alex&serverId=-4b1d2f"), None, None).await; + assert_eq!(s, StatusCode::NO_CONTENT, "wrong name"); + let (s, _) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/hasJoined?username=Steve&serverId=other"), None, None).await; + assert_eq!(s, StatusCode::NO_CONTENT, "wrong server id"); + + // Profile lookups. + let (_, unsigned) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}", steve_id()), None, None).await; + assert!(unsigned["properties"][0].get("signature").is_none()); + let (_, signed) = + t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}?unsigned=false", steve_id()), None, None).await; + assert!(signed["properties"][0]["signature"].is_string()); + let (_, found) = t.call("POST", &format!("{Y}/api/profiles/minecraft"), None, Some(json!(["steve", "nobody"]))).await; + assert_eq!(found, json!([{"id": steve_id(), "name": "Steve"}])); + + // Refresh rotates the token. + let (s, refreshed) = + t.call("POST", &format!("{Y}/authserver/refresh"), None, Some(json!({"accessToken": token, "clientToken": "ct1"}))).await; + assert_eq!(s, StatusCode::OK, "{refreshed}"); + let new_token = refreshed["accessToken"].as_str().unwrap().to_string(); + assert_ne!(new_token, token); + assert_eq!(refreshed["clientToken"], "ct1"); + let (s, _) = t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": token}))).await; + assert_eq!(s, StatusCode::FORBIDDEN, "old token revoked"); + + // Invalidate. + let (s, _) = + t.call("POST", &format!("{Y}/authserver/invalidate"), None, Some(json!({"accessToken": new_token, "clientToken": "ct1"}))).await; + assert_eq!(s, StatusCode::NO_CONTENT); + let (s, _) = t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": new_token}))).await; + assert_eq!(s, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn launcher_login_returns_game_session() { + let t = setup().await; + with_player(&t).await; + let (s, v) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Steve", "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK); + let token = v["yggdrasil"]["access_token"].as_str().unwrap(); + assert_eq!(v["user"]["uuid"], scopenet_shared::offline_uuid("Steve")); + let (s, _) = t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": token}))).await; + assert_eq!(s, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn disabled_accounts_are_refused_with_reason() { + let t = setup().await; + let admin = with_player(&t).await; + let (_, users) = t.call("GET", "/api/admin/users", Some(&admin), None).await; + let id = users.as_array().unwrap().iter().find(|u| u["username"] == "Steve").unwrap()["id"].as_i64().unwrap(); + let (s, v) = t + .call( + "PATCH", + &format!("/api/admin/users/{id}"), + Some(&admin), + Some(json!({"status": "disabled", "status_reason": "Griefing spawn"})), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + let (s, v) = + t.call("POST", &format!("{Y}/authserver/authenticate"), None, Some(json!({"username": "Steve", "password": "password123"}))).await; + assert_eq!(s, StatusCode::FORBIDDEN); + assert_eq!(v["errorMessage"], "Griefing spawn"); +} + +#[tokio::test] +async fn chat_certificates_are_signed_like_mojang() { + let t = setup().await; + with_player(&t).await; + let (_, meta) = t.call("GET", Y, None, None).await; + let public_pem = meta["signaturePublickey"].as_str().unwrap().to_string(); + let (_, auth) = + t.call("POST", &format!("{Y}/authserver/authenticate"), None, Some(json!({"username": "Steve", "password": "password123"}))).await; + let token = auth["accessToken"].as_str().unwrap(); + + let (s, cert) = t.call("POST", &format!("{Y}/minecraftservices/player/certificates"), Some(token), None).await; + assert_eq!(s, StatusCode::OK, "{cert}"); + let pem = cert["keyPair"]["publicKey"].as_str().unwrap(); + assert!(pem.starts_with("-----BEGIN RSA PUBLIC KEY-----")); + assert!(cert["keyPair"]["privateKey"].as_str().unwrap().starts_with("-----BEGIN RSA PRIVATE KEY-----")); + let expires = chrono::DateTime::parse_from_rfc3339(cert["expiresAt"].as_str().unwrap()).unwrap(); + + // Rebuild the V2 payload the way Minecraft does and check the signature. + let body: String = pem.lines().filter(|l| !l.starts_with("-----")).map(|l| l.trim()).collect(); + let der = b64(&body); + let uuid = uuid::Uuid::parse_str(&scopenet_shared::offline_uuid("Steve")).unwrap(); + let mut payload = uuid.as_bytes().to_vec(); + payload.extend_from_slice(&expires.timestamp_millis().to_be_bytes()); + payload.extend_from_slice(&der); + assert!(verify(&public_pem, &payload, cert["publicKeySignatureV2"].as_str().unwrap())); + let v1 = format!("{}{}", expires.timestamp_millis(), pem); + assert!(verify(&public_pem, v1.as_bytes(), cert["publicKeySignature"].as_str().unwrap())); + + // Cached until refresh time. + let (_, again) = t.call("POST", &format!("{Y}/minecraftservices/player/certificates"), Some(token), None).await; + assert_eq!(again["keyPair"]["publicKey"], cert["keyPair"]["publicKey"]); + + let (s, _) = t.call("POST", &format!("{Y}/minecraftservices/player/certificates"), None, None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + let (_, keys) = t.call("GET", &format!("{Y}/minecraftservices/publickeys"), None, None).await; + assert!(keys["playerCertificateKeys"][0]["publicKey"].is_string()); +} + +#[tokio::test] +async fn avatars_and_skin_validation() { + let t = setup().await; + with_player(&t).await; + let panel = t.login("Steve", "password123").await; + let (s, _) = t.call("GET", &format!("/api/v1/avatar/{}", steve_id()), None, None).await; + assert_eq!(s, StatusCode::NOT_FOUND, "no skin yet"); + + let (ct, body) = multipart(&[], ("bad.png", b"GIF89a not a png")); + let req = Request::post("/api/v1/account/skin") + .header("authorization", format!("Bearer {panel}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, _) = t.send(req).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + let (ct, body) = multipart(&[], ("skin.png", &skin_png())); + let req = Request::post("/api/v1/account/skin") + .header("authorization", format!("Bearer {panel}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + assert_eq!(t.send(req).await.0, StatusCode::OK); + for id in [steve_id(), "Steve".to_string()] { + let resp = + t.router.clone().oneshot(Request::get(format!("/api/v1/avatar/{id}?size=32")).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(image::load_from_memory(&bytes).unwrap().width(), 32); + } + + // Private capes can't be self-selected. + let admin = t.login("admin", "supersecret").await; + let (ct, body) = multipart(&[("name", "Staff"), ("visibility", "private"), ("allowed_groups", "[]")], ("cape.png", &cape_png())); + let req = Request::post("/api/admin/capes") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (_, capes) = t.send(req).await; + let (s, _) = t.call("PUT", "/api/v1/account/cape", Some(&panel), Some(json!({"cape_id": capes[0]["id"]}))).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let (_, profile) = t.call("GET", "/api/v1/account/profile", Some(&panel), None).await; + assert_eq!(profile["available_capes"], json!([])); +}