Microsoft sign-in is gone. Server accounts now receive Yggdrasil tokens from the panel and launch with authlib-injector pointed at the panel's auth server, so online-mode servers verify players against it. - Download authlib-injector from the panel mirror (fallback: official), SHA-256 verified and cached, with prefetched metadata - New Skin & cape settings tab: upload, arm style, reset, cape picker with front/back previews - Avatars render from the panel's head endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
279 lines
10 KiB
Rust
279 lines
10 KiB
Rust
//! Player accounts: panel accounts (authenticated by the panel's Yggdrasil
|
||
//! server through authlib-injector) and local offline accounts.
|
||
|
||
use crate::secrets::Secret;
|
||
use crate::state::AppState;
|
||
use anyhow::{anyhow, bail, Context, Result};
|
||
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::json;
|
||
use std::path::Path;
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
pub struct Account {
|
||
pub id: String,
|
||
/// "panel" | "offline"
|
||
pub kind: String,
|
||
pub username: String,
|
||
pub uuid: String,
|
||
/// Panel accounts: which panel they belong to.
|
||
#[serde(default)]
|
||
pub panel_url: Option<String>,
|
||
#[serde(default)]
|
||
pub role: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
#[serde(default)]
|
||
pub struct AccountsFile {
|
||
pub accounts: Vec<Account>,
|
||
pub active: Option<String>,
|
||
}
|
||
|
||
impl AccountsFile {
|
||
pub fn load(path: &Path) -> Self {
|
||
let mut file: Self = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||
// Microsoft accounts are no longer supported.
|
||
file.accounts.retain(|a| a.kind == "panel" || a.kind == "offline");
|
||
if file.active.as_ref().is_some_and(|id| !file.accounts.iter().any(|a| &a.id == id)) {
|
||
file.active = file.accounts.first().map(|a| a.id.clone());
|
||
}
|
||
file
|
||
}
|
||
|
||
pub fn save(&self, path: &Path) -> Result<()> {
|
||
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn active(&self) -> Option<&Account> {
|
||
let id = self.active.as_ref()?;
|
||
self.accounts.iter().find(|a| &a.id == id)
|
||
}
|
||
|
||
/// Add or replace (same kind + uuid + panel) and make it active.
|
||
pub fn upsert(&mut self, account: Account) -> Account {
|
||
if let Some(existing) =
|
||
self.accounts.iter_mut().find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
|
||
{
|
||
let id = existing.id.clone();
|
||
*existing = Account { id: id.clone(), ..account };
|
||
self.active = Some(id);
|
||
return existing.clone();
|
||
}
|
||
self.active = Some(account.id.clone());
|
||
self.accounts.push(account.clone());
|
||
account
|
||
}
|
||
}
|
||
|
||
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
|
||
let status = resp.status();
|
||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||
anyhow!(
|
||
"{}",
|
||
body.get("error")
|
||
.and_then(|e| e.as_str())
|
||
.or_else(|| body.get("errorMessage").and_then(|e| e.as_str()))
|
||
.map(String::from)
|
||
.unwrap_or_else(|| format!("panel returned {status}"))
|
||
)
|
||
}
|
||
|
||
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
|
||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||
let resp = state
|
||
.http
|
||
.post(format!("{panel}/api/v1/auth/login"))
|
||
.json(&LoginRequest { username: username.trim().into(), password: password.into() })
|
||
.send()
|
||
.await
|
||
.context("couldn't reach the server")?;
|
||
if !resp.status().is_success() {
|
||
return Err(panel_error(resp).await);
|
||
}
|
||
let auth: AuthResponse = resp.json().await?;
|
||
save_panel_account(state, &panel, auth)
|
||
}
|
||
|
||
pub async fn register_panel(state: &AppState, username: &str, password: &str, email: Option<String>) -> Result<(Option<Account>, bool)> {
|
||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||
let resp = state
|
||
.http
|
||
.post(format!("{panel}/api/v1/auth/register"))
|
||
.json(&RegisterRequest { username: username.trim().into(), password: password.into(), email })
|
||
.send()
|
||
.await
|
||
.context("couldn't reach the server")?;
|
||
if !resp.status().is_success() {
|
||
return Err(panel_error(resp).await);
|
||
}
|
||
let auth: AuthResponse = resp.json().await?;
|
||
if auth.pending {
|
||
return Ok((None, true));
|
||
}
|
||
Ok((Some(save_panel_account(state, &panel, auth)?), false))
|
||
}
|
||
|
||
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
|
||
let account = Account {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
kind: "panel".into(),
|
||
username: auth.user.username.clone(),
|
||
uuid: auth.user.uuid.clone(),
|
||
panel_url: Some(panel.to_string()),
|
||
role: Some(auth.user.role.clone()),
|
||
};
|
||
let account = state.accounts.write().unwrap().upsert(account);
|
||
let ygg = auth.yggdrasil.ok_or_else(|| anyhow!("the server didn't start a game session — is the panel up to date?"))?;
|
||
state.secrets.set(
|
||
&account.id,
|
||
Secret { panel_token: Some(auth.token), ygg_access_token: Some(ygg.access_token), ygg_client_token: Some(ygg.client_token) },
|
||
)?;
|
||
state.save_accounts()?;
|
||
Ok(account)
|
||
}
|
||
|
||
pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
|
||
let allowed = state.manifest.read().unwrap().as_ref().map(|m| m.auth.offline_local).unwrap_or(true);
|
||
if !allowed {
|
||
bail!("offline accounts are disabled on this server");
|
||
}
|
||
let name = username.trim();
|
||
if !valid_username(name) {
|
||
bail!("usernames are 3–16 letters, numbers or underscores");
|
||
}
|
||
let account = Account {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
kind: "offline".into(),
|
||
username: name.into(),
|
||
uuid: offline_uuid(name),
|
||
panel_url: None,
|
||
role: None,
|
||
};
|
||
let account = state.accounts.write().unwrap().upsert(account);
|
||
state.save_accounts()?;
|
||
Ok(account)
|
||
}
|
||
|
||
/// The panel's Yggdrasil API root for an account.
|
||
pub fn yggdrasil_url(state: &AppState, account: &Account) -> Option<String> {
|
||
let panel = account.panel_url.clone()?;
|
||
let from_manifest = state
|
||
.manifest
|
||
.read()
|
||
.unwrap()
|
||
.as_ref()
|
||
.filter(|_| state.panel_url().as_deref() == Some(panel.as_str()))
|
||
.and_then(|m| m.auth.yggdrasil_url.clone());
|
||
Some(from_manifest.unwrap_or_else(|| format!("{panel}/api/yggdrasil")))
|
||
}
|
||
|
||
/// Make sure the account's game session is valid, refreshing it if needed.
|
||
/// Returns the access token.
|
||
async fn ensure_session(state: &AppState, account: &Account, api: &str) -> Result<String> {
|
||
let secret = state.secrets.get(&account.id);
|
||
let (Some(access), Some(client)) = (secret.ygg_access_token.clone(), secret.ygg_client_token.clone()) else {
|
||
bail!("please sign in to {} again", account.username);
|
||
};
|
||
let validate = state
|
||
.http
|
||
.post(format!("{api}/authserver/validate"))
|
||
.json(&json!({ "accessToken": access, "clientToken": client }))
|
||
.send()
|
||
.await
|
||
.context("couldn't reach the auth server")?;
|
||
if validate.status().is_success() {
|
||
return Ok(access);
|
||
}
|
||
let resp = state
|
||
.http
|
||
.post(format!("{api}/authserver/refresh"))
|
||
.json(&json!({ "accessToken": access, "clientToken": client }))
|
||
.send()
|
||
.await
|
||
.context("couldn't reach the auth server")?;
|
||
if !resp.status().is_success() {
|
||
bail!("your session for {} expired — please sign in again", account.username);
|
||
}
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct Refreshed {
|
||
access_token: String,
|
||
client_token: String,
|
||
}
|
||
let r: Refreshed = resp.json().await?;
|
||
state
|
||
.secrets
|
||
.set(&account.id, Secret { ygg_access_token: Some(r.access_token.clone()), ygg_client_token: Some(r.client_token), ..secret })?;
|
||
Ok(r.access_token)
|
||
}
|
||
|
||
/// Credentials passed to the game, plus the auth server URL for
|
||
/// authlib-injector (panel accounts only).
|
||
pub async fn game_auth(state: &AppState, account: &Account) -> Result<(scopenet_core::launch::Auth, Option<String>)> {
|
||
use scopenet_core::launch::Auth;
|
||
match account.kind.as_str() {
|
||
"panel" => {
|
||
let api = yggdrasil_url(state, account).ok_or_else(|| anyhow!("account has no server"))?;
|
||
let access_token = ensure_session(state, account, &api).await?;
|
||
Ok((
|
||
Auth { username: account.username.clone(), uuid: account.uuid.clone(), access_token, user_type: "mojang".into() },
|
||
Some(api),
|
||
))
|
||
}
|
||
_ => Ok((
|
||
Auth {
|
||
username: account.username.clone(),
|
||
uuid: account.uuid.clone(),
|
||
// Offline mode: any token works; offline servers ignore it.
|
||
access_token: "0".into(),
|
||
user_type: "legacy".into(),
|
||
},
|
||
None,
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Panel token for requests, when the active account belongs to this panel.
|
||
pub fn panel_token(state: &AppState) -> Option<String> {
|
||
let panel = state.panel_url()?;
|
||
let accounts = state.accounts.read().unwrap();
|
||
let active = accounts.active()?;
|
||
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) {
|
||
return None;
|
||
}
|
||
state.secrets.get(&active.id).panel_token
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn upsert_replaces_same_identity() {
|
||
let mut f = AccountsFile::default();
|
||
let a = Account { id: "1".into(), kind: "offline".into(), username: "Steve".into(), uuid: "u".into(), panel_url: None, role: None };
|
||
f.upsert(a.clone());
|
||
f.upsert(Account { id: "2".into(), ..a.clone() });
|
||
assert_eq!(f.accounts.len(), 1);
|
||
assert_eq!(f.active.as_deref(), Some("1"));
|
||
}
|
||
|
||
#[test]
|
||
fn drops_microsoft_accounts_on_load() {
|
||
let dir = std::env::temp_dir().join(format!("scopenet-acc-{}", uuid::Uuid::new_v4()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("accounts.json");
|
||
std::fs::write(
|
||
&path,
|
||
r#"{"accounts":[{"id":"m","kind":"microsoft","username":"A","uuid":"x"},{"id":"o","kind":"offline","username":"B","uuid":"y"}],"active":"m"}"#,
|
||
)
|
||
.unwrap();
|
||
let f = AccountsFile::load(&path);
|
||
assert_eq!(f.accounts.len(), 1);
|
||
assert_eq!(f.active.as_deref(), Some("o"));
|
||
std::fs::remove_dir_all(dir).ok();
|
||
}
|
||
}
|