Add HTTP Basic Auth

All write endpoints and the manage
routes are gated behind HTTP Basic Auth middleware; credentials are
configured via [auth] in config.toml or APP__AUTH__USERNAME /
APP__AUTH__PASSWORD environment variables.
This commit is contained in:
Ganonmaster
2026-04-28 03:23:57 +02:00
parent c13f5b9a88
commit 08fd6cea70
7 changed files with 103 additions and 6 deletions
+5
View File
@@ -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 - `AdminEmoteResponse` model exposing `uuid` and `alias` fields not present in
the public `EmoteResponse`. the public `EmoteResponse`.
- Client-side search on the management page filtering emotes by name or alias. - 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 ## [0.2.4] - 2026-04-11
Generated
+1
View File
@@ -1851,6 +1851,7 @@ dependencies = [
"aws-credential-types", "aws-credential-types",
"aws-sdk-s3", "aws-sdk-s3",
"axum", "axum",
"base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"config", "config",
+1
View File
@@ -22,3 +22,4 @@ thiserror = "1"
mime_guess = "2" mime_guess = "2"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
bytes = "1" bytes = "1"
base64 = "0.22"
+7
View File
@@ -12,6 +12,13 @@ url = "sqlite://mikebase.db"
host = "0.0.0.0" host = "0.0.0.0"
port = 3000 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] [s3]
endpoint = "https://s3.eu-central-1.wasabisys.com" endpoint = "https://s3.eu-central-1.wasabisys.com"
region = "eu-central-1" region = "eu-central-1"
+66
View File
@@ -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<AppState>,
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
}
+7
View File
@@ -37,12 +37,19 @@ fn default_port() -> u16 {
3000 3000
} }
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub s3: S3Config, pub s3: S3Config,
pub database: DatabaseConfig, pub database: DatabaseConfig,
#[serde(default)] #[serde(default)]
pub server: ServerConfig, pub server: ServerConfig,
pub auth: Option<AuthConfig>,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
+16 -6
View File
@@ -1,3 +1,4 @@
mod auth;
mod config; mod config;
mod db; mod db;
mod models; mod models;
@@ -7,6 +8,7 @@ mod storage;
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
middleware,
routing::{delete, get, post, put}, routing::{delete, get, post, put},
Router, Router,
}; };
@@ -21,6 +23,7 @@ use crate::{config::AppConfig, db::Database, storage::S3Storage};
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
pub storage: S3Storage, pub storage: S3Storage,
pub cfg: Arc<AppConfig>,
} }
#[tokio::main] #[tokio::main]
@@ -50,18 +53,25 @@ async fn main() {
// Build S3 storage client. // Build S3 storage client.
let storage = S3Storage::new(&cfg); 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() let app = Router::new()
.route("/", get(routes::emotes::root)) .route("/", get(routes::emotes::root))
.route("/health", get(routes::health::health)) .route("/health", get(routes::health::health))
.route("/version", get(routes::version::version)) .route("/version", get(routes::version::version))
.route("/json", get(routes::emotes::list_emotes)) .route("/json", get(routes::emotes::list_emotes))
.route("/emotes", post(routes::emotes::create_emote)) .merge(protected)
.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))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);