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

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