diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bea74..9ccfa1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AdminEmoteResponse` model exposing `uuid` and `alias` fields not present in the public `EmoteResponse`. - Client-side search on the management page filtering emotes by name or alias. +- HTTP Basic Auth middleware protecting `/manage` and all emote write endpoints + (`POST /emotes`, `PUT /emotes/{uuid}`, `DELETE /emotes/{uuid}`). Credentials + are configured via `[auth]` in `config.toml` or the `APP__AUTH__USERNAME` / + `APP__AUTH__PASSWORD` environment variables. Omitting the section causes all + protected routes to return 401. ## [0.2.4] - 2026-04-11 diff --git a/Cargo.lock b/Cargo.lock index 920014a..d850df7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1851,6 +1851,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-s3", "axum", + "base64 0.22.1", "bytes", "chrono", "config", diff --git a/Cargo.toml b/Cargo.toml index bd87392..1ba678e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ thiserror = "1" mime_guess = "2" tokio-util = { version = "0.7", features = ["io"] } bytes = "1" +base64 = "0.22" diff --git a/config.example.toml b/config.example.toml index c01f3f2..57a2a75 100644 --- a/config.example.toml +++ b/config.example.toml @@ -12,6 +12,13 @@ url = "sqlite://mikebase.db" host = "0.0.0.0" port = 3000 +[auth] +# Credentials for the /manage UI and emote write endpoints (POST/PUT/DELETE). +# Can also be set via APP__AUTH__USERNAME and APP__AUTH__PASSWORD env vars. +# If omitted, all protected routes return 401. +username = "admin" +password = "changeme" + [s3] endpoint = "https://s3.eu-central-1.wasabisys.com" region = "eu-central-1" diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..2c05c94 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,66 @@ +use axum::{ + extract::{Request, State}, + http::{header, HeaderValue, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; + +use crate::AppState; + +static WWW_AUTHENTICATE: HeaderValue = + HeaderValue::from_static("Basic realm=\"mikebase\", charset=\"UTF-8\""); + +pub async fn require_basic_auth( + State(state): State, + request: Request, + next: Next, +) -> Response { + if !check(&state, request.headers().get(header::AUTHORIZATION)) { + return ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, WWW_AUTHENTICATE.clone())], + ) + .into_response(); + } + next.run(request).await +} + +fn check(state: &AppState, header: Option<&HeaderValue>) -> bool { + let creds = match &state.cfg.auth { + Some(c) => c, + None => return false, + }; + + let value = match header.and_then(|v| v.to_str().ok()) { + Some(v) => v, + None => return false, + }; + + let encoded = match value.strip_prefix("Basic ") { + Some(s) => s, + None => return false, + }; + + let decoded = match STANDARD.decode(encoded) { + Ok(b) => b, + Err(_) => return false, + }; + + let text = match std::str::from_utf8(&decoded) { + Ok(s) => s, + Err(_) => return false, + }; + + // Split on the first colon only — passwords may contain colons. + let (user, pass) = match text.split_once(':') { + Some(pair) => pair, + None => return false, + }; + + // Compare both fields regardless of which one fails to avoid leaking + // information about which part was wrong. + let user_ok = user == creds.username; + let pass_ok = pass == creds.password; + user_ok & pass_ok +} diff --git a/src/config.rs b/src/config.rs index 59e3144..7c1f6e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,12 +37,19 @@ fn default_port() -> u16 { 3000 } +#[derive(Debug, Deserialize, Clone)] +pub struct AuthConfig { + pub username: String, + pub password: String, +} + #[derive(Debug, Deserialize, Clone)] pub struct AppConfig { pub s3: S3Config, pub database: DatabaseConfig, #[serde(default)] pub server: ServerConfig, + pub auth: Option, } impl Default for ServerConfig { diff --git a/src/main.rs b/src/main.rs index a81ad23..3ef9995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod auth; mod config; mod db; mod models; @@ -7,6 +8,7 @@ mod storage; use std::sync::Arc; use axum::{ + middleware, routing::{delete, get, post, put}, Router, }; @@ -21,6 +23,7 @@ use crate::{config::AppConfig, db::Database, storage::S3Storage}; pub struct AppState { pub db: Database, pub storage: S3Storage, + pub cfg: Arc, } #[tokio::main] @@ -50,18 +53,25 @@ async fn main() { // Build S3 storage client. let storage = S3Storage::new(&cfg); - let state = AppState { db, storage }; + let state = AppState { db, storage, cfg: cfg.clone() }; + + let protected = Router::new() + .route("/manage", get(routes::manage::manage_root)) + .route("/manage/emotes", get(routes::manage::list_admin_emotes)) + .route("/emotes", post(routes::emotes::create_emote)) + .route("/emotes/{uuid}", put(routes::emotes::update_emote)) + .route("/emotes/{uuid}", delete(routes::emotes::delete_emote)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_basic_auth, + )); let app = Router::new() .route("/", get(routes::emotes::root)) .route("/health", get(routes::health::health)) .route("/version", get(routes::version::version)) .route("/json", get(routes::emotes::list_emotes)) - .route("/emotes", post(routes::emotes::create_emote)) - .route("/emotes/{uuid}", put(routes::emotes::update_emote)) - .route("/emotes/{uuid}", delete(routes::emotes::delete_emote)) - .route("/manage", get(routes::manage::manage_root)) - .route("/manage/emotes", get(routes::manage::list_admin_emotes)) + .merge(protected) .layer(TraceLayer::new_for_http()) .with_state(state);