//! 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::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 { 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 { #[serde(default)] pub version: i64, 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 { let claims = Claims { version: user.auth_version, 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 { decode::(token, &self.dec, &Validation::default()).ok().map(|d| d.claims) } } #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct UserRow { #[serde(skip)] pub auth_version: i64, pub id: i64, pub username: String, #[serde(skip)] pub password_hash: String, pub email: Option, pub role: String, pub status: String, pub created_at: String, pub last_login: Option, /// Dashed player UUID. pub uuid: String, pub skin_hash: Option, pub skin_model: String, pub cape_id: Option, /// Why the account is disabled (shown to the player when they're refused). pub status_reason: Option, } impl UserRow { pub fn is_admin(&self) -> bool { self.role == "admin" } } pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult> { 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 { Ok(PublicUser { id: user.id, username: user.username.clone(), uuid: user.uuid.clone(), role: user.role.clone(), groups: user_groups(state, user.id).await?, }) } /// Allocate identity once. Names can change; the account UUID never does. pub async fn create_user( state: &AppState, username: &str, password: &str, email: Option<&str>, role: &str, status: &str, ) -> AppResult { crate::store::check_username(state, username).await?; let hash = hash_password(password)?; sqlx::query_scalar( "INSERT INTO users (username, password_hash, email, role, status, created_at, uuid) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(username) .bind(hash) .bind(email.map(str::trim).filter(|e| !e.is_empty())) .bind(role) .bind(status) .bind(crate::db::now()) .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") || d.message().contains("username reserved") => { AppError::conflict("that username is taken or reserved") } e => e.into(), }) } pub async fn find_user_by_name(state: &AppState, name: &str) -> AppResult> { Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(name.trim()).fetch_optional(&state.db).await?) } pub 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> { 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 = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?; match user { 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")), } } /// Signed-in user if a valid token was sent, otherwise `None`. pub struct MaybeUser(pub Option); impl FromRequestParts for MaybeUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { // 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 std::ops::Deref for AuthUser { type Target = UserRow; fn deref(&self) -> &Self::Target { &self.0 } } impl FromRequestParts for AuthUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { resolve(parts, state).await?.map(AuthUser).ok_or_else(|| AppError::unauthorized("sign in required")) } } pub struct AdminUser(pub UserRow); impl std::ops::Deref for AdminUser { type Target = UserRow; fn deref(&self) -> &Self::Target { &self.0 } } impl FromRequestParts for AdminUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { 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>, } 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()); } }