Complete private authentication, server integrations and activity reporting

Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
This commit is contained in:
SCOPEDD committed 2026-09-28 13:31:17 -04:00
commit a1f19e86c6
74 files changed
+2108 -95

No files matched your search

+58
View File
@@ -21,6 +21,64 @@ pub async fn profile(State(state): State<AppState>, headers: HeaderMap, AuthUser
Ok(Json(yggdrasil::player_profile(&state, &base, &user).await?))
}
#[derive(Deserialize)]
pub struct UsernameInput {
username: String,
password: String,
}
pub async fn set_username(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Json(input): Json<UsernameInput>,
) -> AppResult<Json<scopenet_shared::AuthResponse>> {
let name = input.username.trim();
if !scopenet_shared::valid_username(name) {
return Err(AppError::bad_request("usernames are 3–16 letters, numbers or underscores"));
}
state.login_guard.check(&user.username)?;
if !crate::auth::verify_password(&input.password, &user.password_hash) {
state.login_guard.fail(&user.username);
return Err(AppError::unauthorized("incorrect password"));
}
state.login_guard.succeed(&user.username);
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(90)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let online: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM server_online o JOIN game_servers s ON s.id=o.server_id WHERE o.uuid=? AND s.last_seen>=?)",
)
.bind(&user.uuid)
.bind(cutoff)
.fetch_one(&state.db)
.await?;
if online {
return Err(AppError::conflict("disconnect from your servers before changing your username"));
}
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE users SET username=?, auth_version=auth_version+1 WHERE id=?")
.bind(name)
.bind(user.id)
.execute(&mut *tx)
.await
.map_err(|e| match e {
sqlx::Error::Database(d) if d.message().contains("UNIQUE") || d.message().contains("username reserved") => {
AppError::conflict("that username is taken or reserved")
}
e => e.into(),
})?;
sqlx::query("DELETE FROM ygg_tokens WHERE user_id=?").bind(user.id).execute(&mut *tx).await?;
sqlx::query("DELETE FROM ygg_sessions WHERE user_id=?").bind(user.id).execute(&mut *tx).await?;
sqlx::query("DELETE FROM launcher_sessions WHERE user_id=?").bind(user.id).execute(&mut *tx).await?;
sqlx::query("INSERT INTO events (username, uuid, kind, detail, created_at) VALUES (?, ?, 'username_change', ?, ?)")
.bind(name)
.bind(&user.uuid)
.bind(format!("{} → {name}", user.username))
.bind(crate::db::now())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(super::public::signed_in(&state, &reload(&state, user.id).await?).await?))
}
/// Read a `file` (+ optional `model`) multipart upload.
pub async fn read_texture_form(form: &mut Multipart) -> AppResult<(Vec<u8>, String)> {
let mut model = String::from("classic");
+106
View File
@@ -0,0 +1,106 @@
//! Metadata-only audit trail. Never persist passwords, tokens, chat or command arguments.
use crate::{
auth::{AdminUser, UserRow},
error::AppResult,
state::AppState,
};
use axum::{
extract::{Query, Request, State},
middleware::Next,
response::Response,
Json,
};
use serde::{Deserialize, Serialize};
pub async fn record(state: &AppState, user: &UserRow, source: &str, kind: &str, detail: Option<&str>) -> AppResult<()> {
sqlx::query("INSERT INTO events (username, uuid, source, kind, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&user.username)
.bind(&user.uuid)
.bind(source)
.bind(kind)
.bind(detail.map(|d| d.chars().filter(|c| !c.is_control()).take(256).collect::<String>()))
.bind(crate::db::now())
.execute(&state.db)
.await?;
Ok(())
}
pub async fn audit(State(state): State<AppState>, request: Request, next: Next) -> Response {
let path = request.uri().path().to_owned();
let mutation = matches!(request.method().as_str(), "POST" | "PUT" | "PATCH" | "DELETE")
&& (path.starts_with("/api/admin/") || path.starts_with("/api/v1/account/"));
let user = if mutation {
let claims = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|t| state.keys.verify(t));
if let Some(claims) = claims {
sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id=? AND auth_version=? AND status='active'")
.bind(claims.sub)
.bind(claims.version)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
} else {
None
}
} else {
None
};
let detail = format!("{} {path}", request.method());
let response = next.run(request).await;
if response.status().is_success() {
if let Some(user) = user {
if let Err(e) = record(&state, &user, "panel", "account_or_admin_change", Some(&detail)).await {
tracing::error!("audit write failed: {}", e.message);
}
}
}
response
}
#[derive(Deserialize, Default)]
pub struct Filter {
#[serde(default)]
source: String,
#[serde(default)]
player: String,
#[serde(default)]
offset: u32,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct Entry {
id: i64,
source: String,
server: Option<String>,
uuid: Option<String>,
name: Option<String>,
kind: String,
detail: Option<String>,
created_at: String,
}
pub async fn list(_: AdminUser, State(state): State<AppState>, Query(filter): Query<Filter>) -> AppResult<Json<Vec<Entry>>> {
let rows = sqlx::query_as(
"SELECT * FROM (
SELECT id, source, NULL AS server, uuid, username AS name, kind, detail, created_at FROM events
UNION ALL
SELECT e.id, 'server' AS source, s.name AS server, e.uuid, e.name, e.kind, e.detail, e.created_at
FROM server_events e JOIN game_servers s ON s.id=e.server_id
) WHERE (?='' OR source=?) AND (?='' OR name=? COLLATE NOCASE OR uuid=?)
ORDER BY created_at DESC, source, id DESC LIMIT 100 OFFSET ?",
)
.bind(&filter.source)
.bind(&filter.source)
.bind(&filter.player)
.bind(&filter.player)
.bind(&filter.player)
.bind(filter.offset.min(100_000))
.fetch_all(&state.db)
.await?;
Ok(Json(rows))
}
+6 -2
View File
@@ -178,11 +178,15 @@ pub async fn update_user(
.ok_or_else(|| AppError::not_found("user not found"))?;
if let Some(password) = input.password.filter(|p| !p.is_empty()) {
auth::validate_password(&password)?;
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE users SET password_hash = ?, auth_version = auth_version + 1 WHERE id = ?")
.bind(auth::hash_password(&password)?)
.bind(id)
.execute(&state.db)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM ygg_tokens WHERE user_id=?").bind(id).execute(&mut *tx).await?;
sqlx::query("DELETE FROM ygg_sessions WHERE user_id=?").bind(id).execute(&mut *tx).await?;
tx.commit().await?;
}
if let Some(email) = input.email {
sqlx::query("UPDATE users SET email = ? WHERE id = ?")
+4
View File
@@ -1,4 +1,5 @@
pub mod account;
pub mod activity;
pub mod admin;
pub mod meta;
pub mod public;
@@ -20,6 +21,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/auth/register", post(public::register))
.route("/auth/me", get(public::me))
.route("/account/profile", get(account::profile))
.route("/account/username", axum::routing::put(account::set_username))
.route("/account/skin", post(account::upload_skin).delete(account::delete_skin))
.route("/account/skin/model", axum::routing::put(account::set_model))
.route("/account/cape", axum::routing::put(account::set_cape))
@@ -29,6 +31,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
let admin = Router::new()
.route("/activity", get(activity::list))
.route("/stats", get(admin::stats))
.route("/users", get(admin::list_users).post(admin::create_user))
.route("/users/{id}", axum::routing::patch(admin::update_user).delete(admin::delete_user))
@@ -74,4 +77,5 @@ pub fn api(state: &AppState) -> Router<AppState> {
.nest("/api/server/v1", game)
.nest("/api/admin", admin)
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
.layer(axum::middleware::from_fn_with_state(state.clone(), activity::audit))
}
+35 -8
View File
@@ -67,7 +67,7 @@ async fn find_user(state: &AppState, username: &str) -> AppResult<Option<UserRow
}
/// Panel token + a fresh game session for authlib-injector.
async fn signed_in(state: &AppState, user: &UserRow) -> AppResult<AuthResponse> {
pub(crate) 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)?,
@@ -98,6 +98,7 @@ pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>)
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?;
super::activity::record(&state, &user, "auth", "login", None).await?;
Ok(Json(signed_in(&state, &user).await?))
}
@@ -134,16 +135,41 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
pub async fn event(
State(state): State<AppState>,
MaybeUser(user): MaybeUser,
AuthUser(user): AuthUser,
ClientIp(ip): ClientIp,
Json(ev): Json<LaunchEvent>,
) -> AppResult<Json<serde_json::Value>> {
let kind = if ev.kind == "launch" { "launch" } else { "other" };
let kind = ev.kind.as_str();
if !matches!(
kind,
"launch"
| "launcher_open"
| "launcher_close"
| "settings_changed"
| "account_selected"
| "logout"
| "install_start"
| "install_complete"
| "repair_start"
| "repair_complete"
| "launch_failed"
| "launch_cancelled"
| "game_exit"
| "game_crash"
| "game_killed"
| "instance_deleted"
| "cache_cleared"
| "update_installed"
| "folder_opened"
| "link_opened"
) {
return Err(AppError::bad_request("unknown launcher event"));
}
// 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) {
if let (Some(ip), "launch") = (&ip, kind) {
sqlx::query("INSERT INTO launcher_sessions (user_id, ip, created_at) VALUES (?, ?, ?)")
.bind(u.id)
.bind(user.id)
.bind(ip)
.bind(crate::db::now())
.execute(&state.db)
@@ -151,10 +177,11 @@ pub async fn event(
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 (?, ?, ?, ?)")
// Identity comes exclusively from the verified bearer token.
sqlx::query("INSERT INTO events (instance_id, username, uuid, kind, created_at) VALUES (?, ?, ?, ?, ?)")
.bind(ev.instance_id.chars().take(64).collect::<String>())
.bind(name)
.bind(&user.username)
.bind(&user.uuid)
.bind(kind)
.bind(crate::db::now())
.execute(&state.db)
+41 -11
View File
@@ -200,12 +200,22 @@ pub async fn login(
Json(req): Json<LoginCheck>,
) -> AppResult<Json<LoginVerdict>> {
let brand = store::branding(&state).await?.name;
let user = match user_by_uuid(&state, &req.uuid).await? {
Some(u) => Some(u),
// Offline-mode servers may send a UUID we don't know (e.g. a
// Floodgate player) — fall back to the name.
None => auth::find_user_by_name(&state, &req.name).await?,
};
// Names are not proof of identity. Only the UUID authenticated by the
// server's online-mode session check may select a panel account.
let user = user_by_uuid(&state, &req.uuid).await?;
if user.is_none() {
let reserved: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reserved_usernames WHERE name=?)").bind(&req.name).fetch_one(&state.db).await?;
if reserved {
return Ok(Json(LoginVerdict::deny("That player name belongs to another account.")));
}
}
if user.as_ref().is_some_and(|u| !u.username.eq_ignore_ascii_case(&req.name)) {
return Ok(Json(LoginVerdict::deny("Your player name does not match your account.")));
}
if server.require_launcher && req.ip.as_deref().is_none_or(|ip| ip.trim().is_empty()) {
return Ok(Json(LoginVerdict::deny("Your connection address could not be verified. Please reconnect through the launcher.")));
}
Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?))
}
@@ -217,7 +227,7 @@ async fn check_login(
brand: &str,
) -> AppResult<LoginVerdict> {
let Some(user) = user else {
return Ok(if server.access == "all" {
return Ok(if server.access == "all" && !server.require_launcher {
LoginVerdict { allowed: true, message: None, account: None }
} else {
LoginVerdict::deny(format!("You need a {brand} account to join this server.\nCreate one in the {brand} launcher."))
@@ -304,6 +314,8 @@ pub struct GameEvent {
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct Sync {
/// Stable across retries. Older integrations may omit it.
batch_id: Option<String>,
tps: Option<f64>,
online: Vec<OnlinePlayer>,
stats: Vec<StatDelta>,
@@ -316,6 +328,24 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
let at_now = now();
let mut tx = state.db.begin().await?;
// Record the receipt in the same transaction as the deltas. A response
// lost after commit can then be retried without counting activity twice.
let fresh = if let Some(batch) = &s.batch_id {
if uuid::Uuid::parse_str(batch).is_err() {
return Err(AppError::bad_request("batch_id must be a UUID"));
}
sqlx::query("INSERT OR IGNORE INTO server_sync_receipts (server_id, batch_id, created_at) VALUES (?, ?, ?)")
.bind(server.id)
.bind(batch)
.bind(&at_now)
.execute(&mut *tx)
.await?
.rows_affected()
> 0
} else {
true
};
let online: Vec<(String, String)> =
s.online.iter().take(MAX_ONLINE).filter_map(|p| Some((dashed(&p.uuid)?, clip(&p.name, 16)))).collect();
sqlx::query("UPDATE game_servers SET last_seen = ?, online_count = ?, tps = ? WHERE id = ?")
@@ -343,7 +373,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.await?;
}
for d in &s.stats {
for d in s.stats.iter().filter(|_| fresh) {
let Some(uuid) = dashed(&d.uuid) else { continue };
let n = |v: i64| v.clamp(0, 1_000_000);
sqlx::query(
@@ -378,7 +408,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.await?;
}
for e in s.events.iter().take(MAX_EVENTS_PER_SYNC) {
for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) {
let kind = clip(&e.kind, 24).to_ascii_lowercase();
if kind.is_empty() {
continue;
@@ -411,8 +441,8 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
let mut kick = Vec::new();
for (uuid, _) in &online {
let Some(user) = user_by_uuid(&state, uuid).await? else { continue };
if user.status != "active" {
let verdict = check_login(&state, &server, Some(&user), None, &brand).await?;
let verdict = check_login(&state, &server, Some(&user), None, &brand).await?;
if !verdict.allowed {
kick.push(json!({ "uuid": uuid, "message": verdict.message }));
}
}