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

+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(())
}