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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 06:57:07 +00:00
1 parent 1b1bb984bc
commit 99b45141fc
29 files changed
+2436 -400

No files matched your search

Generated
+166
View File
@@ -478,6 +478,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@@ -698,6 +704,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.2" version = "0.18.2"
@@ -946,6 +958,17 @@ dependencies = [
"thiserror 2.0.21", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -994,6 +1017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"const-oid",
"crypto-common", "crypto-common",
"subtle", "subtle",
] ]
@@ -2108,6 +2132,19 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2378,6 +2415,9 @@ name = "lazy_static"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "libappindicator" name = "libappindicator"
@@ -2428,6 +2468,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.25" version = "0.1.25"
@@ -2579,6 +2625,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "muda" name = "muda"
version = "0.20.0" version = "0.20.0"
@@ -2678,6 +2734,22 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2693,6 +2765,16 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -2700,6 +2782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -3035,6 +3118,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -3111,6 +3203,27 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -3288,6 +3401,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.42.0" version = "0.42.0"
@@ -3618,6 +3737,27 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3764,6 +3904,7 @@ name = "scopenet-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"fastnbt", "fastnbt",
"futures", "futures",
"hex", "hex",
@@ -3772,6 +3913,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing", "tracing",
@@ -3812,18 +3954,22 @@ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"futures", "futures",
"hex", "hex",
"image",
"jsonwebtoken", "jsonwebtoken",
"percent-encoding", "percent-encoding",
"rand 0.8.8", "rand 0.8.8",
"reqwest 0.12.28", "reqwest 0.12.28",
"rsa",
"scopenet-core", "scopenet-core",
"scopenet-shared", "scopenet-shared",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -4149,6 +4295,16 @@ dependencies = [
"libc", "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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -4258,6 +4414,16 @@ dependencies = [
"lock_api", "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]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.6" version = "0.8.6"
+2 -1
View File
@@ -17,7 +17,8 @@ serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] } 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"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
futures = "0.3" futures = "0.3"
sha1 = "0.10" sha1 = { version = "0.10", features = ["oid"] }
sha2 = "0.10"
md-5 = "0.10" md-5 = "0.10"
hex = "0.4" hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
+2
View File
@@ -13,6 +13,8 @@ tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
futures.workspace = true futures.workspace = true
sha1.workspace = true sha1.workspace = true
sha2.workspace = true
base64.workspace = true
hex.workspace = true hex.workspace = true
zip.workspace = true zip.workspace = true
tracing.workspace = true tracing.workspace = true
+130
View File
@@ -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<String> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String> {
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::<serde_json::Value>(&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<String> {
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");
}
}
+7 -6
View File
@@ -27,14 +27,15 @@ pub struct Auth {
/// Dashed or undashed UUID. /// Dashed or undashed UUID.
pub uuid: String, pub uuid: String,
pub access_token: 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 user_type: String,
pub xuid: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LaunchOptions { pub struct LaunchOptions {
pub auth: Auth, pub auth: Auth,
/// JVM arguments that must come first (the authlib-injector agent).
pub agent_args: Vec<String>,
pub game_dir: PathBuf, pub game_dir: PathBuf,
pub memory_min_mb: u32, pub memory_min_mb: u32,
pub memory_max_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_access_token", opts.auth.access_token.clone());
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid)); vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
vars.insert("clientid", String::new()); 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_type", opts.auth.user_type.clone());
vars.insert("user_properties", "{}".into()); vars.insert("user_properties", "{}".into());
vars.insert("version_type", opts.version_label.clone()); 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}")); 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!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
args.push(format!("-Xmx{}M", opts.memory_max_mb)); args.push(format!("-Xmx{}M", opts.memory_max_mb));
args.extend(gc_args(opts.gc, installed.java_major)); args.extend(gc_args(opts.gc, installed.java_major));
@@ -336,8 +337,8 @@ mod tests {
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
access_token: "0".into(), access_token: "0".into(),
user_type: "legacy".into(), user_type: "legacy".into(),
xuid: None,
}, },
agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()],
game_dir: "/g".into(), game_dir: "/g".into(),
memory_min_mb: 1024, memory_min_mb: 1024,
memory_max_mb: 4096, memory_max_mb: 4096,
@@ -362,7 +363,7 @@ mod tests {
let layout = Layout::new("/root"); let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true)); let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" "); 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("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f")); assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720")); assert!(a.contains("--width 1280 --height 720"));
+1 -1
View File
@@ -5,6 +5,7 @@
//! tested without a window. //! tested without a window.
pub mod assets; pub mod assets;
pub mod authlib;
pub mod http; pub mod http;
pub mod install; pub mod install;
pub mod java; pub mod java;
@@ -13,7 +14,6 @@ pub mod libraries;
pub mod loaders; pub mod loaders;
pub mod maven; pub mod maven;
pub mod meta; pub mod meta;
pub mod msa;
pub mod options; pub mod options;
pub mod paths; pub mod paths;
pub mod ping; pub mod ping;
-212
View File
@@ -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<String>,
refresh_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McProfile {
pub id: String,
pub name: String,
#[serde(default)]
pub skins: Vec<McSkin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McSkin {
pub url: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub variant: Option<String>,
}
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
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<MsaSession> {
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<MsaSession> {
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<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
#[serde(default)]
xid: Option<String>,
}
#[derive(Deserialize)]
struct McLogin {
access_token: String,
expires_in: i64,
}
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
// 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 })
}
+12 -1
View File
@@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) {
uuid: scopenet_shared::offline_uuid("CiBot"), uuid: scopenet_shared::offline_uuid("CiBot"),
access_token: "0".into(), access_token: "0".into(),
user_type: "legacy".into(), user_type: "legacy".into(),
xuid: None,
}, },
agent_args: vec![],
game_dir, game_dir,
memory_min_mb: 512, memory_min_mb: 512,
memory_max_mb: 2048, memory_max_mb: 2048,
@@ -94,3 +94,14 @@ async fn forge_1_20_1() {
async fn neoforge_1_21_1() { async fn neoforge_1_21_1() {
run("1.21.1", Loader::NeoForge).await; 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());
}
+40 -10
View File
@@ -175,23 +175,48 @@ pub enum RegistrationMode {
pub struct AuthConfig { pub struct AuthConfig {
pub panel_accounts: bool, pub panel_accounts: bool,
pub registration: RegistrationMode, pub registration: RegistrationMode,
pub microsoft: bool,
pub microsoft_client_id: Option<String>,
pub offline_local: bool, 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<String>,
} }
impl Default for AuthConfig { impl Default for AuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None }
panel_accounts: true,
registration: RegistrationMode::Closed,
microsoft: false,
microsoft_client_id: None,
offline_local: true,
}
} }
} }
/// 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<String>,
/// "classic" (Steve arms) or "slim" (Alex arms).
pub skin_model: String,
pub cape: Option<CapeInfo>,
/// Capes this player is allowed to pick.
pub available_capes: Vec<CapeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
@@ -210,7 +235,8 @@ pub struct RegisterRequest {
pub struct PublicUser { pub struct PublicUser {
pub id: i64, pub id: i64,
pub username: String, 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 uuid: String,
pub role: String, pub role: String,
pub groups: Vec<String>, pub groups: Vec<String>,
@@ -218,11 +244,15 @@ pub struct PublicUser {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse { pub struct AuthResponse {
/// Panel session token (manifest, skins, account API).
pub token: String, pub token: String,
pub user: PublicUser, pub user: PublicUser,
/// Set when the account exists but is waiting for admin approval. /// Set when the account exists but is waiting for admin approval.
#[serde(default)] #[serde(default)]
pub pending: bool, pub pending: bool,
/// Game session for authlib-injector; absent for pending accounts.
#[serde(default)]
pub yggdrasil: Option<YggdrasilTokens>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
-25
View File
@@ -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 <https://aka.ms/mce-reviewappid> 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).
+4
View File
@@ -29,6 +29,10 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio",
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" jsonwebtoken = "9"
percent-encoding = "2" 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] [dev-dependencies]
tempfile = "3" tempfile = "3"
+43 -3
View File
@@ -9,7 +9,7 @@ use argon2::Argon2;
use axum::extract::FromRequestParts; use axum::extract::FromRequestParts;
use axum::http::request::Parts; use axum::http::request::Parts;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use scopenet_shared::{offline_uuid, PublicUser}; use scopenet_shared::PublicUser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
@@ -81,6 +81,13 @@ pub struct UserRow {
pub status: String, pub status: String,
pub created_at: String, pub created_at: String,
pub last_login: Option<String>, pub last_login: Option<String>,
/// Dashed player UUID.
pub uuid: String,
pub skin_hash: Option<String>,
pub skin_model: String,
pub cape_id: Option<i64>,
/// Why the account is disabled (shown to the player when they're refused).
pub status_reason: Option<String>,
} }
impl UserRow { impl UserRow {
@@ -100,13 +107,46 @@ pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUs
Ok(PublicUser { Ok(PublicUser {
id: user.id, id: user.id,
username: user.username.clone(), username: user.username.clone(),
uuid: offline_uuid(&user.username), uuid: user.uuid.clone(),
role: user.role.clone(), role: user.role.clone(),
groups: user_groups(state, user.id).await?, groups: user_groups(state, user.id).await?,
}) })
} }
fn bearer(parts: &Parts) -> 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<i64> {
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<Option<UserRow>> {
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 parts
.headers .headers
.get(axum::http::header::AUTHORIZATION) .get(axum::http::header::AUTHORIZATION)
+8
View File
@@ -12,6 +12,7 @@ pub struct Config {
pub jwt_secret: Option<String>, pub jwt_secret: Option<String>,
pub curseforge_api_key: Option<String>, pub curseforge_api_key: Option<String>,
pub max_upload_mb: usize, pub max_upload_mb: usize,
pub public_url: Option<String>,
} }
fn var(name: &str) -> Option<String> { fn var(name: &str) -> Option<String> {
@@ -29,6 +30,7 @@ impl Config {
jwt_secret: var("JWT_SECRET"), jwt_secret: var("JWT_SECRET"),
curseforge_api_key: var("CURSEFORGE_API_KEY"), curseforge_api_key: var("CURSEFORGE_API_KEY"),
max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048), 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 { pub fn uploads_dir(&self) -> PathBuf {
self.data_dir.join("uploads") 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")
}
} }
+115
View File
@@ -77,6 +77,107 @@ const MIGRATIONS: &[&str] = &[
); );
CREATE INDEX events_created ON events(created_at); 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<SqlitePool> { pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
@@ -112,6 +213,20 @@ async fn migrate(pool: &SqlitePool) -> Result<()> {
tx.commit().await?; tx.commit().await?;
tracing::info!("applied database migration {version}"); 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(()) Ok(())
} }
+19 -7
View File
@@ -4,10 +4,13 @@ pub mod auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod net;
pub mod packs; pub mod packs;
pub mod routes; pub mod routes;
pub mod state; pub mod state;
pub mod store; pub mod store;
pub mod textures;
pub mod yggdrasil;
use axum::http::{header, HeaderValue}; use axum::http::{header, HeaderValue};
use axum::Router; use axum::Router;
@@ -38,9 +41,17 @@ pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result<Vec<u8>> {
} }
pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result<AppState> { pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result<AppState> {
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<yggdrasil::keys::Keys>) -> anyhow::Result<AppState> {
let secret = jwt_secret(&cfg)?; let secret = jwt_secret(&cfg)?;
Ok(AppState { Ok(AppState {
db, db,
ygg,
keys: Arc::new(auth::Keys::new(&secret)), keys: Arc::new(auth::Keys::new(&secret)),
http: scopenet_core::http::client(), http: scopenet_core::http::client(),
login_guard: Arc::new(auth::LoginGuard::default()), login_guard: Arc::new(auth::LoginGuard::default()),
@@ -62,13 +73,9 @@ pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> {
(hex::encode(bytes), true) (hex::encode(bytes), true)
} }
}; };
let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?; auth::create_user(state, &state.cfg.admin_username, &password, None, "admin", "active")
sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)") .await
.bind(&state.cfg.admin_username) .map_err(|e| anyhow::anyhow!(e.message))?;
.bind(hash)
.bind(db::now())
.execute(&state.db)
.await?;
if generated { if generated {
tracing::warn!("============================================================"); tracing::warn!("============================================================");
tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username); 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("/files", ServeDir::new(state.cfg.files_dir()))
.nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir()))) .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())))
.fallback_service(spa) .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(CompressionLayer::new())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state) .with_state(state)
+3 -1
View File
@@ -25,7 +25,9 @@ async fn main() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind(&bind).await?; let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("SCOPENET panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION")); 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::<std::net::SocketAddr>())
.with_graceful_shutdown(shutdown())
.await?;
Ok(()) Ok(())
} }
+68
View File
@@ -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<String>);
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
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::<ConnectInfo<SocketAddr>>().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");
}
}
+189
View File
@@ -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<AppState>, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult<Json<PlayerProfile>> {
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<u8>, 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<UserRow> {
Ok(sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?)
}
pub async fn upload_skin(
State(state): State<AppState>,
headers: HeaderMap,
AuthUser(user): AuthUser,
mut form: Multipart,
) -> AppResult<Json<PlayerProfile>> {
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<AppState>,
headers: HeaderMap,
AuthUser(user): AuthUser,
Json(input): Json<ModelInput>,
) -> AppResult<Json<PlayerProfile>> {
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<AppState>, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult<Json<PlayerProfile>> {
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<i64>,
}
pub async fn set_cape(
State(state): State<AppState>,
headers: HeaderMap,
AuthUser(user): AuthUser,
Json(input): Json<CapeInput>,
) -> AppResult<Json<PlayerProfile>> {
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<u32>,
}
/// 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<AppState>, Path(id): Path<String>, Query(q): Query<AvatarQuery>) -> AppResult<Response> {
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<scopenet_core::authlib::Artifact> {
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<Artifact> = 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<Artifact> = 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<AppState>) -> AppResult<Json<scopenet_core::authlib::Artifact>> {
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<AppState>) -> AppResult<Response> {
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())
}
+195 -19
View File
@@ -69,13 +69,28 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
pub struct AdminUserView { pub struct AdminUserView {
#[serde(flatten)] #[serde(flatten)]
user: UserRow, user: UserRow,
uuid: String,
groups: Vec<String>, groups: Vec<String>,
/// Panel-relative texture URL.
skin_url: Option<String>,
/// Across all game servers reporting to the panel.
playtime_secs: i64,
last_seen_ingame: Option<String>,
} }
async fn view(state: &AppState, user: UserRow) -> AppResult<AdminUserView> { async fn view(state: &AppState, user: UserRow) -> AppResult<AdminUserView> {
let groups = auth::user_groups(state, user.id).await?; 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<i64>, Option<String>) =
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<AppState>) -> AppResult<Json<Vec<AdminUserView>>> { pub async fn list_users(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminUserView>>> {
@@ -95,6 +110,7 @@ pub struct UserInput {
email: Option<String>, email: Option<String>,
role: Option<String>, role: Option<String>,
status: Option<String>, status: Option<String>,
status_reason: Option<String>,
groups: Option<Vec<String>>, groups: Option<Vec<String>>,
} }
@@ -135,21 +151,7 @@ pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input
check_role(&role)?; check_role(&role)?;
let status = input.status.unwrap_or_else(|| "active".into()); let status = input.status.unwrap_or_else(|| "active".into());
check_status(&status)?; check_status(&status)?;
let id: i64 = sqlx::query_scalar( let id = auth::create_user(&state, username, &password, input.email.as_deref(), &role, &status).await?;
"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(),
})?;
if let Some(groups) = input.groups { if let Some(groups) = input.groups {
set_groups(&state, id, &groups).await?; 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?; 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 { if let Some(status) = input.status {
check_status(&status)?; check_status(&status)?;
if me.id == id && status != "active" { if me.id == id && status != "active" {
@@ -299,9 +308,13 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(mut
Some("-") => None, Some("-") => None,
Some(k) => Some(k.to_string()), Some(k) => Some(k.to_string()),
}; };
if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() { s.public_url = s.public_url.map(|u| u.trim().trim_end_matches('/').to_string()).filter(|u| !u.is_empty());
return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID")); 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?; store::kv_set(&state, "settings", &s).await?;
settings_view(&state).await settings_view(&state).await
} }
@@ -670,3 +683,166 @@ pub async fn upload_media(_: AdminUser, State(state): State<AppState>, mut form:
} }
Err(AppError::bad_request("no file uploaded")) Err(AppError::bad_request("no file uploaded"))
} }
// ---------------------------------------------------------------------------
// Skins & capes
// ---------------------------------------------------------------------------
pub async fn admin_set_skin(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
mut form: Multipart,
) -> AppResult<Json<AdminUserView>> {
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<AppState>, Path(id): Path<i64>) -> AppResult<Json<AdminUserView>> {
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<i64>,
}
/// Admins can give any cape to anyone (including "private" ones).
pub async fn admin_set_cape(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<AdminCapeInput>,
) -> AppResult<Json<AdminUserView>> {
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<String>,
wearers: i64,
created_at: String,
}
async fn cape_views(state: &AppState) -> AppResult<Vec<CapeView>> {
let rows: Vec<crate::yggdrasil::CapeRow> =
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<AppState>) -> AppResult<Json<Vec<CapeView>>> {
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<AppState>, mut form: Multipart) -> AppResult<Json<Vec<CapeView>>> {
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<String> = 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<String>,
}
pub async fn update_cape(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<CapeUpdate>,
) -> AppResult<Json<Vec<CapeView>>> {
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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Vec<CapeView>>> {
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<AppState>, headers: axum::http::HeaderMap) -> AppResult<Json<Value>> {
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,
})))
}
+19 -2
View File
@@ -1,3 +1,4 @@
pub mod account;
pub mod admin; pub mod admin;
pub mod meta; pub mod meta;
pub mod public; pub mod public;
@@ -16,7 +17,15 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/launcher/events", post(public::event)) .route("/launcher/events", post(public::event))
.route("/auth/login", post(public::login)) .route("/auth/login", post(public::login))
.route("/auth/register", post(public::register)) .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() let admin = Router::new()
.route("/stats", get(admin::stats)) .route("/stats", get(admin::stats))
@@ -34,6 +43,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/instances/{id}/import/upload", post(admin::import_upload)) .route("/instances/{id}/import/upload", post(admin::import_upload))
.route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file)) .route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file))
.route("/uploads", post(admin::upload_media)) .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/minecraft", get(meta::minecraft))
.route("/meta/loaders/{loader}", get(meta::loaders)) .route("/meta/loaders/{loader}", get(meta::loaders))
.route("/modrinth/search", get(meta::modrinth_search)) .route("/modrinth/search", get(meta::modrinth_search))
@@ -42,5 +56,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files)) .route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
.layer(DefaultBodyLimit::max(upload_limit)); .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)))
} }
+44 -23
View File
@@ -2,9 +2,12 @@
use crate::auth::{self, AuthUser, MaybeUser, UserRow}; use crate::auth::{self, AuthUser, MaybeUser, UserRow};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::net::{self, ClientIp};
use crate::state::AppState; use crate::state::AppState;
use crate::store; use crate::store;
use crate::yggdrasil;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::Json; use axum::Json;
use scopenet_shared::*; use scopenet_shared::*;
@@ -12,7 +15,7 @@ pub async fn health() -> &'static str {
"ok" "ok"
} }
pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser) -> AppResult<Json<LauncherManifest>> { pub async fn manifest(State(state): State<AppState>, headers: HeaderMap, MaybeUser(user): MaybeUser) -> AppResult<Json<LauncherManifest>> {
let settings = store::settings(&state).await?; let settings = store::settings(&state).await?;
let groups = match &user { let groups = match &user {
Some(u) => auth::user_groups(&state, u.id).await?, Some(u) => auth::user_groups(&state, u.id).await?,
@@ -30,9 +33,7 @@ pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser)
None => None, None => None,
}; };
let mut auth_cfg = settings.auth; let mut auth_cfg = settings.auth;
if !auth_cfg.microsoft { auth_cfg.yggdrasil_url = Some(format!("{}{}", net::public_base(&state, &headers).await, yggdrasil::ROOT));
auth_cfg.microsoft_client_id = None;
}
Ok(Json(LauncherManifest { Ok(Json(LauncherManifest {
api_version: API_VERSION, api_version: API_VERSION,
panel_version: env!("CARGO_PKG_VERSION").into(), 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<Option<UserRow>> { async fn find_user(state: &AppState, username: &str) -> AppResult<Option<UserRow>> {
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<AuthResponse> {
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<AppState>, Json(req): Json<LoginRequest>) -> AppResult<Json<AuthResponse>> { pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>) -> AppResult<Json<AuthResponse>> {
@@ -77,14 +89,16 @@ pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>)
state.login_guard.succeed(&username); state.login_guard.succeed(&username);
match user.status.as_str() { match user.status.as_str() {
"pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")), "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() { if !settings.auth.panel_accounts && !user.is_admin() {
return Err(AppError::forbidden("account sign-in is currently disabled")); 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?; 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<AppState>, Json(req): Json<RegisterRequest>) -> AppResult<Json<AuthResponse>> { pub async fn register(State(state): State<AppState>, Json(req): Json<RegisterRequest>) -> AppResult<Json<AuthResponse>> {
@@ -101,23 +115,17 @@ pub async fn register(State(state): State<AppState>, Json(req): Json<RegisterReq
return Err(AppError::conflict("that username is taken")); return Err(AppError::conflict("that username is taken"));
} }
let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" }; let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" };
let id: i64 = sqlx::query_scalar( let id = auth::create_user(&state, username, &req.password, req.email.as_deref(), "player", status).await?;
"INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, 'player', ?, ?) RETURNING id",
)
.bind(username)
.bind(auth::hash_password(&req.password)?)
.bind(req.email.as_deref().map(str::trim).filter(|e| !e.is_empty()))
.bind(status)
.bind(crate::db::now())
.fetch_one(&state.db)
.await?;
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
let pending = status == "pending"; if status == "pending" {
Ok(Json(AuthResponse { return Ok(Json(AuthResponse {
token: if pending { String::new() } else { state.keys.issue(&user)? }, token: String::new(),
user: auth::public_user(&state, &user).await?, user: auth::public_user(&state, &user).await?,
pending, pending: true,
})) yggdrasil: None,
}));
}
Ok(Json(signed_in(&state, &user).await?))
} }
pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<PublicUser>> { pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<PublicUser>> {
@@ -127,9 +135,22 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
pub async fn event( pub async fn event(
State(state): State<AppState>, State(state): State<AppState>,
MaybeUser(user): MaybeUser, MaybeUser(user): MaybeUser,
ClientIp(ip): ClientIp,
Json(ev): Json<LaunchEvent>, Json(ev): Json<LaunchEvent>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
let kind = if ev.kind == "launch" { "launch" } else { "other" }; 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::<String>()); let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::<String>());
sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)") sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)")
.bind(ev.instance_id.chars().take(64).collect::<String>()) .bind(ev.instance_id.chars().take(64).collect::<String>())
+3
View File
@@ -7,7 +7,10 @@ use std::sync::Arc;
pub struct AppState { pub struct AppState {
pub db: SqlitePool, pub db: SqlitePool,
pub cfg: Arc<Config>, pub cfg: Arc<Config>,
/// Panel session tokens (JWT).
pub keys: Arc<Keys>, pub keys: Arc<Keys>,
/// Auth server signing key (textures, chat certificates).
pub ygg: Arc<crate::yggdrasil::keys::Keys>,
pub http: reqwest::Client, pub http: reqwest::Client,
pub login_guard: Arc<LoginGuard>, pub login_guard: Arc<LoginGuard>,
} }
+3
View File
@@ -27,6 +27,9 @@ pub struct Settings {
pub curseforge_api_key: Option<String>, pub curseforge_api_key: Option<String>,
/// Where players can download the launcher (shown on the dashboard). /// Where players can download the launcher (shown on the dashboard).
pub launcher_download_url: Option<String>, pub launcher_download_url: Option<String>,
/// 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<String>,
} }
pub async fn settings(state: &AppState) -> AppResult<Settings> { pub async fn settings(state: &AppState) -> AppResult<Settings> {
+124
View File
@@ -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<RgbaImage> {
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<String> {
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<PathBuf> {
// 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<Vec<u8>> {
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<u8> {
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]);
}
}
+74
View File
@@ -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<Sha1>,
/// `-----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<Self> {
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::<Sha1>::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<Self> {
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::<Sha1>::new(public).verify(b"hello", &Signature::try_from(sig.as_slice()).unwrap()).unwrap();
assert!(keys.public_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
}
}
+742
View File
@@ -0,0 +1,742 @@
//! A Yggdrasil-compatible authentication server, following the
//! authlib-injector specification:
//! <https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83>
//!
//! 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<String>) -> Self {
Self { status: StatusCode::FORBIDDEN, error: "ForbiddenOperationException", message: message.into() }
}
fn bad_request(message: impl Into<String>) -> 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<crate::error::AppError> for YggError {
fn from(e: crate::error::AppError) -> Self {
Self { status: e.status, error: "InternalServerError", message: e.message }
}
}
impl From<sqlx::Error> for YggError {
fn from(e: sqlx::Error) -> Self {
crate::error::AppError::from(e).into()
}
}
type YggResult<T> = Result<T, YggError>;
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<String> {
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<Option<UserRow>> {
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<Option<CapeRow>> {
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<Vec<CapeRow>> {
let groups = auth::user_groups(state, user.id).await?;
let all: Vec<CapeRow> = 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<String> = 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<PlayerProfile> {
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<String> {
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<Value> {
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<String>) -> 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<Option<UserRow>> {
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<UserRow> = 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<UserRow> {
state.login_guard.check(username).map_err(|e| YggError::forbidden(e.message))?;
// Email or username (`feature.non_email_login`).
let user: Option<UserRow> = 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<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
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<String>,
#[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<AppState>, Json(req): Json<AuthenticateReq>) -> YggResult<Json<Value>> {
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<String>,
#[serde(default)]
request_user: bool,
}
async fn refresh(State(state): State<AppState>, Json(req): Json<RefreshReq>) -> YggResult<Json<Value>> {
let client_token: Option<String> = 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<String>,
}
async fn validate(State(state): State<AppState>, Json(req): Json<TokenReq>) -> YggResult<Response> {
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<AppState>, Json(req): Json<TokenReq>) -> YggResult<Response> {
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<AppState>, Json(req): Json<SignoutReq>) -> YggResult<Response> {
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<AppState>, ClientIp(ip): ClientIp, Json(req): Json<JoinReq>) -> YggResult<Response> {
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<String>,
}
async fn has_joined(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<HasJoinedQuery>) -> YggResult<Response> {
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let row: Option<(i64, Option<String>)> = 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<UserRow> =
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<String>,
}
async fn session_profile(
State(state): State<AppState>,
headers: HeaderMap,
Path(uuid): Path<String>,
Query(q): Query<ProfileQuery>,
) -> YggResult<Response> {
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<AppState>, Json(names): Json<Vec<String>>) -> YggResult<Json<Vec<Value>>> {
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<AppState>, Path(name): Path<String>) -> YggResult<Response> {
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<UserRow> {
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<AppState>,
headers: HeaderMap,
Path((uuid, kind)): Path<(String, String)>,
mut form: Multipart,
) -> YggResult<Response> {
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<AppState>,
headers: HeaderMap,
Path((uuid, kind)): Path<(String, String)>,
) -> YggResult<Response> {
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<chrono::Utc>) -> 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<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
let user = bearer_user(&state, &headers).await?;
let existing: Option<PlayerKeyRow> =
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<PlayerKeyRow> {
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<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
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<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
bearer_user(&state, &headers).await?;
Ok(Json(json!({ "blockedProfiles": [] })))
}
async fn public_keys(State(state): State<AppState>) -> Json<Value> {
let key = json!([{ "publicKey": state.ygg.public_der_b64 }]);
Json(json!({ "profilePropertyKeys": key, "playerCertificateKeys": key }))
}
async fn services_profile(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
let user = bearer_user(&state, &headers).await?;
let base = net::public_base(&state, &headers).await;
let skins: Vec<Value> = 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<Value> = 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<AppState>, Path(hash): Path<String>) -> 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<AppState> {
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");
}
}
+4 -89
View File
@@ -1,92 +1,7 @@
//! End-to-end tests against the real router with an in-memory database. //! End-to-end tests against the real router with an in-memory database.
use axum::body::Body; mod common;
use axum::http::{Request, StatusCode}; use common::*;
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<Value>) -> (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<Body>) -> (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<u8>) {
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<u8> {
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()
}
#[tokio::test] #[tokio::test]
async fn health_and_default_manifest() { 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. // Saving again with an empty key keeps it.
let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await; let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await;
assert_eq!(v["curseforge_key_set"], true); assert_eq!(v["curseforge_key_set"], true);
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await; let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"public_url": "ftp://nope"}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id"); assert_eq!(s, StatusCode::BAD_REQUEST, "public URL must be http(s)");
} }
+98
View File
@@ -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<Keys> {
static KEYS: OnceLock<Arc<Keys>> = 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<Value>) -> (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<Body>) -> (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<u8>) {
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<u8> {
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()
}
+321
View File
@@ -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<u8> {
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::<sha1::Sha1>::new(key).verify(data, &sig).is_ok()
}
fn skin_png() -> Vec<u8> {
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<u8> {
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!([]));
}