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

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