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:
@@ -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
|
||||
|
||||
|
||||
Generated
+1
@@ -1851,6 +1851,7 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"config",
|
||||
|
||||
@@ -22,3 +22,4 @@ thiserror = "1"
|
||||
mime_guess = "2"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
bytes = "1"
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+66
@@ -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
|
||||
}
|
||||
@@ -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<AuthConfig>,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
|
||||
+16
-6
@@ -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<AppConfig>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user