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:
29 files changed
+2436
-400
No files matched your search
Generated
+166
@@ -478,6 +478,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -698,6 +704,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -946,6 +958,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -994,6 +1017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
@@ -2108,6 +2132,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2378,6 +2415,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
@@ -2428,6 +2468,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
@@ -2579,6 +2625,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.20.0"
|
||||
@@ -2678,6 +2734,22 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.8",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2693,6 +2765,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2700,6 +2782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3035,6 +3118,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -3111,6 +3203,27 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -3288,6 +3401,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -3618,6 +3737,27 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sha1",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3764,6 +3904,7 @@ name = "scopenet-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"fastnbt",
|
||||
"futures",
|
||||
"hex",
|
||||
@@ -3772,6 +3913,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3812,18 +3954,22 @@ dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hex",
|
||||
"image",
|
||||
"jsonwebtoken",
|
||||
"percent-encoding",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.12.28",
|
||||
"rsa",
|
||||
"scopenet-core",
|
||||
"scopenet-shared",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4149,6 +4295,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -4258,6 +4414,16 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
|
||||
futures = "0.3"
|
||||
sha1 = "0.10"
|
||||
sha1 = { version = "0.10", features = ["oid"] }
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
@@ -13,6 +13,8 @@ tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
futures.workspace = true
|
||||
sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
hex.workspace = true
|
||||
zip.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,15 @@ pub struct Auth {
|
||||
/// Dashed or undashed UUID.
|
||||
pub uuid: String,
|
||||
pub access_token: String,
|
||||
/// "msa" for Microsoft accounts, "legacy" for offline.
|
||||
/// "mojang" for panel (Yggdrasil) accounts, "legacy" for offline.
|
||||
pub user_type: String,
|
||||
pub xuid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOptions {
|
||||
pub auth: Auth,
|
||||
/// JVM arguments that must come first (the authlib-injector agent).
|
||||
pub agent_args: Vec<String>,
|
||||
pub game_dir: PathBuf,
|
||||
pub memory_min_mb: u32,
|
||||
pub memory_max_mb: u32,
|
||||
@@ -187,7 +188,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
|
||||
vars.insert("auth_access_token", opts.auth.access_token.clone());
|
||||
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
|
||||
vars.insert("clientid", String::new());
|
||||
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
|
||||
vars.insert("auth_xuid", String::new());
|
||||
vars.insert("user_type", opts.auth.user_type.clone());
|
||||
vars.insert("user_properties", "{}".into());
|
||||
vars.insert("version_type", opts.version_label.clone());
|
||||
@@ -203,7 +204,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
|
||||
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
|
||||
}
|
||||
|
||||
let mut args = Vec::new();
|
||||
let mut args = opts.agent_args.clone();
|
||||
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
|
||||
args.push(format!("-Xmx{}M", opts.memory_max_mb));
|
||||
args.extend(gc_args(opts.gc, installed.java_major));
|
||||
@@ -336,8 +337,8 @@ mod tests {
|
||||
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
},
|
||||
agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()],
|
||||
game_dir: "/g".into(),
|
||||
memory_min_mb: 1024,
|
||||
memory_max_mb: 4096,
|
||||
@@ -362,7 +363,7 @@ mod tests {
|
||||
let layout = Layout::new("/root");
|
||||
let cmd = build(&installed(json), &layout, &opts(true));
|
||||
let a = cmd.args.join(" ");
|
||||
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
|
||||
assert!(a.starts_with("-javaagent:/a.jar=https://p/api/yggdrasil -Xms1024M -Xmx4096M"), "agent must come first");
|
||||
assert!(a.contains("-Dcustom=1"));
|
||||
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
|
||||
assert!(a.contains("--width 1280 --height 720"));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! tested without a window.
|
||||
|
||||
pub mod assets;
|
||||
pub mod authlib;
|
||||
pub mod http;
|
||||
pub mod install;
|
||||
pub mod java;
|
||||
@@ -13,7 +14,6 @@ pub mod libraries;
|
||||
pub mod loaders;
|
||||
pub mod maven;
|
||||
pub mod meta;
|
||||
pub mod msa;
|
||||
pub mod options;
|
||||
pub mod paths;
|
||||
pub mod ping;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) {
|
||||
uuid: scopenet_shared::offline_uuid("CiBot"),
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
},
|
||||
agent_args: vec![],
|
||||
game_dir,
|
||||
memory_min_mb: 512,
|
||||
memory_max_mb: 2048,
|
||||
@@ -94,3 +94,14 @@ async fn forge_1_20_1() {
|
||||
async fn neoforge_1_21_1() {
|
||||
run("1.21.1", Loader::NeoForge).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn authlib_injector_official_download() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let layout = Layout::new(dir.path());
|
||||
let jar = scopenet_core::authlib::ensure(&scopenet_core::http::client(), &layout, None).await.unwrap();
|
||||
let zip = zip::ZipArchive::new(std::fs::File::open(&jar).unwrap()).unwrap();
|
||||
assert!(zip.file_names().any(|n| n == "META-INF/MANIFEST.MF"), "not a jar: {}", jar.display());
|
||||
println!("authlib-injector: {}", jar.display());
|
||||
}
|
||||
+40
-10
@@ -175,23 +175,48 @@ pub enum RegistrationMode {
|
||||
pub struct AuthConfig {
|
||||
pub panel_accounts: bool,
|
||||
pub registration: RegistrationMode,
|
||||
pub microsoft: bool,
|
||||
pub microsoft_client_id: Option<String>,
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_accounts: true,
|
||||
registration: RegistrationMode::Closed,
|
||||
microsoft: false,
|
||||
microsoft_client_id: None,
|
||||
offline_local: true,
|
||||
}
|
||||
Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Yggdrasil session tokens handed to the launcher at sign-in. The access
|
||||
/// token is what the game (via authlib-injector) uses to join servers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct YggdrasilTokens {
|
||||
pub access_token: String,
|
||||
pub client_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CapeInfo {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A player's in-game identity: UUID, skin and cape.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct PlayerProfile {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub skin_url: Option<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)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
@@ -210,7 +235,8 @@ pub struct RegisterRequest {
|
||||
pub struct PublicUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
/// Offline-mode UUID (dashed), identical to what an offline server computes.
|
||||
/// The player's UUID (dashed). New accounts get the offline-mode UUID for
|
||||
/// their name, so offline and authenticated servers agree.
|
||||
pub uuid: String,
|
||||
pub role: String,
|
||||
pub groups: Vec<String>,
|
||||
@@ -218,11 +244,15 @@ pub struct PublicUser {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
/// Panel session token (manifest, skins, account API).
|
||||
pub token: String,
|
||||
pub user: PublicUser,
|
||||
/// Set when the account exists but is waiting for admin approval.
|
||||
#[serde(default)]
|
||||
pub pending: bool,
|
||||
/// Game session for authlib-injector; absent for pending accounts.
|
||||
#[serde(default)]
|
||||
pub yggdrasil: Option<YggdrasilTokens>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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).
|
||||
@@ -29,6 +29,10 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio",
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
percent-encoding = "2"
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
rsa = { version = "0.9", features = ["sha1", "pem"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -9,7 +9,7 @@ use argon2::Argon2;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use scopenet_shared::{offline_uuid, PublicUser};
|
||||
use scopenet_shared::PublicUser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
@@ -81,6 +81,13 @@ pub struct UserRow {
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub last_login: Option<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 {
|
||||
@@ -100,13 +107,46 @@ pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUs
|
||||
Ok(PublicUser {
|
||||
id: user.id,
|
||||
username: user.username.clone(),
|
||||
uuid: offline_uuid(&user.username),
|
||||
uuid: user.uuid.clone(),
|
||||
role: user.role.clone(),
|
||||
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
|
||||
.headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct Config {
|
||||
pub jwt_secret: Option<String>,
|
||||
pub curseforge_api_key: Option<String>,
|
||||
pub max_upload_mb: usize,
|
||||
pub public_url: Option<String>,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> Option<String> {
|
||||
@@ -29,6 +30,7 @@ impl Config {
|
||||
jwt_secret: var("JWT_SECRET"),
|
||||
curseforge_api_key: var("CURSEFORGE_API_KEY"),
|
||||
max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048),
|
||||
public_url: var("PUBLIC_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +40,10 @@ impl Config {
|
||||
pub fn uploads_dir(&self) -> PathBuf {
|
||||
self.data_dir.join("uploads")
|
||||
}
|
||||
pub fn textures_dir(&self) -> PathBuf {
|
||||
self.data_dir.join("textures")
|
||||
}
|
||||
pub fn signing_key_path(&self) -> PathBuf {
|
||||
self.data_dir.join("yggdrasil-signing.pem")
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,107 @@ const MIGRATIONS: &[&str] = &[
|
||||
);
|
||||
CREATE INDEX events_created ON events(created_at);
|
||||
"#,
|
||||
// 2: Yggdrasil auth server (UUIDs, skins, capes, sessions) and game
|
||||
// server integration (plugin/mod tracking)
|
||||
r#"
|
||||
ALTER TABLE users ADD COLUMN uuid TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN skin_hash TEXT;
|
||||
ALTER TABLE users ADD COLUMN skin_model TEXT NOT NULL DEFAULT 'classic';
|
||||
ALTER TABLE users ADD COLUMN cape_id INTEGER;
|
||||
ALTER TABLE users ADD COLUMN status_reason TEXT;
|
||||
|
||||
CREATE TABLE capes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
allowed_groups TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE ygg_tokens (
|
||||
access_token TEXT PRIMARY KEY,
|
||||
client_token TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX ygg_tokens_user ON ygg_tokens(user_id);
|
||||
CREATE TABLE ygg_sessions (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE player_keys (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
private_pem TEXT NOT NULL,
|
||||
public_pem TEXT NOT NULL,
|
||||
signature_v1 TEXT NOT NULL,
|
||||
signature_v2 TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
refreshed_after TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE launcher_sessions (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at);
|
||||
|
||||
CREATE TABLE game_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_hint TEXT NOT NULL,
|
||||
access TEXT NOT NULL DEFAULT 'all',
|
||||
allowed_groups TEXT NOT NULL DEFAULT '[]',
|
||||
require_launcher INTEGER NOT NULL DEFAULT 0,
|
||||
software TEXT,
|
||||
mc_version TEXT,
|
||||
plugin_version TEXT,
|
||||
online_mode INTEGER,
|
||||
max_players INTEGER NOT NULL DEFAULT 0,
|
||||
online_count INTEGER NOT NULL DEFAULT 0,
|
||||
tps REAL,
|
||||
last_seen TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE server_online (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, uuid)
|
||||
);
|
||||
CREATE TABLE player_stats (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
playtime_secs INTEGER NOT NULL DEFAULT 0,
|
||||
joins INTEGER NOT NULL DEFAULT 0,
|
||||
deaths INTEGER NOT NULL DEFAULT 0,
|
||||
player_kills INTEGER NOT NULL DEFAULT 0,
|
||||
mob_kills INTEGER NOT NULL DEFAULT 0,
|
||||
blocks_broken INTEGER NOT NULL DEFAULT 0,
|
||||
blocks_placed INTEGER NOT NULL DEFAULT 0,
|
||||
messages INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, uuid)
|
||||
);
|
||||
CREATE INDEX player_stats_uuid ON player_stats(uuid);
|
||||
CREATE TABLE server_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
uuid TEXT,
|
||||
name TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX server_events_server ON server_events(server_id, id);
|
||||
CREATE INDEX server_events_uuid ON server_events(uuid, id);
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
@@ -112,6 +213,20 @@ async fn migrate(pool: &SqlitePool) -> Result<()> {
|
||||
tx.commit().await?;
|
||||
tracing::info!("applied database migration {version}");
|
||||
}
|
||||
backfill_uuids(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accounts created before the auth server existed get the offline-mode UUID
|
||||
/// for their name — the one offline servers already knew them by.
|
||||
async fn backfill_uuids(pool: &SqlitePool) -> Result<()> {
|
||||
let missing: Vec<(i64, String)> = sqlx::query_as("SELECT id, username FROM users WHERE uuid = ''").fetch_all(pool).await?;
|
||||
for (id, name) in missing {
|
||||
sqlx::query("UPDATE users SET uuid = ? WHERE id = ?").bind(scopenet_shared::offline_uuid(&name)).bind(id).execute(pool).await?;
|
||||
}
|
||||
if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE name = 'users_uuid'").fetch_one(pool).await? == 0 {
|
||||
sqlx::raw_sql("CREATE UNIQUE INDEX users_uuid ON users(uuid)").execute(pool).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+19
-7
@@ -4,10 +4,13 @@ pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod net;
|
||||
pub mod packs;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
pub mod textures;
|
||||
pub mod yggdrasil;
|
||||
|
||||
use axum::http::{header, HeaderValue};
|
||||
use axum::Router;
|
||||
@@ -38,9 +41,17 @@ pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(AppState {
|
||||
db,
|
||||
ygg,
|
||||
keys: Arc::new(auth::Keys::new(&secret)),
|
||||
http: scopenet_core::http::client(),
|
||||
login_guard: Arc::new(auth::LoginGuard::default()),
|
||||
@@ -62,13 +73,9 @@ pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> {
|
||||
(hex::encode(bytes), true)
|
||||
}
|
||||
};
|
||||
let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?;
|
||||
sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)")
|
||||
.bind(&state.cfg.admin_username)
|
||||
.bind(hash)
|
||||
.bind(db::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
auth::create_user(state, &state.cfg.admin_username, &password, None, "admin", "active")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.message))?;
|
||||
if generated {
|
||||
tracing::warn!("============================================================");
|
||||
tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username);
|
||||
@@ -91,6 +98,11 @@ pub fn app(state: AppState) -> Router {
|
||||
.nest_service("/files", ServeDir::new(state.cfg.files_dir()))
|
||||
.nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())))
|
||||
.fallback_service(spa)
|
||||
// Lets authlib-injector users enter just the panel URL (API Location Indication).
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
header::HeaderName::from_static("x-authlib-injector-api-location"),
|
||||
HeaderValue::from_static("/api/yggdrasil/"),
|
||||
))
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
|
||||
@@ -25,7 +25,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
tracing::info!("SCOPENET panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION"));
|
||||
axum::serve(listener, app(state)).with_graceful_shutdown(shutdown()).await?;
|
||||
axum::serve(listener, app(state).into_make_service_with_connect_info::<std::net::SocketAddr>())
|
||||
.with_graceful_shutdown(shutdown())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -69,13 +69,28 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
|
||||
pub struct AdminUserView {
|
||||
#[serde(flatten)]
|
||||
user: UserRow,
|
||||
uuid: 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> {
|
||||
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>>> {
|
||||
@@ -95,6 +110,7 @@ pub struct UserInput {
|
||||
email: Option<String>,
|
||||
role: Option<String>,
|
||||
status: Option<String>,
|
||||
status_reason: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -135,21 +151,7 @@ pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input
|
||||
check_role(&role)?;
|
||||
let status = input.status.unwrap_or_else(|| "active".into());
|
||||
check_status(&status)?;
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(auth::hash_password(&password)?)
|
||||
.bind(input.email.filter(|e| !e.trim().is_empty()))
|
||||
.bind(&role)
|
||||
.bind(&status)
|
||||
.bind(crate::db::now())
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"),
|
||||
e => e.into(),
|
||||
})?;
|
||||
let id = auth::create_user(&state, username, &password, input.email.as_deref(), &role, &status).await?;
|
||||
if let Some(groups) = input.groups {
|
||||
set_groups(&state, id, &groups).await?;
|
||||
}
|
||||
@@ -190,6 +192,13 @@ pub async fn update_user(
|
||||
}
|
||||
sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?;
|
||||
}
|
||||
if let Some(reason) = input.status_reason {
|
||||
sqlx::query("UPDATE users SET status_reason = ? WHERE id = ?")
|
||||
.bind(Some(reason.trim()).filter(|r| !r.is_empty()))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
if let Some(status) = input.status {
|
||||
check_status(&status)?;
|
||||
if me.id == id && status != "active" {
|
||||
@@ -299,9 +308,13 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(mut
|
||||
Some("-") => None,
|
||||
Some(k) => Some(k.to_string()),
|
||||
};
|
||||
if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() {
|
||||
return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID"));
|
||||
s.public_url = s.public_url.map(|u| u.trim().trim_end_matches('/').to_string()).filter(|u| !u.is_empty());
|
||||
if let Some(u) = &s.public_url {
|
||||
if !u.starts_with("http://") && !u.starts_with("https://") {
|
||||
return Err(AppError::bad_request("the public URL must start with https:// (or http://)"));
|
||||
}
|
||||
}
|
||||
s.auth.yggdrasil_url = None; // derived, never stored
|
||||
store::kv_set(&state, "settings", &s).await?;
|
||||
settings_view(&state).await
|
||||
}
|
||||
@@ -670,3 +683,166 @@ pub async fn upload_media(_: AdminUser, State(state): State<AppState>, mut form:
|
||||
}
|
||||
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,
|
||||
})))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod account;
|
||||
pub mod admin;
|
||||
pub mod meta;
|
||||
pub mod public;
|
||||
@@ -16,7 +17,15 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/launcher/events", post(public::event))
|
||||
.route("/auth/login", post(public::login))
|
||||
.route("/auth/register", post(public::register))
|
||||
.route("/auth/me", get(public::me));
|
||||
.route("/auth/me", get(public::me))
|
||||
.route("/account/profile", get(account::profile))
|
||||
.route("/account/skin", post(account::upload_skin).delete(account::delete_skin))
|
||||
.route("/account/skin/model", axum::routing::put(account::set_model))
|
||||
.route("/account/cape", axum::routing::put(account::set_cape))
|
||||
.route("/avatar/{id}", get(account::avatar))
|
||||
.route("/launcher/authlib-injector.json", get(account::authlib_index))
|
||||
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
||||
|
||||
let admin = Router::new()
|
||||
.route("/stats", get(admin::stats))
|
||||
@@ -34,6 +43,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/instances/{id}/import/upload", post(admin::import_upload))
|
||||
.route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file))
|
||||
.route("/uploads", post(admin::upload_media))
|
||||
.route("/users/{id}/skin", post(admin::admin_set_skin).delete(admin::admin_delete_skin))
|
||||
.route("/users/{id}/cape", axum::routing::put(admin::admin_set_cape))
|
||||
.route("/capes", get(admin::list_capes).post(admin::create_cape))
|
||||
.route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape))
|
||||
.route("/auth-server", get(admin::auth_server_info))
|
||||
.route("/meta/minecraft", get(meta::minecraft))
|
||||
.route("/meta/loaders/{loader}", get(meta::loaders))
|
||||
.route("/modrinth/search", get(meta::modrinth_search))
|
||||
@@ -42,5 +56,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
||||
.layer(DefaultBodyLimit::max(upload_limit));
|
||||
|
||||
Router::new().nest("/api/v1", launcher).nest("/api/admin", admin)
|
||||
Router::new()
|
||||
.nest("/api/v1", launcher)
|
||||
.nest("/api/admin", admin)
|
||||
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
use crate::auth::{self, AuthUser, MaybeUser, UserRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::net::{self, ClientIp};
|
||||
use crate::state::AppState;
|
||||
use crate::store;
|
||||
use crate::yggdrasil;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use scopenet_shared::*;
|
||||
|
||||
@@ -12,7 +15,7 @@ pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
pub async fn manifest(State(state): State<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 groups = match &user {
|
||||
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,
|
||||
};
|
||||
let mut auth_cfg = settings.auth;
|
||||
if !auth_cfg.microsoft {
|
||||
auth_cfg.microsoft_client_id = None;
|
||||
}
|
||||
auth_cfg.yggdrasil_url = Some(format!("{}{}", net::public_base(&state, &headers).await, yggdrasil::ROOT));
|
||||
Ok(Json(LauncherManifest {
|
||||
api_version: API_VERSION,
|
||||
panel_version: env!("CARGO_PKG_VERSION").into(),
|
||||
@@ -62,7 +63,18 @@ pub async fn instance_manifest(
|
||||
}
|
||||
|
||||
async fn find_user(state: &AppState, username: &str) -> AppResult<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>> {
|
||||
@@ -77,14 +89,16 @@ pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>)
|
||||
state.login_guard.succeed(&username);
|
||||
match user.status.as_str() {
|
||||
"pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")),
|
||||
"disabled" => return Err(AppError::forbidden("this account has been disabled")),
|
||||
"disabled" => {
|
||||
return Err(AppError::forbidden(user.status_reason.clone().unwrap_or_else(|| "this account has been disabled".into())))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !settings.auth.panel_accounts && !user.is_admin() {
|
||||
return Err(AppError::forbidden("account sign-in is currently disabled"));
|
||||
}
|
||||
sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?;
|
||||
Ok(Json(AuthResponse { token: state.keys.issue(&user)?, user: auth::public_user(&state, &user).await?, pending: false }))
|
||||
Ok(Json(signed_in(&state, &user).await?))
|
||||
}
|
||||
|
||||
pub async fn register(State(state): State<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"));
|
||||
}
|
||||
let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" };
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"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 id = auth::create_user(&state, username, &req.password, req.email.as_deref(), "player", status).await?;
|
||||
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
|
||||
let pending = status == "pending";
|
||||
Ok(Json(AuthResponse {
|
||||
token: if pending { String::new() } else { state.keys.issue(&user)? },
|
||||
user: auth::public_user(&state, &user).await?,
|
||||
pending,
|
||||
}))
|
||||
if status == "pending" {
|
||||
return Ok(Json(AuthResponse {
|
||||
token: String::new(),
|
||||
user: auth::public_user(&state, &user).await?,
|
||||
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>> {
|
||||
@@ -127,9 +135,22 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
|
||||
pub async fn event(
|
||||
State(state): State<AppState>,
|
||||
MaybeUser(user): MaybeUser,
|
||||
ClientIp(ip): ClientIp,
|
||||
Json(ev): Json<LaunchEvent>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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>());
|
||||
sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(ev.instance_id.chars().take(64).collect::<String>())
|
||||
|
||||
@@ -7,7 +7,10 @@ use std::sync::Arc;
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub cfg: Arc<Config>,
|
||||
/// Panel session tokens (JWT).
|
||||
pub keys: Arc<Keys>,
|
||||
/// Auth server signing key (textures, chat certificates).
|
||||
pub ygg: Arc<crate::yggdrasil::keys::Keys>,
|
||||
pub http: reqwest::Client,
|
||||
pub login_guard: Arc<LoginGuard>,
|
||||
}
|
||||
@@ -27,6 +27,9 @@ pub struct Settings {
|
||||
pub curseforge_api_key: Option<String>,
|
||||
/// Where players can download the launcher (shown on the dashboard).
|
||||
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> {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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-----"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,7 @@
|
||||
//! End-to-end tests against the real router with an in-memory database.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db};
|
||||
use serde_json::{json, Value};
|
||||
use std::io::Write;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct TestApp {
|
||||
router: axum::Router,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
async fn setup() -> TestApp {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = Config {
|
||||
bind: "127.0.0.1:0".into(),
|
||||
data_dir: dir.path().to_path_buf(),
|
||||
web_dir: dir.path().join("web"),
|
||||
admin_username: "admin".into(),
|
||||
admin_password: Some("supersecret".into()),
|
||||
jwt_secret: Some("test-secret-test-secret-test-secret".into()),
|
||||
curseforge_api_key: None,
|
||||
max_upload_mb: 64,
|
||||
};
|
||||
let pool = db::connect_memory().await.unwrap();
|
||||
let state = build_state(cfg, pool).await.unwrap();
|
||||
bootstrap_admin(&state).await.unwrap();
|
||||
TestApp { router: app(state), _dir: dir }
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option<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()
|
||||
}
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_and_default_manifest() {
|
||||
@@ -305,6 +220,6 @@ async fn settings_never_leak_curseforge_key() {
|
||||
// Saving again with an empty key keeps it.
|
||||
let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await;
|
||||
assert_eq!(v["curseforge_key_set"], true);
|
||||
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id");
|
||||
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"public_url": "ftp://nope"}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "public URL must be http(s)");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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!([]));
|
||||
}
|
||||
Reference in new issue
Block a user