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:
commit
a1f19e86c6
74 files changed
+2108
-95
No files matched your search
@@ -38,6 +38,8 @@ pub fn validate_password(password: &str) -> AppResult<()> {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
#[serde(default)]
|
||||
pub version: i64,
|
||||
pub sub: i64,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
@@ -56,6 +58,7 @@ impl Keys {
|
||||
|
||||
pub fn issue(&self, user: &UserRow) -> AppResult<String> {
|
||||
let claims = Claims {
|
||||
version: user.auth_version,
|
||||
sub: user.id,
|
||||
name: user.username.clone(),
|
||||
role: user.role.clone(),
|
||||
@@ -72,6 +75,8 @@ impl Keys {
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct UserRow {
|
||||
#[serde(skip)]
|
||||
pub auth_version: i64,
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
#[serde(skip)]
|
||||
@@ -113,8 +118,7 @@ pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUs
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert an account. Every account gets its offline-mode UUID, so offline
|
||||
/// and panel-authenticated servers identify players the same way.
|
||||
/// Allocate identity once. Names can change; the account UUID never does.
|
||||
pub async fn create_user(
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
@@ -133,11 +137,13 @@ pub async fn create_user(
|
||||
.bind(role)
|
||||
.bind(status)
|
||||
.bind(crate::db::now())
|
||||
.bind(scopenet_shared::offline_uuid(username))
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.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"),
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -161,7 +167,7 @@ async fn resolve(parts: &Parts, state: &AppState) -> AppResult<Option<UserRow>>
|
||||
};
|
||||
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?;
|
||||
match user {
|
||||
Some(u) if u.status == "active" => Ok(Some(u)),
|
||||
Some(u) if u.status == "active" && u.auth_version == claims.version => Ok(Some(u)),
|
||||
Some(u) if u.status == "pending" => Err(AppError::forbidden("your account is waiting for approval")),
|
||||
_ => Err(AppError::unauthorized("account disabled or removed")),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct Config {
|
||||
pub curseforge_api_key: Option<String>,
|
||||
pub max_upload_mb: usize,
|
||||
pub public_url: Option<String>,
|
||||
pub trusted_proxies: Vec<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> Option<String> {
|
||||
@@ -31,6 +32,9 @@ impl Config {
|
||||
curseforge_api_key: var("CURSEFORGE_API_KEY"),
|
||||
max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048),
|
||||
public_url: var("PUBLIC_URL"),
|
||||
trusted_proxies: var("SCOPENET_TRUSTED_PROXIES")
|
||||
.map(|s| s.split(',').map(|v| v.trim().parse().expect("SCOPENET_TRUSTED_PROXIES must contain IP addresses")).collect())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,43 @@ const MIGRATIONS: &[&str] = &[
|
||||
CREATE INDEX server_events_server ON server_events(server_id, id);
|
||||
CREATE INDEX server_events_uuid ON server_events(uuid, id);
|
||||
"#,
|
||||
// 3: idempotent server stat batches (retained until the server is deleted).
|
||||
r#"
|
||||
CREATE TABLE server_sync_receipts (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
batch_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, batch_id)
|
||||
);
|
||||
"#,
|
||||
// 4: permanent player identity, reserved historical names and audit data.
|
||||
r#"
|
||||
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN uuid TEXT;
|
||||
ALTER TABLE events ADD COLUMN detail TEXT;
|
||||
ALTER TABLE events ADD COLUMN source TEXT NOT NULL DEFAULT 'launcher';
|
||||
CREATE INDEX events_uuid ON events(uuid, id);
|
||||
CREATE TABLE reserved_usernames (
|
||||
name TEXT PRIMARY KEY COLLATE NOCASE,
|
||||
uuid TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO reserved_usernames SELECT username, uuid FROM users WHERE uuid <> '';
|
||||
CREATE TRIGGER immutable_player_uuid BEFORE UPDATE OF uuid ON users
|
||||
WHEN OLD.uuid <> '' AND NEW.uuid <> OLD.uuid
|
||||
BEGIN SELECT RAISE(ABORT, 'player UUID is immutable'); END;
|
||||
CREATE TRIGGER reserve_name_insert BEFORE INSERT ON users
|
||||
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
|
||||
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
|
||||
CREATE TRIGGER reserve_name_update BEFORE UPDATE OF username ON users
|
||||
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
|
||||
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
|
||||
CREATE TRIGGER remember_name_insert AFTER INSERT ON users
|
||||
WHEN NEW.uuid <> ''
|
||||
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
|
||||
CREATE TRIGGER remember_name_update AFTER UPDATE OF username, uuid ON users
|
||||
WHEN NEW.uuid <> ''
|
||||
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
@@ -233,3 +270,32 @@ async fn backfill_uuids(pool: &SqlitePool) -> Result<()> {
|
||||
pub fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_migration_preserves_existing_player_data_keys() {
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::raw_sql(MIGRATIONS[0]).execute(&pool).await.unwrap();
|
||||
sqlx::raw_sql(MIGRATIONS[1]).execute(&pool).await.unwrap();
|
||||
let existing = scopenet_shared::offline_uuid("LegacyPlayer");
|
||||
sqlx::query("INSERT INTO users(username,password_hash,created_at,uuid) VALUES('LegacyPlayer','test',?,?)")
|
||||
.bind(now())
|
||||
.bind(&existing)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql("PRAGMA user_version=2").execute(&pool).await.unwrap();
|
||||
migrate(&pool).await.unwrap();
|
||||
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='LegacyPlayer'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(uuid, existing);
|
||||
sqlx::query("UPDATE users SET username='RenamedPlayer' WHERE uuid=?").bind(&existing).execute(&pool).await.unwrap();
|
||||
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='RenamedPlayer'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(uuid, existing);
|
||||
let owner: String =
|
||||
sqlx::query_scalar("SELECT uuid FROM reserved_usernames WHERE name='legacyplayer'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(owner, existing);
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -42,16 +42,28 @@ pub fn host_of(url: &str) -> String {
|
||||
host.split(':').next().unwrap_or(host).to_string()
|
||||
}
|
||||
|
||||
/// Client IP: the first `X-Forwarded-For` hop, `X-Real-IP`, or the socket.
|
||||
/// Forwarded addresses are accepted only from explicitly trusted proxies.
|
||||
pub struct ClientIp(pub Option<String>);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
|
||||
impl FromRequestParts<AppState> for ClientIp {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
let forwarded = header(&parts.headers, "x-forwarded-for").and_then(|v| v.split(',').next()).map(|v| v.trim().to_string());
|
||||
let real = header(&parts.headers, "x-real-ip").map(String::from);
|
||||
let socket = parts.extensions.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip().to_string());
|
||||
Ok(ClientIp(forwarded.or(real).or(socket)))
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let peer = parts.extensions.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip());
|
||||
if peer.is_some_and(|ip| state.cfg.trusted_proxies.contains(&ip)) {
|
||||
// Walk right-to-left so a client-supplied prefix cannot spoof its IP.
|
||||
if let Some(chain) = header(&parts.headers, "x-forwarded-for") {
|
||||
for hop in chain.rsplit(',') {
|
||||
let Ok(ip) = hop.trim().parse::<std::net::IpAddr>() else { return Ok(ClientIp(None)) };
|
||||
if !state.cfg.trusted_proxies.contains(&ip) {
|
||||
return Ok(ClientIp(Some(ip.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ip) = header(&parts.headers, "x-real-ip").and_then(|v| v.parse::<std::net::IpAddr>().ok()) {
|
||||
return Ok(ClientIp(Some(ip.to_string())));
|
||||
}
|
||||
}
|
||||
Ok(ClientIp(peer.map(|ip| ip.to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,8 +424,8 @@ async fn has_joined(State(state): State<AppState>, headers: HeaderMap, Query(q):
|
||||
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 {
|
||||
if let Some(expected) = q.ip.as_deref().filter(|i| !i.is_empty()) {
|
||||
if joined_ip.as_deref() != Some(expected) {
|
||||
return Ok(no_content());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ async fn admin_only_routes_are_guarded() {
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
|
||||
assert_eq!(uuid::Uuid::parse_str(v["uuid"].as_str().unwrap()).unwrap().get_version_num(), 4);
|
||||
|
||||
let player = t.login("steve", "password123").await; // usernames are case-insensitive
|
||||
let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn test_keys() -> Arc<Keys> {
|
||||
|
||||
pub struct TestApp {
|
||||
pub router: axum::Router,
|
||||
pub db: sqlx::SqlitePool,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
@@ -32,14 +33,18 @@ pub async fn setup() -> TestApp {
|
||||
curseforge_api_key: None,
|
||||
max_upload_mb: 64,
|
||||
public_url: Some("https://panel.test".into()),
|
||||
trusted_proxies: vec!["127.0.0.1".parse().unwrap()],
|
||||
};
|
||||
let pool = db::connect_memory().await.unwrap();
|
||||
let state = build_state_with_keys(cfg, pool, test_keys()).await.unwrap();
|
||||
bootstrap_admin(&state).await.unwrap();
|
||||
TestApp { router: app(state), _dir: dir }
|
||||
TestApp { db: state.db.clone(), router: app(state), _dir: dir }
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub async fn uuid(&self, name: &str) -> String {
|
||||
sqlx::query_scalar("SELECT uuid FROM users WHERE username = ?").bind(name).fetch_one(&self.db).await.unwrap()
|
||||
}
|
||||
pub async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri);
|
||||
if let Some(t) = token {
|
||||
@@ -52,7 +57,10 @@ impl TestApp {
|
||||
self.send(req).await
|
||||
}
|
||||
|
||||
pub async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
|
||||
pub async fn send(&self, mut req: Request<Body>) -> (StatusCode, Value) {
|
||||
if req.extensions().get::<axum::extract::ConnectInfo<std::net::SocketAddr>>().is_none() {
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo("127.0.0.1:12345".parse::<std::net::SocketAddr>().unwrap()));
|
||||
}
|
||||
let resp = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn renaming_preserves_uuid_stats_and_reserves_names() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (_, created) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"Steve", "password":"password123"}))).await;
|
||||
let id = created["id"].as_i64().unwrap();
|
||||
let uuid = t.uuid("Steve").await;
|
||||
let (_, auth) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username":"Steve", "password":"password123"}))).await;
|
||||
let old_token = auth["token"].as_str().unwrap();
|
||||
let old_game_token = auth["yggdrasil"]["access_token"].as_str().unwrap();
|
||||
let (s, _) = t.call("PUT", "/api/v1/account/username", Some(old_token), Some(json!({"username":"Alex", "password":"wrong"}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"Survival"}))).await;
|
||||
let server_id = server["server"]["id"].as_i64().unwrap();
|
||||
t.call(
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
server["token"].as_str(),
|
||||
Some(json!({"stats":[{"uuid":uuid,"name":"Steve","playtime_secs":123}]})),
|
||||
)
|
||||
.await;
|
||||
let (s, renamed) = t
|
||||
.call(
|
||||
"PUT",
|
||||
"/api/v1/account/username",
|
||||
Some(old_token),
|
||||
Some(json!({"username":"Alex", "password":"password123", "uuid":"spoofed"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{renamed}");
|
||||
assert_eq!(renamed["user"]["uuid"], uuid);
|
||||
assert_eq!(renamed["user"]["id"], id);
|
||||
assert_eq!(renamed["user"]["username"], "Alex");
|
||||
let (s, _) = t.call("GET", "/api/v1/auth/me", Some(old_token), None).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
let (s, _) = t.call("POST", "/api/yggdrasil/authserver/validate", None, Some(json!({"accessToken":old_game_token}))).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
let (s, _) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"steve", "password":"password123"}))).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
let (_, details) = t.call("GET", &format!("/api/admin/servers/{server_id}"), Some(&admin), None).await;
|
||||
assert_eq!(details["leaderboard"][0]["uuid"], uuid);
|
||||
assert_eq!(details["leaderboard"][0]["playtime_secs"], 123);
|
||||
assert!(sqlx::query("UPDATE users SET uuid=? WHERE id=?")
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(id)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.is_err());
|
||||
let fresh = renamed["token"].as_str().unwrap();
|
||||
let (_, profile) = t.call("GET", "/api/v1/account/profile", Some(fresh), None).await;
|
||||
assert_eq!(profile["uuid"], uuid);
|
||||
// Deleting an account never releases its historical identity to a new user.
|
||||
t.call("DELETE", &format!("/api/admin/users/{id}"), Some(&admin), None).await;
|
||||
let (s, _) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"Alex", "password":"password123"}))).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activity_cannot_impersonate_another_account() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let body = json!({"kind":"launcher_open","instance_id":"", "username":"SomeoneElse"});
|
||||
assert_eq!(t.call("POST", "/api/v1/launcher/events", None, Some(body.clone())).await.0, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(t.call("POST", "/api/v1/launcher/events", Some(&admin), Some(body)).await.0, StatusCode::OK);
|
||||
let (_, rows) = t.call("GET", "/api/admin/activity?source=launcher", Some(&admin), None).await;
|
||||
assert_eq!(rows[0]["name"], "admin");
|
||||
assert_eq!(rows[0]["uuid"], t.uuid("admin").await);
|
||||
assert_eq!(t.call("GET", "/api/admin/activity", None, None).await.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_accounts_are_random_and_old_uuids_stay_fixed() {
|
||||
let t = setup().await;
|
||||
let id = t.uuid("admin").await;
|
||||
assert_eq!(uuid::Uuid::parse_str(&id).unwrap().get_version_num(), 4);
|
||||
assert_ne!(id, scopenet_shared::offline_uuid("admin"));
|
||||
assert!(sqlx::query("UPDATE users SET uuid='' WHERE username='admin'").execute(&t.db).await.is_err());
|
||||
}
|
||||
@@ -20,8 +20,8 @@ async fn create_server(t: &TestApp, admin: &str, body: Value) -> (i64, String) {
|
||||
(v["server"]["id"].as_i64().unwrap(), token)
|
||||
}
|
||||
|
||||
fn steve() -> String {
|
||||
scopenet_shared::offline_uuid("Steve")
|
||||
async fn steve(t: &TestApp) -> String {
|
||||
t.uuid("Steve").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -80,7 +80,7 @@ async fn login_rules() {
|
||||
let (_, open) = create_server(&t, &admin, json!({"name": "Open"})).await;
|
||||
let v = login(open.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
let v = login(open.clone(), steve().replace('-', ""), "Steve", None).await;
|
||||
let v = login(open.clone(), steve(&t).await.replace('-', ""), "Steve", None).await;
|
||||
assert_eq!(v["allowed"], true);
|
||||
assert_eq!(v["account"]["username"], "Steve");
|
||||
|
||||
@@ -94,20 +94,20 @@ async fn login_rules() {
|
||||
let (s, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Staff", "access": "groups"}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
|
||||
let (_, staff) = create_server(&t, &admin, json!({"name": "Staff", "access": "groups", "allowed_groups": ["builders"]})).await;
|
||||
let v = login(staff.clone(), steve(), "Steve", None).await;
|
||||
let v = login(staff.clone(), steve(&t).await, "Steve", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
let admin_uuid = scopenet_shared::offline_uuid("admin");
|
||||
let admin_uuid = t.uuid("admin").await;
|
||||
let v = login(staff.clone(), admin_uuid, "admin", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "builders"}))).await;
|
||||
let (s, v) = t.call("PATCH", &format!("/api/admin/users/{steve_id}"), Some(&admin), Some(json!({"groups": ["builders"]}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let v = login(staff.clone(), steve(), "Steve", None).await;
|
||||
let v = login(staff.clone(), steve(&t).await, "Steve", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
|
||||
// Require launcher: a launch from the same IP is needed.
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name": "Strict", "require_launcher": true})).await;
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
|
||||
let v = login(strict.clone(), steve(&t).await, "Steve", Some("203.0.113.9")).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("launcher"));
|
||||
let player = t.login("Steve", "password123").await;
|
||||
@@ -121,9 +121,9 @@ async fn login_rules() {
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
|
||||
let v = login(strict.clone(), steve(&t).await, "Steve", Some("203.0.113.9")).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("198.51.100.1")).await;
|
||||
let v = login(strict.clone(), steve(&t).await, "Steve", Some("198.51.100.1")).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
|
||||
// Disabled accounts see the reason.
|
||||
@@ -134,7 +134,7 @@ async fn login_rules() {
|
||||
Some(json!({"status": "disabled", "status_reason": "Griefing"})),
|
||||
)
|
||||
.await;
|
||||
let v = login(open, steve(), "Steve", None).await;
|
||||
let v = login(open, steve(&t).await, "Steve", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("Griefing"));
|
||||
}
|
||||
@@ -148,11 +148,11 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
|
||||
let sync = json!({
|
||||
"tps": 19.8,
|
||||
"online": [{"uuid": steve().replace('-', ""), "name": "Steve"}],
|
||||
"stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
|
||||
"online": [{"uuid": steve(&t).await.replace('-', ""), "name": "Steve"}],
|
||||
"stats": [{"uuid": steve(&t).await, "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
|
||||
"events": [
|
||||
{"uuid": steve(), "name": "Steve", "kind": "join"},
|
||||
{"uuid": steve(), "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
|
||||
{"uuid": steve(&t).await, "name": "Steve", "kind": "join"},
|
||||
{"uuid": steve(&t).await, "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
|
||||
]
|
||||
});
|
||||
let (s, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(sync)).await;
|
||||
@@ -164,7 +164,7 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
Some(&token),
|
||||
Some(json!({"tps": 20.0, "online": [{"uuid": steve(), "name": "Steve"}], "stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
|
||||
Some(json!({"tps": 20.0, "online": [{"uuid": steve(&t).await, "name": "Steve"}], "stats": [{"uuid": steve(&t).await, "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
@@ -201,8 +201,9 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
Some(json!({"status": "disabled", "status_reason": "Cheating"})),
|
||||
)
|
||||
.await;
|
||||
let (_, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(), "name": "Steve"}]}))).await;
|
||||
assert_eq!(v["kick"][0]["uuid"], steve());
|
||||
let (_, v) =
|
||||
t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(&t).await, "name": "Steve"}]}))).await;
|
||||
assert_eq!(v["kick"][0]["uuid"], steve(&t).await);
|
||||
assert!(v["kick"][0]["message"].as_str().unwrap().contains("Cheating"));
|
||||
|
||||
// Leaving empties the online list; deleting the server removes its data.
|
||||
@@ -214,3 +215,53 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
let (s, _) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_uuid_cannot_claim_a_members_name_or_skip_launcher_check() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let (_, token) = create_server(&t, &admin, json!({"name":"Private", "access":"members"})).await;
|
||||
let (_, verdict) =
|
||||
t.call("POST", "/api/server/v1/login", Some(&token), Some(json!({"uuid":uuid::Uuid::new_v4(),"name":"Steve"}))).await;
|
||||
assert_eq!(verdict["allowed"], false);
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name":"Launcher", "require_launcher":true})).await;
|
||||
for body in [json!({"uuid":steve(&t).await,"name":"Steve"}), json!({"uuid":uuid::Uuid::new_v4(),"name":"Stranger","ip":"203.0.113.9"})]
|
||||
{
|
||||
let (_, verdict) = t.call("POST", "/api/server/v1/login", Some(&strict), Some(body)).await;
|
||||
assert_eq!(verdict["allowed"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_does_not_duplicate_stats_or_events() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let (id, token) = create_server(&t, &admin, json!({"name":"Retry"})).await;
|
||||
let body = json!({"batch_id":uuid::Uuid::new_v4(), "stats":[{"uuid":steve(&t).await,"name":"Steve","playtime_secs":30}],"events":[{"kind":"join","name":"Steve","uuid":steve(&t).await}]});
|
||||
for _ in 0..2 {
|
||||
assert_eq!(t.call("POST", "/api/server/v1/sync", Some(&token), Some(body.clone())).await.0, StatusCode::OK);
|
||||
}
|
||||
let (_, detail) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await;
|
||||
assert_eq!(detail["leaderboard"][0]["playtime_secs"], 30);
|
||||
assert_eq!(detail["events"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untrusted_forwarded_header_cannot_forge_a_launcher_ip() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let token = t.login("Steve", "password123").await;
|
||||
let mut request = Request::post("/api/v1/launcher/events")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-for", "203.0.113.9")
|
||||
.body(Body::from(json!({"kind":"launch","instance_id":"survival"}).to_string()))
|
||||
.unwrap();
|
||||
request.extensions_mut().insert(axum::extract::ConnectInfo("198.51.100.1:12345".parse::<std::net::SocketAddr>().unwrap()));
|
||||
assert_eq!(t.send(request).await.0, StatusCode::OK);
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name":"Strict", "require_launcher":true})).await;
|
||||
let (_, verdict) = t
|
||||
.call("POST", "/api/server/v1/login", Some(&strict), Some(json!({"uuid":steve(&t).await,"name":"Steve","ip":"203.0.113.9"})))
|
||||
.await;
|
||||
assert_eq!(verdict["allowed"], false);
|
||||
}
|
||||
@@ -42,8 +42,8 @@ async fn with_player(t: &TestApp) -> String {
|
||||
admin
|
||||
}
|
||||
|
||||
fn steve_id() -> String {
|
||||
scopenet_shared::offline_uuid("Steve").replace('-', "")
|
||||
async fn steve_id(t: &TestApp) -> String {
|
||||
t.uuid("Steve").await.replace('-', "")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -82,7 +82,7 @@ async fn full_authlib_flow() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{auth}");
|
||||
assert_eq!(auth["clientToken"], "ct1");
|
||||
assert_eq!(auth["selectedProfile"]["id"], steve_id());
|
||||
assert_eq!(auth["selectedProfile"]["id"], steve_id(&t).await);
|
||||
assert_eq!(auth["selectedProfile"]["name"], "Steve");
|
||||
assert_eq!(auth["availableProfiles"].as_array().unwrap().len(), 1);
|
||||
assert!(auth["user"]["id"].is_string());
|
||||
@@ -140,7 +140,7 @@ async fn full_authlib_flow() {
|
||||
"POST",
|
||||
&format!("{Y}/sessionserver/session/minecraft/join"),
|
||||
None,
|
||||
Some(json!({"accessToken": token, "selectedProfile": steve_id(), "serverId": "-4b1d2f"})),
|
||||
Some(json!({"accessToken": token, "selectedProfile": steve_id(&t).await, "serverId": "-4b1d2f"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||
@@ -157,7 +157,7 @@ async fn full_authlib_flow() {
|
||||
let (s, joined) =
|
||||
t.call("GET", &format!("{Y}/sessionserver/session/minecraft/hasJoined?username=Steve&serverId=-4b1d2f"), None, None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{joined}");
|
||||
assert_eq!(joined["id"], steve_id());
|
||||
assert_eq!(joined["id"], steve_id(&t).await);
|
||||
let textures = joined["properties"].as_array().unwrap().iter().find(|p| p["name"] == "textures").unwrap();
|
||||
let value = textures["value"].as_str().unwrap();
|
||||
assert!(
|
||||
@@ -176,13 +176,13 @@ async fn full_authlib_flow() {
|
||||
assert_eq!(s, StatusCode::NO_CONTENT, "wrong server id");
|
||||
|
||||
// Profile lookups.
|
||||
let (_, unsigned) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}", steve_id()), None, None).await;
|
||||
let (_, unsigned) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}", steve_id(&t).await), None, None).await;
|
||||
assert!(unsigned["properties"][0].get("signature").is_none());
|
||||
let (_, signed) =
|
||||
t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}?unsigned=false", steve_id()), None, None).await;
|
||||
t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}?unsigned=false", steve_id(&t).await), None, None).await;
|
||||
assert!(signed["properties"][0]["signature"].is_string());
|
||||
let (_, found) = t.call("POST", &format!("{Y}/api/profiles/minecraft"), None, Some(json!(["steve", "nobody"]))).await;
|
||||
assert_eq!(found, json!([{"id": steve_id(), "name": "Steve"}]));
|
||||
assert_eq!(found, json!([{"id": steve_id(&t).await, "name": "Steve"}]));
|
||||
|
||||
// Refresh rotates the token.
|
||||
let (s, refreshed) =
|
||||
@@ -209,7 +209,7 @@ async fn launcher_login_returns_game_session() {
|
||||
let (s, v) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let token = v["yggdrasil"]["access_token"].as_str().unwrap();
|
||||
assert_eq!(v["user"]["uuid"], scopenet_shared::offline_uuid("Steve"));
|
||||
assert_eq!(v["user"]["uuid"], t.uuid("Steve").await);
|
||||
let (s, _) = t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": token}))).await;
|
||||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||
}
|
||||
@@ -255,7 +255,7 @@ async fn chat_certificates_are_signed_like_mojang() {
|
||||
// Rebuild the V2 payload the way Minecraft does and check the signature.
|
||||
let body: String = pem.lines().filter(|l| !l.starts_with("-----")).map(|l| l.trim()).collect();
|
||||
let der = b64(&body);
|
||||
let uuid = uuid::Uuid::parse_str(&scopenet_shared::offline_uuid("Steve")).unwrap();
|
||||
let uuid = uuid::Uuid::parse_str(&t.uuid("Steve").await).unwrap();
|
||||
let mut payload = uuid.as_bytes().to_vec();
|
||||
payload.extend_from_slice(&expires.timestamp_millis().to_be_bytes());
|
||||
payload.extend_from_slice(&der);
|
||||
@@ -278,7 +278,7 @@ async fn avatars_and_skin_validation() {
|
||||
let t = setup().await;
|
||||
with_player(&t).await;
|
||||
let panel = t.login("Steve", "password123").await;
|
||||
let (s, _) = t.call("GET", &format!("/api/v1/avatar/{}", steve_id()), None, None).await;
|
||||
let (s, _) = t.call("GET", &format!("/api/v1/avatar/{}", steve_id(&t).await), None, None).await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND, "no skin yet");
|
||||
|
||||
let (ct, body) = multipart(&[], ("bad.png", b"GIF89a not a png"));
|
||||
@@ -297,7 +297,7 @@ async fn avatars_and_skin_validation() {
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
assert_eq!(t.send(req).await.0, StatusCode::OK);
|
||||
for id in [steve_id(), "Steve".to_string()] {
|
||||
for id in [steve_id(&t).await, "Steve".to_string()] {
|
||||
let resp =
|
||||
t.router.clone().oneshot(Request::get(format!("/api/v1/avatar/{id}?size=32")).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
Reference in new issue
Block a user