use axum::{
extract::{ConnectInfo, Request as AxumRequest, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use crate::AppState;
pub mod local_access;
pub mod rate_limit;
pub use rate_limit::LoginGuard;
pub const SETTING_AUTH_ENABLED: &str = "auth_enabled";
pub const SETTING_LOCAL_AUTH_REQUIRED: &str = "local_auth_required";
pub const DEFAULT_USERNAME: &str = "admin";
const MAX_USERNAME_CHARS: usize = 32;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub ver: i64,
}
pub fn normalize_username(raw: &str) -> Result<String, ()> {
let s = raw.trim();
if s.is_empty() || s.chars().count() > MAX_USERNAME_CHARS || s.chars().any(char::is_control) {
return Err(());
}
Ok(s.to_string())
}
pub fn normalize_username_or_default(raw: Option<&str>) -> Result<String, ()> {
match raw {
Some(s) if !s.trim().is_empty() => normalize_username(s),
_ => Ok(DEFAULT_USERNAME.to_string()),
}
}
pub fn create_token(
secret: &str,
ver: i64,
username: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
let claims = Claims {
sub: username.to_string(),
exp: (chrono::Utc::now() + chrono::Duration::days(90)).timestamp() as usize,
ver,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
}
pub fn verify_token(secret: &str, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
Ok(token_data.claims)
}
pub async fn verify_token_for_state(
db: &SqlitePool,
secret: &str,
token: &str,
) -> Result<Claims, StatusCode> {
let claims = verify_token(secret, token).map_err(|_| StatusCode::UNAUTHORIZED)?;
let stored: Option<i64> = sqlx::query_scalar("SELECT token_version FROM users LIMIT 1")
.fetch_optional(db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if stored != Some(claims.ver) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(claims)
}
pub fn extract_token(req: &AxumRequest, cookie_name: &str) -> Option<String> {
if let Some(cookie) = req.headers().get("cookie").and_then(|v| v.to_str().ok()) {
for pair in cookie.split(';') {
let pair = pair.trim();
if let Some(rest) = pair.strip_prefix(cookie_name)
&& let Some(value) = rest.strip_prefix('=')
{
return Some(value.to_string());
}
}
}
if let Some(auth) = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
return Some(auth.to_string());
}
None
}
pub async fn verify_request(
state: &AppState,
token: Option<&str>,
headers: &HeaderMap,
peer: Option<SocketAddr>,
) -> Result<(), StatusCode> {
if !state.auth_enabled.load(Ordering::Relaxed) {
return Ok(());
}
if !state.local_auth_required.load(Ordering::Relaxed)
&& local_access::is_local_request(headers, peer)
{
return Ok(());
}
let token = token.ok_or(StatusCode::UNAUTHORIZED)?;
verify_token_for_state(&state.db, &state.jwt_secret, token).await?;
Ok(())
}
pub async fn require_auth_mw(
State(state): State<AppState>,
request: AxumRequest,
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_token(&request, &state.token_cookie);
let peer = request.extensions().get::<ConnectInfo<SocketAddr>>().map(|ci| ci.0);
verify_request(&state, token.as_deref(), request.headers(), peer).await?;
Ok(next.run(request).await)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn headers(host: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("host", HeaderValue::from_str(host).unwrap());
h
}
fn peer(ip: &str) -> Option<SocketAddr> {
Some(SocketAddr::new(ip.parse().unwrap(), 40000))
}
async fn state(auth_enabled: bool, local_auth_required: bool) -> AppState {
let state = crate::test_utils::test_state().await;
state.auth_enabled.store(auth_enabled, Ordering::Relaxed);
state.local_auth_required.store(local_auth_required, Ordering::Relaxed);
state
}
#[tokio::test]
async fn local_bypass_hits_without_token() {
let state = state(true, false).await;
let r = verify_request(&state, None, &headers("127.0.0.1:18777"), peer("127.0.0.1")).await;
assert!(r.is_ok());
}
#[tokio::test]
async fn remote_shape_requires_token() {
let state = state(true, false).await;
let r =
verify_request(&state, None, &headers("omniterm.example.com"), peer("127.0.0.1")).await;
assert_eq!(r, Err(StatusCode::UNAUTHORIZED));
}
#[tokio::test]
async fn local_bypass_disabled_requires_token_even_locally() {
let state = state(true, true).await;
let r = verify_request(&state, None, &headers("127.0.0.1:18777"), peer("127.0.0.1")).await;
assert_eq!(r, Err(StatusCode::UNAUTHORIZED));
}
#[tokio::test]
async fn auth_disabled_allows_everything() {
let state = state(false, true).await;
let r = verify_request(&state, None, &headers("evil.example.com"), peer("8.8.8.8")).await;
assert!(r.is_ok());
}
#[tokio::test]
async fn proxy_header_blocks_local_bypass() {
let state = state(true, false).await;
let mut h = headers("127.0.0.1:18777");
h.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.9"));
let r = verify_request(&state, None, &h, peer("127.0.0.1")).await;
assert_eq!(r, Err(StatusCode::UNAUTHORIZED));
}
#[tokio::test]
async fn middleware_wires_peer_and_headers_into_local_bypass() {
use axum::{Router, middleware, routing::get};
use tower::ServiceExt;
let state = state(true, false).await;
let app = Router::new()
.route("/protected", get(|| async { "ok" }))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth_mw))
.with_state(state.clone());
let request = |host: &str| {
axum::http::Request::builder()
.uri("/protected")
.header("host", host)
.extension(ConnectInfo(SocketAddr::new("127.0.0.1".parse().unwrap(), 40000)))
.body(axum::body::Body::empty())
.unwrap()
};
let resp = app.clone().oneshot(request("127.0.0.1:18777")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app.clone().oneshot(request("omniterm.example.com")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn middleware_local_bypass_fails_closed_without_connect_info() {
use axum::{Router, middleware, routing::get};
use tower::ServiceExt;
let state = state(true, false).await;
let app = Router::new()
.route("/protected", get(|| async { "ok" }))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth_mw))
.with_state(state.clone());
let request = axum::http::Request::builder()
.uri("/protected")
.header("host", "127.0.0.1:18777")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(request).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn valid_token_passes_even_remotely() {
let state = state(true, true).await;
sqlx::query(
"INSERT INTO users (username, password_hash, created_at) VALUES ('admin', 'x', '')",
)
.execute(&state.db)
.await
.expect("seed user");
let token = create_token(&state.jwt_secret, 1, "admin").expect("token");
let r = verify_request(
&state,
Some(&token),
&headers("omniterm.example.com"),
peer("203.0.113.9"),
)
.await;
assert!(r.is_ok());
}
#[test]
fn normalize_username_enforces_boundaries() {
assert_eq!(normalize_username(&"a".repeat(31)).unwrap().len(), 31);
assert!(normalize_username(&"a".repeat(32)).is_ok());
assert!(normalize_username(&"a".repeat(33)).is_err());
assert!(normalize_username(&"汉".repeat(32)).is_ok());
assert!(normalize_username(&"汉".repeat(33)).is_err());
}
#[test]
fn normalize_username_trims_and_rejects_control_chars() {
assert_eq!(normalize_username(" alice ").unwrap(), "alice");
for bad in ["", " ", "bad\nname", "bad\tname", "bad\u{7f}name", "a\u{0}b"] {
assert!(normalize_username(bad).is_err(), "bad={bad:?}");
}
}
#[test]
fn normalize_username_or_default_falls_back_to_admin() {
assert_eq!(normalize_username_or_default(None).unwrap(), DEFAULT_USERNAME);
assert_eq!(normalize_username_or_default(Some("")).unwrap(), DEFAULT_USERNAME);
assert_eq!(normalize_username_or_default(Some(" ")).unwrap(), DEFAULT_USERNAME);
assert_eq!(normalize_username_or_default(Some(" alice ")).unwrap(), "alice");
assert!(normalize_username_or_default(Some("bad\nname")).is_err());
}
#[test]
fn setting_local_auth_required_key_is_stable() {
assert_eq!(SETTING_LOCAL_AUTH_REQUIRED, "local_auth_required");
}
}