Add launcher engine (scopenet-core) and admin panel server

- scopenet-shared: wire types for branding, auth, instances, manifests
- scopenet-core: Mojang versions/libraries/assets, automatic Java runtimes,
  Fabric/Quilt/Forge/NeoForge installation, file sync, launch command
  builder, Microsoft auth, servers.dat injection, server list ping
- scopenet-panel: Axum + SQLite admin API with JWT auth, users/groups,
  branding, settings, instances, Modrinth/CurseForge/zip import, file
  hosting and launch stats

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 04:22:17 +00:00
1 parent 915f40a4c4
commit 8cdf616a94
43 files changed
+9317

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
//! Passwords (Argon2), tokens (JWT) and the request extractors that guard
//! routes.
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use scopenet_shared::{offline_uuid, PublicUser};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const TOKEN_DAYS: i64 = 30;
pub fn hash_password(password: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))
}
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash).map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()).unwrap_or(false)
}
pub fn validate_password(password: &str) -> AppResult<()> {
if password.chars().count() < 8 {
return Err(AppError::bad_request("passwords need at least 8 characters"));
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: i64,
pub name: String,
pub role: String,
pub exp: i64,
}
pub struct Keys {
enc: EncodingKey,
dec: DecodingKey,
}
impl Keys {
pub fn new(secret: &[u8]) -> Self {
Self { enc: EncodingKey::from_secret(secret), dec: DecodingKey::from_secret(secret) }
}
pub fn issue(&self, user: &UserRow) -> AppResult<String> {
let claims = Claims {
sub: user.id,
name: user.username.clone(),
role: user.role.clone(),
exp: (chrono::Utc::now() + chrono::Duration::days(TOKEN_DAYS)).timestamp(),
};
encode(&Header::default(), &claims, &self.enc)
.map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
pub fn verify(&self, token: &str) -> Option<Claims> {
decode::<Claims>(token, &self.dec, &Validation::default()).ok().map(|d| d.claims)
}
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct UserRow {
pub id: i64,
pub username: String,
#[serde(skip)]
pub password_hash: String,
pub email: Option<String>,
pub role: String,
pub status: String,
pub created_at: String,
pub last_login: Option<String>,
}
impl UserRow {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar(
"SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name",
)
.bind(user_id)
.fetch_all(&state.db)
.await?)
}
pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> {
Ok(PublicUser {
id: user.id,
username: user.username.clone(),
uuid: offline_uuid(&user.username),
role: user.role.clone(),
groups: user_groups(state, user.id).await?,
})
}
fn bearer(parts: &Parts) -> Option<&str> {
parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
}
async fn resolve(parts: &Parts, state: &AppState) -> AppResult<Option<UserRow>> {
let Some(token) = bearer(parts) else { return Ok(None) };
let Some(claims) = state.keys.verify(token) else {
return Err(AppError::unauthorized("your session expired, please sign in again"));
};
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 == "pending" => Err(AppError::forbidden("your account is waiting for approval")),
_ => Err(AppError::unauthorized("account disabled or removed")),
}
}
/// Signed-in user if a valid token was sent, otherwise `None`.
pub struct MaybeUser(pub Option<UserRow>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
// A bad token on a public endpoint just means "anonymous".
Ok(MaybeUser(resolve(parts, state).await.unwrap_or(None)))
}
}
pub struct AuthUser(pub UserRow);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
resolve(parts, state).await?.map(AuthUser).ok_or_else(|| AppError::unauthorized("sign in required"))
}
}
pub struct AdminUser(pub UserRow);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let user = resolve(parts, state).await?.ok_or_else(|| AppError::unauthorized("sign in required"))?;
if !user.is_admin() {
return Err(AppError::forbidden("admins only"));
}
Ok(AdminUser(user))
}
}
/// Very small brute-force guard: 10 failed attempts per username locks it
/// for 5 minutes.
#[derive(Default)]
pub struct LoginGuard {
failures: Mutex<HashMap<String, (u32, Instant)>>,
}
impl LoginGuard {
const MAX: u32 = 10;
const WINDOW: Duration = Duration::from_secs(300);
pub fn check(&self, username: &str) -> AppResult<()> {
let map = self.failures.lock().unwrap();
if let Some((count, since)) = map.get(&username.to_lowercase()) {
if *count >= Self::MAX && since.elapsed() < Self::WINDOW {
return Err(AppError::new(
axum::http::StatusCode::TOO_MANY_REQUESTS,
"too many failed attempts, try again in a few minutes",
));
}
}
Ok(())
}
pub fn fail(&self, username: &str) {
let mut map = self.failures.lock().unwrap();
let entry = map.entry(username.to_lowercase()).or_insert((0, Instant::now()));
if entry.1.elapsed() >= Self::WINDOW {
*entry = (0, Instant::now());
}
entry.0 += 1;
}
pub fn succeed(&self, username: &str) {
self.failures.lock().unwrap().remove(&username.to_lowercase());
}
}