Panel: Yggdrasil auth server (authlib-injector), skins and capes

- Yggdrasil API per the authlib-injector spec: metadata with signing key
  and skin domains, authenticate/refresh/validate/invalidate/signout,
  join/hasJoined, profile lookup, texture upload, and the minecraftservices
  endpoints (chat certificates, publickeys, attributes, blocklist)
- 4096-bit signing key generated once into the data volume; textures and
  chat certificates signed SHA1withRSA (verified with Java's own crypto)
- Skins/capes stored content-addressed after validation and re-encoding;
  cape library with public/group/private visibility; head avatars API
- Launcher login returns a game session; launcher sessions recorded for
  launcher-only servers; authlib-injector download mirror
- Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens,
  sessions, chat keys, game server tables
- Remove Microsoft sign-in from the engine and panel

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

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
//! Request helpers: the panel's public URL and the client's IP address.
use crate::error::AppError;
use crate::state::AppState;
use crate::store;
use axum::extract::{ConnectInfo, FromRequestParts};
use axum::http::request::Parts;
use axum::http::HeaderMap;
use std::net::SocketAddr;
fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok()).map(str::trim).filter(|v| !v.is_empty())
}
/// The URL players reach the panel at, without a trailing slash.
///
/// Order: the admin's setting → `PUBLIC_URL` → what the request says
/// (honouring `X-Forwarded-*` from a reverse proxy).
pub async fn public_base(state: &AppState, headers: &HeaderMap) -> String {
if let Ok(s) = store::settings(state).await {
if let Some(u) = s.public_url.filter(|u| !u.is_empty()) {
return u.trim_end_matches('/').to_string();
}
}
if let Some(u) = &state.cfg.public_url {
return u.trim_end_matches('/').to_string();
}
let host = header(headers, "x-forwarded-host").or_else(|| header(headers, "host")).unwrap_or("localhost:8080");
let proto = header(headers, "x-forwarded-proto").map(|p| p.split(',').next().unwrap_or(p).trim()).unwrap_or("http");
format!("{proto}://{}", host.split(',').next().unwrap_or(host).trim())
}
/// Host part of a URL (`https://a.b:8443/x` → `a.b`), used for authlib's
/// skin-domain allow-list.
pub fn host_of(url: &str) -> String {
let rest = url.split("://").nth(1).unwrap_or(url);
let authority = rest.split('/').next().unwrap_or(rest);
let host = authority.rsplit('@').next().unwrap_or(authority);
if host.starts_with('[') {
return host.split(']').next().unwrap_or(host).trim_start_matches('[').to_string();
}
host.split(':').next().unwrap_or(host).to_string()
}
/// Client IP: the first `X-Forwarded-For` hop, `X-Real-IP`, or the socket.
pub struct ClientIp(pub Option<String>);
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let forwarded = header(&parts.headers, "x-forwarded-for").and_then(|v| v.split(',').next()).map(|v| v.trim().to_string());
let real = header(&parts.headers, "x-real-ip").map(String::from);
let socket = parts.extensions.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip().to_string());
Ok(ClientIp(forwarded.or(real).or(socket)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts() {
assert_eq!(host_of("https://panel.example.com/api"), "panel.example.com");
assert_eq!(host_of("http://localhost:8080"), "localhost");
assert_eq!(host_of("https://[::1]:8443/"), "::1");
}
}