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:
29 files changed
+2436
-400
No files matched your search
@@ -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-----"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user