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

+43 -3
View File
@@ -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)
+8
View File
@@ -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")
}
}
+115
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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(())
}
+68
View File
@@ -0,0 +1,68 @@
//! Request helpers: the panel's public URL and the client's IP address.
use crate::error::AppError;
use crate::state::AppState;
use crate::store;
use axum::extract::{ConnectInfo, FromRequestParts};
use axum::http::request::Parts;
use axum::http::HeaderMap;
use std::net::SocketAddr;
fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok()).map(str::trim).filter(|v| !v.is_empty())
}
/// The URL players reach the panel at, without a trailing slash.
///
/// Order: the admin's setting → `PUBLIC_URL` → what the request says
/// (honouring `X-Forwarded-*` from a reverse proxy).
pub async fn public_base(state: &AppState, headers: &HeaderMap) -> String {
if let Ok(s) = store::settings(state).await {
if let Some(u) = s.public_url.filter(|u| !u.is_empty()) {
return u.trim_end_matches('/').to_string();
}
}
if let Some(u) = &state.cfg.public_url {
return u.trim_end_matches('/').to_string();
}
let host = header(headers, "x-forwarded-host").or_else(|| header(headers, "host")).unwrap_or("localhost:8080");
let proto = header(headers, "x-forwarded-proto").map(|p| p.split(',').next().unwrap_or(p).trim()).unwrap_or("http");
format!("{proto}://{}", host.split(',').next().unwrap_or(host).trim())
}
/// Host part of a URL (`https://a.b:8443/x` → `a.b`), used for authlib's
/// skin-domain allow-list.
pub fn host_of(url: &str) -> String {
let rest = url.split("://").nth(1).unwrap_or(url);
let authority = rest.split('/').next().unwrap_or(rest);
let host = authority.rsplit('@').next().unwrap_or(authority);
if host.starts_with('[') {
return host.split(']').next().unwrap_or(host).trim_start_matches('[').to_string();
}
host.split(':').next().unwrap_or(host).to_string()
}
/// Client IP: the first `X-Forwarded-For` hop, `X-Real-IP`, or the socket.
pub struct ClientIp(pub Option<String>);
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let forwarded = header(&parts.headers, "x-forwarded-for").and_then(|v| v.split(',').next()).map(|v| v.trim().to_string());
let real = header(&parts.headers, "x-real-ip").map(String::from);
let socket = parts.extensions.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip().to_string());
Ok(ClientIp(forwarded.or(real).or(socket)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts() {
assert_eq!(host_of("https://panel.example.com/api"), "panel.example.com");
assert_eq!(host_of("http://localhost:8080"), "localhost");
assert_eq!(host_of("https://[::1]:8443/"), "::1");
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Player-facing account API used by the launcher: profile, skin, cape,
//! avatars and the authlib-injector mirror.
use crate::auth::{AuthUser, UserRow};
use crate::error::{AppError, AppResult};
use crate::net;
use crate::state::AppState;
use crate::textures;
use crate::yggdrasil;
use axum::body::Body;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use scopenet_shared::PlayerProfile;
use serde::Deserialize;
use std::time::Duration;
pub async fn profile(State(state): State<AppState>, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult<Json<PlayerProfile>> {
let base = net::public_base(&state, &headers).await;
Ok(Json(yggdrasil::player_profile(&state, &base, &user).await?))
}
/// Read a `file` (+ optional `model`) multipart upload.
pub async fn read_texture_form(form: &mut Multipart) -> AppResult<(Vec<u8>, String)> {
let mut model = String::from("classic");
let mut file = None;
while let Some(field) = form.next_field().await? {
match field.name().unwrap_or_default() {
"model" => model = field.text().await?,
"file" => file = Some(field.bytes().await?.to_vec()),
_ => {}
}
}
Ok((file.ok_or_else(|| AppError::bad_request("no file uploaded"))?, model))
}
async fn reload(state: &AppState, id: i64) -> AppResult<UserRow> {
Ok(sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?)
}
pub async fn upload_skin(
State(state): State<AppState>,
headers: HeaderMap,
AuthUser(user): AuthUser,
mut form: Multipart,
) -> AppResult<Json<PlayerProfile>> {
let (bytes, model) = read_texture_form(&mut form).await?;
yggdrasil::set_skin(&state, user.id, &bytes, &model).await?;
let base = net::public_base(&state, &headers).await;
Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?))
}
#[derive(Deserialize)]
pub struct ModelInput {
model: String,
}
pub async fn set_model(
State(state): State<AppState>,
headers: HeaderMap,
AuthUser(user): AuthUser,
Json(input): Json<ModelInput>,
) -> AppResult<Json<PlayerProfile>> {
let model = if input.model == "slim" { "slim" } else { "classic" };
sqlx::query("UPDATE users SET skin_model = ? WHERE id = ?").bind(model).bind(user.id).execute(&state.db).await?;
let base = net::public_base(&state, &headers).await;
Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?))
}
pub async fn delete_skin(State(state): State<AppState>, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult<Json<PlayerProfile>> {
sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?;
let base = net::public_base(&state, &headers).await;
Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?))
}
#[derive(Deserialize)]
pub struct CapeInput {
cape_id: Option<i64>,
}
pub async fn set_cape(
State(state): State<AppState>,
headers: HeaderMap,
AuthUser(user): AuthUser,
Json(input): Json<CapeInput>,
) -> AppResult<Json<PlayerProfile>> {
if let Some(id) = input.cape_id {
let allowed = yggdrasil::available_capes(&state, &user).await?;
if !allowed.iter().any(|c| c.id == id) {
return Err(AppError::forbidden("that cape isn't available to you"));
}
}
sqlx::query("UPDATE users SET cape_id = ? WHERE id = ?").bind(input.cape_id).bind(user.id).execute(&state.db).await?;
let base = net::public_base(&state, &headers).await;
Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?))
}
#[derive(Deserialize)]
pub struct AvatarQuery {
#[serde(default)]
size: Option<u32>,
}
/// Player head render by UUID or name. 404 when the player has no skin
/// (callers show their own placeholder).
pub async fn avatar(State(state): State<AppState>, Path(id): Path<String>, Query(q): Query<AvatarQuery>) -> AppResult<Response> {
let user = match yggdrasil::user_by_uuid(&state, &id).await? {
Some(u) => Some(u),
None => crate::auth::find_user_by_name(&state, &id).await?,
};
let hash = user.and_then(|u| u.skin_hash).ok_or_else(|| AppError::not_found("no skin"))?;
let path = textures::path(&state.cfg.textures_dir(), &hash).ok_or_else(|| AppError::not_found("no skin"))?;
let bytes = tokio::fs::read(path).await.map_err(|_| AppError::not_found("no skin"))?;
let size = q.size.unwrap_or(64);
let png = tokio::task::spawn_blocking(move || textures::render_head(&bytes, size))
.await
.map_err(|e| AppError::bad_request(e.to_string()))??;
Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=300")], Body::from(png)).into_response())
}
// ---------------------------------------------------------------------------
// authlib-injector mirror
// ---------------------------------------------------------------------------
fn mirror_dir(state: &AppState) -> std::path::PathBuf {
state.cfg.data_dir.join("authlib-injector")
}
/// Refresh the cached authlib-injector from the official source at most
/// every six hours; serve the cache when the official site is unreachable.
async fn mirror_artifact(state: &AppState) -> AppResult<scopenet_core::authlib::Artifact> {
use scopenet_core::authlib::{sha256_file, Artifact, OFFICIAL_LATEST};
let dir = mirror_dir(state);
let index = dir.join("latest.json");
let fresh = std::fs::metadata(&index)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age < Duration::from_secs(6 * 3600));
let cached: Option<Artifact> = std::fs::read(&index).ok().and_then(|b| serde_json::from_slice(&b).ok());
let jar_ok = |a: &Artifact| {
sha256_file(&dir.join("authlib-injector.jar")).map(|h| h == a.checksums.sha256.to_ascii_lowercase()).unwrap_or(false)
};
if let Some(a) = cached.as_ref().filter(|a| fresh && jar_ok(a)) {
return Ok(a.clone());
}
let fetched: anyhow::Result<Artifact> = async {
let artifact: Artifact = scopenet_core::http::get_json(&state.http, OFFICIAL_LATEST).await?;
if !jar_ok(&artifact) {
let tmp = dir.join("authlib-injector.jar.download");
scopenet_core::http::download_one(
&state.http,
&scopenet_core::http::Download::new(artifact.download_url.clone(), tmp.clone(), None, None),
&|_| {},
)
.await?;
if sha256_file(&tmp)? != artifact.checksums.sha256.to_ascii_lowercase() {
std::fs::remove_file(&tmp).ok();
anyhow::bail!("checksum mismatch");
}
std::fs::rename(&tmp, dir.join("authlib-injector.jar"))?;
}
std::fs::create_dir_all(&dir)?;
std::fs::write(&index, serde_json::to_vec(&artifact)?)?;
Ok(artifact)
}
.await;
match (fetched, cached) {
(Ok(a), _) => Ok(a),
(Err(e), Some(a)) if jar_ok(&a) => {
tracing::warn!("authlib-injector refresh failed, serving cached {}: {e:#}", a.version);
Ok(a)
}
(Err(e), _) => Err(AppError::new(StatusCode::BAD_GATEWAY, format!("authlib-injector unavailable: {e:#}"))),
}
}
pub async fn authlib_index(State(state): State<AppState>) -> AppResult<Json<scopenet_core::authlib::Artifact>> {
let mut a = mirror_artifact(&state).await?;
a.download_url = "/api/v1/launcher/authlib-injector.jar".into();
Ok(Json(a))
}
pub async fn authlib_jar(State(state): State<AppState>) -> AppResult<Response> {
mirror_artifact(&state).await?;
let bytes = tokio::fs::read(mirror_dir(&state).join("authlib-injector.jar")).await?;
Ok(([(header::CONTENT_TYPE, "application/java-archive")], Body::from(bytes)).into_response())
}
+195 -19
View File
@@ -69,13 +69,28 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
pub struct AdminUserView {
#[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,
})))
}
+19 -2
View File
@@ -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)))
}
+44 -23
View File
@@ -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>())
+3
View File
@@ -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>,
}
+3
View File
@@ -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> {
+124
View File
@@ -0,0 +1,124 @@
//! Skins and capes: validation, storage (content-addressed PNGs under
//! `data/textures/`) and rendering of player heads for avatars.
use crate::error::{AppError, AppResult};
use image::{imageops, ImageFormat, RgbaImage};
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Skin,
Cape,
}
fn decode(bytes: &[u8]) -> AppResult<RgbaImage> {
if bytes.len() > 2 * 1024 * 1024 {
return Err(AppError::bad_request("image too large (2 MB max)"));
}
let img =
image::load_from_memory_with_format(bytes, ImageFormat::Png).map_err(|_| AppError::bad_request("that isn't a valid PNG image"))?;
Ok(img.to_rgba8())
}
fn check_size(kind: Kind, w: u32, h: u32) -> AppResult<()> {
let ok = match kind {
// The vanilla client only accepts these two layouts.
Kind::Skin => (w, h) == (64, 64) || (w, h) == (64, 32),
// 64x32 is standard; HD capes are multiples of it; 22x17 is the old format.
Kind::Cape => (w % 64 == 0 && h * 2 == w && w <= 1024) || (w, h) == (22, 17),
};
if ok {
Ok(())
} else {
Err(AppError::bad_request(match kind {
Kind::Skin => format!("skins must be 64×64 (or legacy 64×32) pixels — this one is {w}×{h}"),
Kind::Cape => format!("capes must be 64×32 pixels — this one is {w}×{h}"),
}))
}
}
/// Validate, re-encode (strips metadata and anything smuggled inside the
/// file) and store a texture. Returns its hash.
pub fn store(dir: &Path, kind: Kind, bytes: &[u8]) -> AppResult<String> {
let img = decode(bytes)?;
check_size(kind, img.width(), img.height())?;
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.map_err(|e| AppError::bad_request(format!("couldn't process image: {e}")))?;
let hash = hex::encode(Sha256::digest(&out));
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{hash}.png"));
if !path.exists() {
std::fs::write(&path, &out)?;
}
Ok(hash)
}
pub fn path(dir: &Path, hash: &str) -> Option<PathBuf> {
// Hashes are hex only — never let a request escape the directory.
(hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| dir.join(format!("{hash}.png")))
}
/// Front view of the head (face + hat layer), scaled with nearest-neighbour
/// so pixels stay crisp.
pub fn render_head(skin_png: &[u8], size: u32) -> AppResult<Vec<u8>> {
let skin = decode(skin_png)?;
let mut face = imageops::crop_imm(&skin, 8, 8, 8, 8).to_image();
if skin.height() >= 16 && skin.width() >= 48 {
let hat = imageops::crop_imm(&skin, 40, 8, 8, 8).to_image();
// Some skins fill the hat layer with an opaque colour; ignore it then.
let opaque_hat = hat.pixels().all(|p| p[3] == 255);
if !opaque_hat {
imageops::overlay(&mut face, &hat, 0, 0);
}
}
let size = size.clamp(8, 512);
let scaled = imageops::resize(&face, size, size, imageops::FilterType::Nearest);
let mut out = Vec::new();
scaled.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| AppError::bad_request(e.to_string()))?;
Ok(out)
}
#[cfg(test)]
pub mod tests {
use super::*;
pub fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let img = RgbaImage::from_pixel(w, h, image::Rgba(rgba));
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
out
}
#[test]
fn validates_and_stores() {
let dir = tempfile::tempdir().unwrap();
let h1 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap();
let h2 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap();
assert_eq!(h1, h2, "content-addressed");
assert!(path(dir.path(), &h1).unwrap().exists());
assert!(store(dir.path(), Kind::Skin, &png(32, 32, [0, 0, 0, 255])).is_err());
assert!(store(dir.path(), Kind::Cape, &png(64, 32, [0, 0, 0, 255])).is_ok());
assert!(store(dir.path(), Kind::Skin, b"not a png").is_err());
assert!(path(dir.path(), "../../etc/passwd").is_none());
}
#[test]
fn renders_heads() {
let mut skin = RgbaImage::from_pixel(64, 64, image::Rgba([0, 0, 0, 0]));
for x in 8..16 {
for y in 8..16 {
skin.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
}
}
skin.put_pixel(40, 8, image::Rgba([0, 0, 255, 255])); // one hat pixel
let mut bytes = Vec::new();
skin.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap();
let head = image::load_from_memory(&render_head(&bytes, 64).unwrap()).unwrap().to_rgba8();
assert_eq!(head.dimensions(), (64, 64));
assert_eq!(head.get_pixel(0, 0).0, [0, 0, 255, 255], "hat overlays the face");
assert_eq!(head.get_pixel(63, 63).0, [255, 0, 0, 255]);
}
}
+74
View File
@@ -0,0 +1,74 @@
//! The auth server's RSA key. It signs skin/cape data ("textures") and
//! player chat certificates; game clients and servers learn the public half
//! from the Yggdrasil metadata via authlib-injector.
use anyhow::{Context, Result};
use base64::Engine;
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey, LineEnding};
use rsa::signature::{SignatureEncoding, Signer};
use rsa::{RsaPrivateKey, RsaPublicKey};
use sha1::Sha1;
use std::path::Path;
pub const KEY_BITS: usize = 4096;
pub struct Keys {
signer: SigningKey<Sha1>,
/// `-----BEGIN PUBLIC KEY-----` (X.509 SubjectPublicKeyInfo).
pub public_pem: String,
/// DER of the same, base64 — the format of Mojang's `/publickeys`.
pub public_der_b64: String,
}
impl Keys {
pub fn from_private(key: RsaPrivateKey) -> Result<Self> {
let public = RsaPublicKey::from(&key);
let public_pem = public.to_public_key_pem(LineEnding::LF)?;
let public_der_b64 = base64::engine::general_purpose::STANDARD.encode(public.to_public_key_der()?.as_bytes());
Ok(Self { signer: SigningKey::<Sha1>::new(key), public_pem, public_der_b64 })
}
/// Load `path`, or generate and save a new key if it doesn't exist.
pub fn load_or_create(path: &Path) -> Result<Self> {
if let Ok(pem) = std::fs::read_to_string(path) {
let key = RsaPrivateKey::from_pkcs8_pem(&pem).with_context(|| format!("reading {}", path.display()))?;
return Self::from_private(key);
}
tracing::info!("generating the auth server signing key ({KEY_BITS}-bit RSA, one-time)…");
let key = RsaPrivateKey::new(&mut rand::thread_rng(), KEY_BITS)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, key.to_pkcs8_pem(LineEnding::LF)?.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).ok();
}
Self::from_private(key)
}
/// SHA1withRSA, base64 — what Mojang uses for every Yggdrasil signature.
pub fn sign_b64(&self, data: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(self.signer.sign(data).to_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
#[test]
fn signs_verifiably() {
let key = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap();
let keys = Keys::from_private(key).unwrap();
let sig = base64::engine::general_purpose::STANDARD.decode(keys.sign_b64(b"hello")).unwrap();
let public = RsaPublicKey::from_public_key_pem(&keys.public_pem).unwrap();
VerifyingKey::<Sha1>::new(public).verify(b"hello", &Signature::try_from(sig.as_slice()).unwrap()).unwrap();
assert!(keys.public_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
}
}
+742
View File
@@ -0,0 +1,742 @@
//! A Yggdrasil-compatible authentication server, following the
//! authlib-injector specification:
//! <https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83>
//!
//! Game clients and servers run authlib-injector pointed at
//! `{panel}/api/yggdrasil`. Sign-in, server joins, skins and capes then all
//! come from the panel — no Mojang or Microsoft account needed.
pub mod keys;
use crate::auth::{self, UserRow};
use crate::error::AppResult;
use crate::net::{self, ClientIp};
use crate::state::AppState;
use crate::store;
use crate::textures;
use axum::body::Body;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use base64::Engine;
use rand::RngCore;
use scopenet_shared::{CapeInfo, PlayerProfile};
use serde::Deserialize;
use serde_json::{json, Value};
pub const ROOT: &str = "/api/yggdrasil";
const TOKEN_DAYS: i64 = 30;
const MAX_TOKENS_PER_USER: i64 = 10;
const SESSION_SECS: i64 = 60;
// ---------------------------------------------------------------------------
// Errors (Yggdrasil has its own error shape)
// ---------------------------------------------------------------------------
pub struct YggError {
status: StatusCode,
error: &'static str,
message: String,
}
impl YggError {
fn forbidden(message: impl Into<String>) -> Self {
Self { status: StatusCode::FORBIDDEN, error: "ForbiddenOperationException", message: message.into() }
}
fn bad_request(message: impl Into<String>) -> Self {
Self { status: StatusCode::BAD_REQUEST, error: "IllegalArgumentException", message: message.into() }
}
fn invalid_token() -> Self {
Self::forbidden("Invalid token.")
}
}
impl IntoResponse for YggError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.error, "errorMessage": self.message }))).into_response()
}
}
impl From<crate::error::AppError> for YggError {
fn from(e: crate::error::AppError) -> Self {
Self { status: e.status, error: "InternalServerError", message: e.message }
}
}
impl From<sqlx::Error> for YggError {
fn from(e: sqlx::Error) -> Self {
crate::error::AppError::from(e).into()
}
}
type YggResult<T> = Result<T, YggError>;
fn no_content() -> Response {
StatusCode::NO_CONTENT.into_response()
}
// ---------------------------------------------------------------------------
// Helpers shared with the launcher/admin APIs
// ---------------------------------------------------------------------------
pub fn undashed(uuid: &str) -> String {
uuid.replace('-', "").to_ascii_lowercase()
}
pub fn dashed(uuid: &str) -> Option<String> {
let u = undashed(uuid);
(u.len() == 32 && u.chars().all(|c| c.is_ascii_hexdigit()))
.then(|| format!("{}-{}-{}-{}-{}", &u[0..8], &u[8..12], &u[12..16], &u[16..20], &u[20..32]))
}
fn random_token() -> String {
let mut b = [0u8; 16];
rand::thread_rng().fill_bytes(&mut b);
hex::encode(b)
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct CapeRow {
pub id: i64,
pub name: String,
pub hash: String,
pub visibility: String,
pub allowed_groups: String,
pub created_at: String,
}
impl CapeRow {
pub fn info(&self, base: &str) -> CapeInfo {
CapeInfo { id: self.id, name: self.name.clone(), url: texture_url(base, &self.hash) }
}
}
pub fn texture_url(base: &str, hash: &str) -> String {
format!("{base}/textures/{hash}")
}
pub async fn user_by_uuid(state: &AppState, uuid: &str) -> AppResult<Option<UserRow>> {
let Some(d) = dashed(uuid) else { return Ok(None) };
Ok(sqlx::query_as("SELECT * FROM users WHERE uuid = ?").bind(d).fetch_optional(&state.db).await?)
}
pub async fn cape_of(state: &AppState, user: &UserRow) -> AppResult<Option<CapeRow>> {
let Some(id) = user.cape_id else { return Ok(None) };
Ok(sqlx::query_as("SELECT * FROM capes WHERE id = ?").bind(id).fetch_optional(&state.db).await?)
}
/// Capes the player may choose themselves.
pub async fn available_capes(state: &AppState, user: &UserRow) -> AppResult<Vec<CapeRow>> {
let groups = auth::user_groups(state, user.id).await?;
let all: Vec<CapeRow> = sqlx::query_as("SELECT * FROM capes ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?;
Ok(all
.into_iter()
.filter(|c| {
user.is_admin()
|| c.visibility == "public"
|| (c.visibility == "groups" && {
let allowed: Vec<String> = serde_json::from_str(&c.allowed_groups).unwrap_or_default();
allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g)))
})
})
.collect())
}
pub async fn player_profile(state: &AppState, base: &str, user: &UserRow) -> AppResult<PlayerProfile> {
Ok(PlayerProfile {
uuid: user.uuid.clone(),
name: user.username.clone(),
skin_url: user.skin_hash.as_deref().map(|h| texture_url(base, h)),
skin_model: user.skin_model.clone(),
cape: cape_of(state, user).await?.map(|c| c.info(base)),
available_capes: available_capes(state, user).await?.iter().map(|c| c.info(base)).collect(),
})
}
/// The base64 `textures` property value.
async fn textures_value(state: &AppState, base: &str, user: &UserRow) -> AppResult<String> {
let mut textures = serde_json::Map::new();
if let Some(hash) = &user.skin_hash {
let mut skin = json!({ "url": texture_url(base, hash) });
if user.skin_model == "slim" {
skin["metadata"] = json!({ "model": "slim" });
}
textures.insert("SKIN".into(), skin);
}
if let Some(cape) = cape_of(state, user).await? {
textures.insert("CAPE".into(), json!({ "url": texture_url(base, &cape.hash) }));
}
let value = json!({
"timestamp": chrono::Utc::now().timestamp_millis(),
"profileId": undashed(&user.uuid),
"profileName": user.username,
"textures": textures,
});
Ok(base64::engine::general_purpose::STANDARD.encode(value.to_string()))
}
/// A full game profile, optionally with signed properties.
pub async fn profile_json(state: &AppState, base: &str, user: &UserRow, signed: bool) -> AppResult<Value> {
let value = textures_value(state, base, user).await?;
let mut textures = json!({ "name": "textures", "value": value });
let mut uploadable = json!({ "name": "uploadableTextures", "value": "skin" });
if signed {
textures["signature"] = json!(state.ygg.sign_b64(value.as_bytes()));
uploadable["signature"] = json!(state.ygg.sign_b64(b"skin"));
}
Ok(json!({ "id": undashed(&user.uuid), "name": user.username, "properties": [textures, uploadable] }))
}
fn short_profile(user: &UserRow) -> Value {
json!({ "id": undashed(&user.uuid), "name": user.username })
}
/// Issue a game access token for `user_id`.
pub async fn issue_token(state: &AppState, user_id: i64, client_token: Option<String>) -> AppResult<(String, String)> {
let access = random_token();
let client = client_token.filter(|c| !c.is_empty() && c.len() <= 128).unwrap_or_else(random_token);
let now = chrono::Utc::now();
sqlx::query("DELETE FROM ygg_tokens WHERE expires_at < ?").bind(crate::db::now()).execute(&state.db).await?;
sqlx::query("INSERT INTO ygg_tokens (access_token, client_token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)")
.bind(&access)
.bind(&client)
.bind(user_id)
.bind(crate::db::now())
.bind((now + chrono::Duration::days(TOKEN_DAYS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.execute(&state.db)
.await?;
// Keep only the newest few sessions per account.
sqlx::query(
"DELETE FROM ygg_tokens WHERE user_id = ? AND access_token NOT IN
(SELECT access_token FROM ygg_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT ?)",
)
.bind(user_id)
.bind(user_id)
.bind(MAX_TOKENS_PER_USER)
.execute(&state.db)
.await?;
Ok((access, client))
}
/// The active account behind a valid token.
pub async fn token_user(state: &AppState, access: &str, client: Option<&str>) -> AppResult<Option<UserRow>> {
let row: Option<(i64, String)> =
sqlx::query_as("SELECT user_id, client_token FROM ygg_tokens WHERE access_token = ? AND expires_at > ?")
.bind(access)
.bind(crate::db::now())
.fetch_optional(&state.db)
.await?;
let Some((user_id, client_token)) = row else { return Ok(None) };
if client.is_some_and(|c| !c.is_empty() && c != client_token) {
return Ok(None);
}
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(user_id).fetch_optional(&state.db).await?;
Ok(user.filter(|u| u.status == "active"))
}
async fn check_password(state: &AppState, username: &str, password: &str) -> YggResult<UserRow> {
state.login_guard.check(username).map_err(|e| YggError::forbidden(e.message))?;
// Email or username (`feature.non_email_login`).
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?)")
.bind(username.trim())
.bind(username.trim())
.fetch_optional(&state.db)
.await?;
let Some(user) = user.filter(|u| auth::verify_password(password, &u.password_hash)) else {
state.login_guard.fail(username);
return Err(YggError::forbidden("Invalid credentials. Invalid username or password."));
};
state.login_guard.succeed(username);
match user.status.as_str() {
"active" => Ok(user),
"pending" => Err(YggError::forbidden("Your account is waiting for an admin to approve it.")),
_ => Err(YggError::forbidden(user.status_reason.clone().unwrap_or_else(|| "This account has been disabled.".into()))),
}
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
async fn metadata(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let base = net::public_base(&state, &headers).await;
let branding = store::branding(&state).await?;
Ok(Json(json!({
"meta": {
"serverName": branding.name,
"implementationName": "SCOPENET",
"implementationVersion": env!("CARGO_PKG_VERSION"),
"links": { "homepage": base, "register": base },
"feature.non_email_login": true,
"feature.enable_profile_key": true,
"feature.no_mojang_namespace": true,
},
"skinDomains": [net::host_of(&base)],
"signaturePublickey": state.ygg.public_pem,
})))
}
// ---------------------------------------------------------------------------
// authserver
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthenticateReq {
username: String,
password: String,
#[serde(default)]
client_token: Option<String>,
#[serde(default)]
request_user: bool,
}
fn user_json(user: &UserRow) -> Value {
json!({ "id": undashed(&user.uuid), "properties": [{ "name": "preferredLanguage", "value": "en" }] })
}
async fn authenticate(State(state): State<AppState>, Json(req): Json<AuthenticateReq>) -> YggResult<Json<Value>> {
let user = check_password(&state, &req.username, &req.password).await?;
let (access, client) = issue_token(&state, user.id, req.client_token).await?;
let mut body = json!({
"accessToken": access,
"clientToken": client,
"availableProfiles": [short_profile(&user)],
"selectedProfile": short_profile(&user),
});
if req.request_user {
body["user"] = user_json(&user);
}
Ok(Json(body))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RefreshReq {
access_token: String,
#[serde(default)]
client_token: Option<String>,
#[serde(default)]
request_user: bool,
}
async fn refresh(State(state): State<AppState>, Json(req): Json<RefreshReq>) -> YggResult<Json<Value>> {
let client_token: Option<String> = sqlx::query_scalar("SELECT client_token FROM ygg_tokens WHERE access_token = ?")
.bind(&req.access_token)
.fetch_optional(&state.db)
.await?;
let user = token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?;
sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?;
let (access, client) = issue_token(&state, user.id, client_token).await?;
let mut body = json!({ "accessToken": access, "clientToken": client, "selectedProfile": short_profile(&user) });
if req.request_user {
body["user"] = user_json(&user);
}
Ok(Json(body))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenReq {
access_token: String,
#[serde(default)]
client_token: Option<String>,
}
async fn validate(State(state): State<AppState>, Json(req): Json<TokenReq>) -> YggResult<Response> {
token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?;
Ok(no_content())
}
async fn invalidate(State(state): State<AppState>, Json(req): Json<TokenReq>) -> YggResult<Response> {
sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?;
Ok(no_content())
}
#[derive(Deserialize)]
struct SignoutReq {
username: String,
password: String,
}
async fn signout(State(state): State<AppState>, Json(req): Json<SignoutReq>) -> YggResult<Response> {
let user = check_password(&state, &req.username, &req.password).await?;
sqlx::query("DELETE FROM ygg_tokens WHERE user_id = ?").bind(user.id).execute(&state.db).await?;
Ok(no_content())
}
// ---------------------------------------------------------------------------
// sessionserver
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JoinReq {
access_token: String,
selected_profile: String,
server_id: String,
}
async fn join(State(state): State<AppState>, ClientIp(ip): ClientIp, Json(req): Json<JoinReq>) -> YggResult<Response> {
let user = token_user(&state, &req.access_token, None).await?.ok_or_else(YggError::invalid_token)?;
if undashed(&req.selected_profile) != undashed(&user.uuid) {
return Err(YggError::forbidden("Invalid token."));
}
if req.server_id.is_empty() || req.server_id.len() > 64 {
return Err(YggError::bad_request("invalid serverId"));
}
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
sqlx::query("DELETE FROM ygg_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?;
sqlx::query("INSERT OR REPLACE INTO ygg_sessions (server_id, user_id, ip, created_at) VALUES (?, ?, ?, ?)")
.bind(&req.server_id)
.bind(user.id)
.bind(ip)
.bind(crate::db::now())
.execute(&state.db)
.await?;
Ok(no_content())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HasJoinedQuery {
username: String,
server_id: String,
#[serde(default)]
ip: Option<String>,
}
async fn has_joined(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<HasJoinedQuery>) -> YggResult<Response> {
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let row: Option<(i64, Option<String>)> = sqlx::query_as("SELECT user_id, ip FROM ygg_sessions WHERE server_id = ? AND created_at >= ?")
.bind(&q.server_id)
.bind(cutoff)
.fetch_optional(&state.db)
.await?;
let Some((user_id, joined_ip)) = row else { return Ok(no_content()) };
let Some(user): Option<UserRow> =
sqlx::query_as("SELECT * FROM users WHERE id = ? AND status = 'active'").bind(user_id).fetch_optional(&state.db).await?
else {
return Ok(no_content());
};
if !user.username.eq_ignore_ascii_case(&q.username) {
return Ok(no_content());
}
if let (Some(expected), Some(actual)) = (q.ip.as_deref().filter(|i| !i.is_empty()), joined_ip.as_deref()) {
if expected != actual {
return Ok(no_content());
}
}
let base = net::public_base(&state, &headers).await;
Ok(Json(profile_json(&state, &base, &user, true).await?).into_response())
}
#[derive(Deserialize)]
struct ProfileQuery {
#[serde(default)]
unsigned: Option<String>,
}
async fn session_profile(
State(state): State<AppState>,
headers: HeaderMap,
Path(uuid): Path<String>,
Query(q): Query<ProfileQuery>,
) -> YggResult<Response> {
let Some(user) = user_by_uuid(&state, &uuid).await? else { return Ok(no_content()) };
let signed = q.unsigned.as_deref() == Some("false");
let base = net::public_base(&state, &headers).await;
Ok(Json(profile_json(&state, &base, &user, signed).await?).into_response())
}
// ---------------------------------------------------------------------------
// Mojang-style profile API
// ---------------------------------------------------------------------------
async fn profiles_by_names(State(state): State<AppState>, Json(names): Json<Vec<String>>) -> YggResult<Json<Vec<Value>>> {
let mut out = Vec::new();
for name in names.iter().take(100) {
if let Some(user) = auth::find_user_by_name(&state, name).await? {
if !out.iter().any(|v: &Value| v["id"] == undashed(&user.uuid)) {
out.push(short_profile(&user));
}
}
}
Ok(Json(out))
}
async fn profile_by_name(State(state): State<AppState>, Path(name): Path<String>) -> YggResult<Response> {
match auth::find_user_by_name(&state, &name).await? {
Some(user) => Ok(Json(short_profile(&user)).into_response()),
None => Ok(no_content()),
}
}
fn bearer(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
}
async fn bearer_user(state: &AppState, headers: &HeaderMap) -> YggResult<UserRow> {
let token = bearer(headers).ok_or_else(|| YggError {
status: StatusCode::UNAUTHORIZED,
error: "Unauthorized",
message: "Missing token.".into(),
})?;
token_user(state, token, None).await?.ok_or_else(|| YggError {
status: StatusCode::UNAUTHORIZED,
error: "Unauthorized",
message: "Invalid token.".into(),
})
}
/// `PUT /api/user/profile/{uuid}/skin` (multipart: `model`, `file`).
async fn upload_texture(
State(state): State<AppState>,
headers: HeaderMap,
Path((uuid, kind)): Path<(String, String)>,
mut form: Multipart,
) -> YggResult<Response> {
let user = bearer_user(&state, &headers).await?;
if undashed(&uuid) != undashed(&user.uuid) {
return Err(YggError::forbidden("You can only change your own skin."));
}
if kind != "skin" {
return Err(YggError::forbidden("Capes are assigned by the server admins."));
}
let mut model = String::from("classic");
let mut file = None;
while let Some(field) = form.next_field().await.map_err(|e| YggError::bad_request(e.to_string()))? {
match field.name().unwrap_or_default() {
"model" => model = field.text().await.map_err(|e| YggError::bad_request(e.to_string()))?,
"file" => file = Some(field.bytes().await.map_err(|e| YggError::bad_request(e.to_string()))?),
_ => {}
}
}
let bytes = file.ok_or_else(|| YggError::bad_request("no file"))?;
set_skin(&state, user.id, &bytes, &model).await?;
Ok(no_content())
}
async fn delete_texture(
State(state): State<AppState>,
headers: HeaderMap,
Path((uuid, kind)): Path<(String, String)>,
) -> YggResult<Response> {
let user = bearer_user(&state, &headers).await?;
if undashed(&uuid) != undashed(&user.uuid) || kind != "skin" {
return Err(YggError::forbidden("Not allowed."));
}
sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?;
Ok(no_content())
}
/// Store a skin for a user (validated PNG, "classic" or "slim").
pub async fn set_skin(state: &AppState, user_id: i64, bytes: &[u8], model: &str) -> AppResult<()> {
let model = if model == "slim" { "slim" } else { "classic" };
let dir = state.cfg.textures_dir();
let data = bytes.to_vec();
let hash = tokio::task::spawn_blocking(move || textures::store(&dir, textures::Kind::Skin, &data))
.await
.map_err(|e| crate::error::AppError::bad_request(e.to_string()))??;
sqlx::query("UPDATE users SET skin_hash = ?, skin_model = ? WHERE id = ?")
.bind(hash)
.bind(model)
.bind(user_id)
.execute(&state.db)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// minecraftservices (1.19+ chat signing, social features)
// ---------------------------------------------------------------------------
fn iso_millis(t: chrono::DateTime<chrono::Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
/// PEM exactly as Minecraft's `Crypt.rsaPublicKeyToString` writes it (MIME
/// base64: 76-char lines, CRLF) — the V1 signature covers this text.
fn mojang_pem(label: &str, der: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(der);
let lines: Vec<&str> = b64.as_bytes().chunks(76).map(|c| std::str::from_utf8(c).unwrap()).collect();
format!("-----BEGIN {label}-----\n{}\n-----END {label}-----\n", lines.join("\r\n"))
}
#[derive(sqlx::FromRow)]
struct PlayerKeyRow {
private_pem: String,
public_pem: String,
signature_v1: String,
signature_v2: String,
expires_at: String,
refreshed_after: String,
}
async fn player_certificates(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
let user = bearer_user(&state, &headers).await?;
let existing: Option<PlayerKeyRow> =
sqlx::query_as("SELECT * FROM player_keys WHERE user_id = ?").bind(user.id).fetch_optional(&state.db).await?;
let row = match existing.filter(|k| k.refreshed_after > iso_millis(chrono::Utc::now())) {
Some(k) => k,
None => {
let row = generate_player_key(&state, &user).await?;
sqlx::query(
"INSERT OR REPLACE INTO player_keys (user_id, private_pem, public_pem, signature_v1, signature_v2, expires_at, refreshed_after)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(user.id)
.bind(&row.private_pem)
.bind(&row.public_pem)
.bind(&row.signature_v1)
.bind(&row.signature_v2)
.bind(&row.expires_at)
.bind(&row.refreshed_after)
.execute(&state.db)
.await?;
row
}
};
Ok(Json(json!({
"keyPair": { "privateKey": row.private_pem, "publicKey": row.public_pem },
"publicKeySignature": row.signature_v1,
"publicKeySignatureV2": row.signature_v2,
"expiresAt": row.expires_at,
"refreshedAfter": row.refreshed_after,
})))
}
async fn generate_player_key(state: &AppState, user: &UserRow) -> YggResult<PlayerKeyRow> {
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey};
let key = tokio::task::spawn_blocking(|| rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048))
.await
.map_err(|e| YggError::bad_request(e.to_string()))?
.map_err(|e| YggError::bad_request(e.to_string()))?;
let public_der = rsa::RsaPublicKey::from(&key).to_public_key_der().map_err(|e| YggError::bad_request(e.to_string()))?;
let private_der = key.to_pkcs8_der().map_err(|e| YggError::bad_request(e.to_string()))?;
let now = chrono::Utc::now();
let expires = now + chrono::Duration::hours(48);
let refreshed_after = now + chrono::Duration::hours(40);
let public_pem = mojang_pem("RSA PUBLIC KEY", public_der.as_bytes());
// V2 (1.19.1+): uuid msb, uuid lsb, expiry millis (big-endian), key DER.
let uuid = uuid::Uuid::parse_str(&user.uuid).map_err(|e| YggError::bad_request(e.to_string()))?;
let mut v2 = Vec::with_capacity(24 + public_der.as_bytes().len());
v2.extend_from_slice(uuid.as_bytes());
v2.extend_from_slice(&expires.timestamp_millis().to_be_bytes());
v2.extend_from_slice(public_der.as_bytes());
// V1 (1.19.0): expiry millis as text + the PEM text.
let v1 = format!("{}{}", expires.timestamp_millis(), public_pem);
Ok(PlayerKeyRow {
private_pem: mojang_pem("RSA PRIVATE KEY", private_der.as_bytes()),
signature_v1: state.ygg.sign_b64(v1.as_bytes()),
signature_v2: state.ygg.sign_b64(&v2),
public_pem,
expires_at: iso_millis(expires),
refreshed_after: iso_millis(refreshed_after),
})
}
async fn player_attributes(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
bearer_user(&state, &headers).await?;
Ok(Json(json!({
"privileges": {
"onlineChat": { "enabled": true },
"multiplayerServer": { "enabled": true },
"multiplayerRealms": { "enabled": false },
"telemetry": { "enabled": false },
},
"profanityFilterPreferences": { "profanityFilterOn": false },
})))
}
async fn blocklist(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
bearer_user(&state, &headers).await?;
Ok(Json(json!({ "blockedProfiles": [] })))
}
async fn public_keys(State(state): State<AppState>) -> Json<Value> {
let key = json!([{ "publicKey": state.ygg.public_der_b64 }]);
Json(json!({ "profilePropertyKeys": key, "playerCertificateKeys": key }))
}
async fn services_profile(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
let user = bearer_user(&state, &headers).await?;
let base = net::public_base(&state, &headers).await;
let skins: Vec<Value> = user
.skin_hash
.iter()
.map(|h| json!({ "id": h, "state": "ACTIVE", "url": texture_url(&base, h), "variant": if user.skin_model == "slim" { "SLIM" } else { "CLASSIC" } }))
.collect();
let capes: Vec<Value> = cape_of(&state, &user)
.await?
.iter()
.map(|c| json!({ "id": c.id.to_string(), "state": "ACTIVE", "url": texture_url(&base, &c.hash), "alias": c.name }))
.collect();
Ok(Json(json!({ "id": undashed(&user.uuid), "name": user.username, "skins": skins, "capes": capes })))
}
// ---------------------------------------------------------------------------
// Texture files
// ---------------------------------------------------------------------------
pub async fn texture_file(State(state): State<AppState>, Path(hash): Path<String>) -> Response {
let Some(path) = textures::path(&state.cfg.textures_dir(), &hash) else { return StatusCode::NOT_FOUND.into_response() };
match tokio::fs::read(path).await {
Ok(bytes) => {
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], Body::from(bytes))
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub fn routes() -> Router<AppState> {
let p = |path: &str| format!("{ROOT}{path}");
Router::new()
.route(ROOT, get(metadata))
.route(&p("/"), get(metadata))
.route(&p("/authserver/authenticate"), post(authenticate))
.route(&p("/authserver/refresh"), post(refresh))
.route(&p("/authserver/validate"), post(validate))
.route(&p("/authserver/invalidate"), post(invalidate))
.route(&p("/authserver/signout"), post(signout))
.route(&p("/sessionserver/session/minecraft/join"), post(join))
.route(&p("/sessionserver/session/minecraft/hasJoined"), get(has_joined))
.route(&p("/sessionserver/session/minecraft/profile/{uuid}"), get(session_profile))
.route(&p("/api/profiles/minecraft"), post(profiles_by_names))
.route(&p("/api/users/profiles/minecraft/{name}"), get(profile_by_name))
.route(&p("/api/user/profile/{uuid}/{kind}"), put(upload_texture).delete(delete_texture))
.route(&p("/minecraftservices/player/certificates"), post(player_certificates))
.route(&p("/minecraftservices/player/attributes"), get(player_attributes))
.route(&p("/minecraftservices/privacy/blocklist"), get(blocklist))
.route(&p("/minecraftservices/publickeys"), get(public_keys))
.route(&p("/minecraftservices/minecraft/profile"), get(services_profile))
.route("/textures/{hash}", get(texture_file))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_forms() {
assert_eq!(undashed("B50AD385-829D-3141-A216-7E7D7539BA7F"), "b50ad385829d3141a2167e7d7539ba7f");
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f").as_deref(), Some("b50ad385-829d-3141-a216-7e7d7539ba7f"));
assert!(dashed("nope").is_none());
}
#[test]
fn pem_matches_java_mime_layout() {
let pem = mojang_pem("RSA PUBLIC KEY", &[7u8; 100]);
assert!(pem.starts_with("-----BEGIN RSA PUBLIC KEY-----\n"));
assert!(pem.ends_with("\n-----END RSA PUBLIC KEY-----\n"));
assert!(pem.contains("\r\n"), "76-column MIME lines separated by CRLF");
}
}