261 lines
8.4 KiB
Rust
261 lines
8.4 KiB
Rust
//! 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<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 {
|
|
#[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<String> {
|
|
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<Claims> {
|
|
decode::<Claims>(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<String>,
|
|
pub role: String,
|
|
pub status: String,
|
|
pub created_at: String,
|
|
pub last_login: Option<String>,
|
|
/// Dashed player UUID.
|
|
pub uuid: String,
|
|
pub skin_hash: Option<String>,
|
|
pub skin_model: String,
|
|
pub cape_id: Option<i64>,
|
|
/// Why the account is disabled (shown to the player when they're refused).
|
|
pub status_reason: 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: 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<i64> {
|
|
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<Option<UserRow>> {
|
|
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<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" && 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<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 std::ops::Deref for AuthUser {
|
|
type Target = UserRow;
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
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 std::ops::Deref for AdminUser {
|
|
type Target = UserRow;
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|