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
|
- `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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user