use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
use mercurio_server::acl::{Access, Authorizer, ClientIdentity};
use mercurio_server::auth::CredentialValidator;
use serde_json::json;
use tracing::warn;
const CACHE_LIMIT: usize = 10_000;
type CacheKey = (Option<String>, String, String, u8);
pub struct HttpAuth {
agent: ureq::Agent,
user_url: Option<String>,
acl_url: Option<String>,
cache_for: Duration,
cache: Mutex<HashMap<CacheKey, (bool, Instant)>>,
}
impl HttpAuth {
pub fn new(
user_url: Option<String>,
acl_url: Option<String>,
timeout: Duration,
cache_for: Duration,
) -> Self {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.build()
.into();
Self {
agent,
user_url,
acl_url,
cache_for,
cache: Mutex::default(),
}
}
fn allows(&self, url: &str, body: serde_json::Value) -> bool {
let send = || {
self.agent
.post(url)
.header("Content-Type", "application/json")
.send(body.to_string())
};
match tokio::task::block_in_place(send) {
Ok(_) => true,
Err(ureq::Error::StatusCode(_)) => false,
Err(e) => {
warn!("Auth request to {} failed: {}", url, e);
false
}
}
}
}
impl CredentialValidator for HttpAuth {
fn validate(&self, username: &str, password: &[u8]) -> bool {
let Some(url) = &self.user_url else {
return false;
};
let body = json!({
"username": username,
"password": String::from_utf8_lossy(password),
"clientid": "",
});
self.allows(url, body)
}
}
impl Authorizer for HttpAuth {
fn authorize(&self, client: &ClientIdentity, topic: &str, access: Access) -> bool {
let Some(url) = &self.acl_url else {
return true;
};
let acc = match access {
Access::Read => 1,
Access::Write => 2,
Access::Subscribe => 4,
};
let key = (
client.username.clone(),
client.client_id.clone(),
topic.to_string(),
acc,
);
if let Some((allowed, at)) = self.cache.lock().unwrap().get(&key) {
if at.elapsed() < self.cache_for {
return *allowed;
}
}
let body = json!({
"username": client.username,
"clientid": client.client_id,
"topic": topic,
"acc": acc,
});
let allowed = self.allows(url, body);
let mut cache = self.cache.lock().unwrap();
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, (allowed, Instant::now()));
allowed
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
io::{BufRead, BufReader, Read, Write},
net::TcpListener,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
fn serve(allow: &'static str) -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
let requests = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&requests);
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if let Some(value) = line.to_lowercase().strip_prefix("content-length:") {
length = value.trim().parse().unwrap();
}
if line == "\r\n" {
break;
}
}
let mut body = vec![0; length];
reader.read_exact(&mut body).unwrap();
counter.fetch_add(1, Ordering::SeqCst);
let status = if String::from_utf8_lossy(&body).contains(allow) {
"200 OK"
} else {
"403 Forbidden"
};
let response =
format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
stream.write_all(response.as_bytes()).unwrap();
}
});
(url, requests)
}
#[tokio::test(flavor = "multi_thread")]
async fn test_endpoints_decide_and_acl_answers_are_cached() {
let (user_url, _) = serve("\"password\":\"secret\"");
let (acl_url, acl_requests) = serve("\"topic\":\"allowed\"");
let auth = HttpAuth::new(
Some(user_url),
Some(acl_url),
Duration::from_secs(2),
Duration::from_secs(60),
);
assert!(auth.validate("alice", b"secret"));
assert!(!auth.validate("alice", b"wrong"));
let alice = ClientIdentity {
client_id: "c".to_string(),
username: Some("alice".to_string()),
};
assert!(auth.authorize(&alice, "allowed", Access::Write));
assert!(!auth.authorize(&alice, "other", Access::Write));
assert!(auth.authorize(&alice, "allowed", Access::Write));
assert_eq!(acl_requests.load(Ordering::SeqCst), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_unreachable_endpoints_deny() {
let auth = HttpAuth::new(
Some("http://127.0.0.1:1/".to_string()),
None,
Duration::from_secs(2),
Duration::from_secs(60),
);
assert!(!auth.validate("alice", b"secret"));
}
}