use super::{
Error, get_bool_conf, get_hash_key, get_plugin_factory, get_str_conf,
get_str_slice_conf,
};
use async_trait::async_trait;
use bytes::Bytes;
use ctor::ctor;
use http::{HeaderName, StatusCode};
use humantime::parse_duration;
use pingap_config::{PluginCategory, PluginConf};
use pingap_core::{
Ctx, HttpResponse, Plugin, PluginStep, RequestPluginResult,
get_query_value, remove_query_from_header,
};
use pingora::proxy::Session;
use std::borrow::Cow;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, error};
type Result<T, E = Error> = std::result::Result<T, E>;
pub struct KeyAuth {
plugin_step: PluginStep,
header: Option<HeaderName>,
query: Option<String>,
keys: Vec<Vec<u8>>,
delay: Option<Duration>,
miss_authorization_resp: HttpResponse,
unauthorized_resp: HttpResponse,
hide_credentials: bool,
hash_value: String,
}
impl TryFrom<&PluginConf> for KeyAuth {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let hash_value = get_hash_key(value);
let delay = get_str_conf(value, "delay");
let delay = if !delay.is_empty() {
let d = parse_duration(&delay).map_err(|e| Error::Invalid {
category: PluginCategory::KeyAuth.to_string(),
message: e.to_string(),
})?;
Some(d)
} else {
None
};
let query_name = get_str_conf(value, "query");
let header_name = get_str_conf(value, "header");
if query_name.is_empty() && header_name.is_empty() {
return Err(Error::Invalid {
category: PluginCategory::KeyAuth.to_string(),
message: "auth key is not allowed empty".to_string(),
});
}
let mut query = None;
let mut header = None;
if !query_name.is_empty() {
query = Some(query_name);
} else {
header = Some(HeaderName::from_str(&header_name).map_err(|e| {
Error::Invalid {
category: PluginCategory::KeyAuth.to_string(),
message: format!("invalid header name, {e}"),
}
})?);
}
let keys: Vec<Vec<u8>> = get_str_slice_conf(value, "keys")
.iter()
.map(|item| item.as_bytes().to_vec())
.collect();
if keys.is_empty() {
return Err(Error::Invalid {
category: PluginCategory::KeyAuth.to_string(),
message: "auth keys can't be empty".to_string(),
});
}
let params = Self {
hash_value,
keys,
hide_credentials: get_bool_conf(value, "hide_credentials"),
plugin_step: PluginStep::Request,
query,
header,
delay,
miss_authorization_resp: HttpResponse {
status: StatusCode::UNAUTHORIZED,
body: Bytes::from_static(b"Key missing"),
..Default::default()
},
unauthorized_resp: HttpResponse {
status: StatusCode::UNAUTHORIZED,
body: Bytes::from_static(b"Key auth fail"),
..Default::default()
},
};
Ok(params)
}
}
impl KeyAuth {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(params = params.to_string(), "new key auth plugin");
Self::try_from(params)
}
}
#[ctor]
fn init() {
get_plugin_factory()
.register("key_auth", |params| Ok(Arc::new(KeyAuth::new(params)?)));
}
#[async_trait]
impl Plugin for KeyAuth {
#[inline]
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
#[inline]
async fn handle_request(
&self,
step: PluginStep,
session: &mut Session,
_ctx: &mut Ctx,
) -> pingora::Result<RequestPluginResult> {
if step != self.plugin_step {
return Ok(RequestPluginResult::Skipped);
}
let value = if let Some(key) = &self.query {
get_query_value(session.req_header(), key)
.unwrap_or_default()
.as_bytes()
} else {
self.header
.as_ref()
.map(|v| session.get_header_bytes(v))
.unwrap_or_default()
};
if value.is_empty() {
return Ok(RequestPluginResult::Respond(
self.miss_authorization_resp.clone(),
));
}
if !self.keys.iter().any(|key| key == value) {
if let Some(d) = self.delay {
sleep(d).await;
}
return Ok(RequestPluginResult::Respond(
self.unauthorized_resp.clone(),
));
}
if self.hide_credentials {
if let Some(name) = &self.header {
session.req_header_mut().remove_header(name);
} else if let Some(name) = &self.query
&& let Err(e) =
remove_query_from_header(session.req_header_mut(), name)
{
error!(error = e.to_string(), "remove query fail");
}
}
Ok(RequestPluginResult::Continue)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pingap_config::PluginConf;
use pingap_core::{Ctx, PluginStep};
use pingora::proxy::Session;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
#[test]
fn test_key_auth_params() {
let params = KeyAuth::try_from(
&toml::from_str::<PluginConf>(
r###"
header = "X-User"
keys = [
"123",
"456",
]
"###,
)
.unwrap(),
)
.unwrap();
assert_eq!("request", params.plugin_step.to_string());
assert_eq!(true, params.header.is_some());
if let Some(value) = params.header {
assert_eq!("x-user", value.to_string());
}
assert_eq!(
"123,456",
params
.keys
.iter()
.map(|item| std::string::String::from_utf8_lossy(item))
.collect::<Vec<_>>()
.join(",")
);
let result = KeyAuth::try_from(
&toml::from_str::<PluginConf>(
r###"
keys = [
"123",
"456",
]
"###,
)
.unwrap(),
);
assert_eq!(
"Plugin key_auth invalid, message: auth key is not allowed empty",
result.err().unwrap().to_string()
);
}
#[tokio::test]
async fn test_key_auth() {
let auth = KeyAuth::new(
&toml::from_str::<PluginConf>(
r###"
header = "X-User"
keys = [
"123",
"456",
]
hide_credentials = true
"###,
)
.unwrap(),
)
.unwrap();
let headers = ["X-User: 123"].join("\r\n");
let input_header =
format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
assert_eq!(false, session.get_header_bytes("X-User").is_empty());
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
assert_eq!(true, result == RequestPluginResult::Continue);
assert_eq!(true, session.get_header_bytes("X-User").is_empty());
let headers = ["X-User: 12"].join("\r\n");
let input_header =
format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(401, resp.status.as_u16());
assert_eq!(
"Key auth fail",
std::string::String::from_utf8_lossy(resp.body.as_ref())
);
let headers = [""].join("\r\n");
let input_header =
format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(401, resp.status.as_u16());
assert_eq!(
"Key missing",
std::string::String::from_utf8_lossy(resp.body.as_ref())
);
let auth = KeyAuth::new(
&toml::from_str::<PluginConf>(
r###"
query = "user"
keys = [
"123",
"456",
]
hide_credentials = true
"###,
)
.unwrap(),
)
.unwrap();
let headers = [""].join("\r\n");
let input_header = format!(
"GET /vicanso/pingap?user=123&type=1 HTTP/1.1\r\n{headers}\r\n\r\n"
);
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
assert_eq!(
"/vicanso/pingap?user=123&type=1",
session.req_header().uri.to_string()
);
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
assert_eq!(true, result == RequestPluginResult::Continue);
assert_eq!(
"/vicanso/pingap?type=1",
session.req_header().uri.to_string()
);
}
}