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
@@ -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