feat: add basic auth for HTTP proxy (#785)

Signed-off-by: Gaius <gaius.qi@gmail.com>
This commit is contained in:
Gaius 2024-10-18 19:11:19 +08:00 committed by GitHub
parent f73239f00a
commit e396706adf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 189 additions and 11 deletions

31
Cargo.lock generated
View File

@ -985,6 +985,7 @@ dependencies = [
name = "dragonfly-client-core"
version = "0.1.112"
dependencies = [
"headers 0.4.0",
"hyper 1.4.1",
"hyper-util",
"libloading",
@ -1043,11 +1044,13 @@ name = "dragonfly-client-util"
version = "0.1.112"
dependencies = [
"base16ct",
"base64 0.22.1",
"blake3",
"crc32fast",
"dragonfly-api",
"dragonfly-client-core",
"hex",
"http 1.1.0",
"http-range-header",
"hyper 1.4.1",
"openssl",
@ -1405,13 +1408,28 @@ checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270"
dependencies = [
"base64 0.21.7",
"bytes",
"headers-core",
"headers-core 0.2.0",
"http 0.2.11",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
dependencies = [
"base64 0.21.7",
"bytes",
"headers-core 0.3.0",
"http 1.1.0",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.2.0"
@ -1421,6 +1439,15 @@ dependencies = [
"http 0.2.11",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http 1.1.0",
]
[[package]]
name = "heck"
version = "0.4.1"
@ -4724,7 +4751,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"headers",
"headers 0.3.9",
"http 0.2.11",
"hyper 0.14.28",
"log",

View File

@ -19,7 +19,10 @@ use dragonfly_client_core::{
error::{ErrorType, OrErr},
Result,
};
use dragonfly_client_util::tls::{generate_ca_cert_from_pem, generate_cert_from_pem};
use dragonfly_client_util::{
http::basic_auth,
tls::{generate_ca_cert_from_pem, generate_cert_from_pem},
};
use local_ip_address::{local_ip, local_ipv6};
use rcgen::Certificate;
use regex::Regex;
@ -871,6 +874,26 @@ impl Default for GC {
}
}
/// BasicAuth is the basic auth configuration for HTTP proxy in dfdaemon.
#[derive(Default, Debug, Clone, Validate, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct BasicAuth {
/// username is the username of the basic auth.
#[validate(length(min = 1, max = 20))]
pub username: String,
/// passwork is the passwork of the basic auth.
#[validate(length(min = 1, max = 20))]
pub password: String,
}
impl BasicAuth {
/// credentials loads the credentials.
pub fn credentials(&self) -> basic_auth::Credentials {
basic_auth::Credentials::new(&self.username, &self.password)
}
}
/// ProxyServer is the proxy server configuration for dfdaemon.
#[derive(Debug, Clone, Validate, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@ -907,6 +930,12 @@ pub struct ProxyServer {
/// and key, and signs the server cert with the root CA cert. When client requests via the proxy,
/// the proxy can intercept the request by the server cert.
pub ca_key: Option<PathBuf>,
/// basic_auth is the basic auth configuration for HTTP proxy in dfdaemon. If basic_auth is not
/// empty, the proxy will use the basic auth to authenticate the client by Authorization
/// header. The value of the Authorization header is "Basic base64(username:password)", refer
/// to https://en.wikipedia.org/wiki/Basic_access_authentication.
pub basic_auth: Option<BasicAuth>,
}
/// ProxyServer implements Default.
@ -917,6 +946,7 @@ impl Default for ProxyServer {
port: default_proxy_server_port(),
ca_cert: None,
ca_key: None,
basic_auth: None,
}
}
}

View File

@ -19,4 +19,5 @@ hyper.workspace = true
hyper-util.workspace = true
opendal.workspace = true
url.workspace = true
headers.workspace = true
libloading = "0.8.5"

View File

@ -141,6 +141,10 @@ pub enum DFError {
#[error{"RangeUnsatisfiable: Failed to parse range fallback error, please file an issue"}]
EmptyHTTPRangeError,
/// Unauthorized is the error for unauthorized.
#[error{"unauthorized"}]
Unauthorized,
/// TonicStatus is the error for tonic status.
#[error(transparent)]
TonicStatus(#[from] tonic::Status),
@ -153,6 +157,10 @@ pub enum DFError {
#[error(transparent)]
TokioStreamElapsed(#[from] tokio_stream::Elapsed),
/// HeadersError is the error for headers.
#[error(transparent)]
HeadersError(#[from] headers::Error),
/// URLParseError is the error for url parse.
#[error(transparent)]
URLParseError(#[from] url::ParseError),

View File

@ -15,6 +15,7 @@ dragonfly-api.workspace = true
reqwest.workspace = true
hyper.workspace = true
http-range-header.workspace = true
http.workspace = true
tracing.workspace = true
url.workspace = true
rcgen.workspace = true
@ -28,6 +29,7 @@ openssl.workspace = true
blake3.workspace = true
crc32fast.workspace = true
base16ct.workspace = true
base64 = "0.22.1"
[dev-dependencies]
tempfile.workspace = true

View File

@ -0,0 +1,81 @@
/*
* Copyright 2024 The Dragonfly Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
use base64::prelude::*;
use dragonfly_client_core::{
error::{ErrorType, OrErr},
Error, Result,
};
use http::header::{self, HeaderMap};
use tracing::instrument;
/// Credentials is the credentials for the basic auth.
pub struct Credentials {
/// username is the username.
pub username: String,
/// password is the password.
pub password: String,
}
/// Credentials is the basic auth.
impl Credentials {
/// new returns a new Credentials.
#[instrument(skip_all)]
pub fn new(username: &str, password: &str) -> Credentials {
Self {
username: username.to_string(),
password: password.to_string(),
}
}
/// verify verifies the basic auth with the header.
pub fn verify(&self, header: &HeaderMap) -> Result<()> {
let Some(auth_header) = header.get(header::AUTHORIZATION) else {
return Err(Error::Unauthorized);
};
if let Some((typ, payload)) = auth_header
.to_str()
.or_err(ErrorType::ParseError)?
.to_string()
.split_once(' ')
{
if typ.to_lowercase() != "basic" {
return Err(Error::Unauthorized);
};
let decoded = String::from_utf8(
BASE64_STANDARD
.decode(payload)
.or_err(ErrorType::ParseError)?,
)
.or_err(ErrorType::ParseError)?;
let Some((username, password)) = decoded.split_once(':') else {
return Err(Error::Unauthorized);
};
if username != self.username || password != self.password {
return Err(Error::Unauthorized);
}
return Ok(());
}
Ok(())
}
}

View File

@ -23,6 +23,8 @@ use reqwest::header::{HeaderMap, HeaderValue};
use std::collections::HashMap;
use tracing::{error, instrument};
pub mod basic_auth;
/// reqwest_headermap_to_hashmap converts a reqwest headermap to a hashmap.
#[instrument(skip_all)]
pub fn reqwest_headermap_to_hashmap(header: &HeaderMap<HeaderValue>) -> HashMap<String, String> {

View File

@ -39,8 +39,8 @@ use dragonfly_client_util::{
use futures_util::TryStreamExt;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, StreamBody};
use hyper::body::Frame;
use hyper::client::conn::http1::Builder;
use hyper::server::conn::http1;
use hyper::client::conn::http1::Builder as ClientBuilder;
use hyper::server::conn::http1::Builder as ServerBuilder;
use hyper::service::service_fn;
use hyper::upgrade::Upgraded;
use hyper::{Method, Request};
@ -170,7 +170,7 @@ impl Proxy {
let registry_cert = self.registry_cert.clone();
let server_ca_cert = self.server_ca_cert.clone();
tokio::task::spawn(async move {
if let Err(err) = http1::Builder::new()
if let Err(err) = ServerBuilder::new()
.keep_alive(true)
.preserve_header_case(true)
.title_case_headers(true)
@ -315,6 +315,21 @@ pub async fn http_handler(
) -> ClientResult<Response> {
info!("handle HTTP request: {:?}", request);
// Authenticate the request with the basic auth.
if let Some(basic_auth) = config.proxy.server.basic_auth.as_ref() {
match basic_auth.credentials().verify(request.headers()) {
Ok(_) => {}
Err(ClientError::Unauthorized) => {
error!("basic auth failed");
return Ok(make_error_response(http::StatusCode::UNAUTHORIZED, None));
}
Err(err) => {
error!("verify basic auth failed: {}", err);
return Ok(make_error_response(http::StatusCode::BAD_REQUEST, None));
}
}
}
// If find the matching rule, proxy the request via the dfdaemon.
let request_uri = request.uri();
if let Some(rule) =
@ -425,9 +440,6 @@ async fn upgraded_tunnel(
registry_cert: Arc<Option<Vec<CertificateDer<'static>>>>,
server_ca_cert: Arc<Option<Certificate>>,
) -> ClientResult<()> {
// Initialize the tcp stream to the remote server.
let upgraded = TokioIo::new(upgraded);
// Generate the self-signed certificate by the given host. If the ca_cert
// is not set, use the self-signed certificate. Otherwise, use the CA
// certificate to sign the self-signed certificate.
@ -449,8 +461,9 @@ async fn upgraded_tunnel(
.with_single_cert(server_certs, server_key)
.or_err(ErrorType::TLSConfigError)?;
server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"http/1.0".to_vec()];
let tls_acceptor = TlsAcceptor::from(Arc::new(server_config));
let tls_stream = tls_acceptor.accept(upgraded).await?;
let tls_stream = tls_acceptor.accept(TokioIo::new(upgraded)).await?;
// Serve the connection with the TLS stream.
if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
@ -490,6 +503,20 @@ pub async fn upgraded_handler(
Span::current().record("uri", request.uri().to_string().as_str());
Span::current().record("method", request.method().as_str());
// Authenticate the request with the basic auth.
if let Some(basic_auth) = config.proxy.server.basic_auth.as_ref() {
match basic_auth.credentials().verify(request.headers()) {
Ok(_) => {}
Err(ClientError::Unauthorized) => {
return Ok(make_error_response(http::StatusCode::UNAUTHORIZED, None));
}
Err(err) => {
error!("verify basic auth failed: {}", err);
return Ok(make_error_response(http::StatusCode::BAD_REQUEST, None));
}
}
}
// If the scheme is not set, set the scheme to https.
if request.uri().scheme().is_none() {
*request.uri_mut() = format!("https://{}{}", host, request.uri())
@ -827,7 +854,7 @@ async fn proxy_http(request: Request<hyper::body::Incoming>) -> ClientResult<Res
let stream = TcpStream::connect((host, port)).await?;
let io = TokioIo::new(stream);
let (mut client, conn) = Builder::new()
let (mut client, conn) = ClientBuilder::new()
.preserve_header_case(true)
.title_case_headers(true)
.handshake(io)