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
@@ -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>())
|
||||
|
||||
Reference in new issue
Block a user