Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
[package]
|
||||
name = "http"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
store = { path = "../store" }
|
||||
common = { path = "../common" }
|
||||
utils = { path = "../utils" }
|
||||
trc = { path = "../trc" }
|
||||
email = { path = "../email" }
|
||||
smtp = { path = "../smtp" }
|
||||
jmap = { path = "../jmap" }
|
||||
dav = { path = "../dav" }
|
||||
scim = { path = "../scim" }
|
||||
groupware = { path = "../groupware" }
|
||||
http_proto = { path = "../http-proto" }
|
||||
jmap_proto = { path = "../jmap-proto" }
|
||||
types = { path = "../types" }
|
||||
directory = { path = "../directory" }
|
||||
services = { path = "../services" }
|
||||
registry = { path = "../registry" }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] }
|
||||
mail-builder = { version = "1.0" }
|
||||
mail-auth = { version = "0.13", features = ["generate", "arc"] }
|
||||
tokio = { version = "1.53", features = ["rt"] }
|
||||
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1.20", features = ["tokio"] }
|
||||
http-body-util = "0.1.5"
|
||||
async-stream = "0.3.6"
|
||||
serde = { version = "1.0", features = ["derive"]}
|
||||
serde_json = "1.0"
|
||||
chrono = "0.4"
|
||||
base64 = "0.23"
|
||||
sha2 = "0.11"
|
||||
rkyv = { version = "0.8.18", features = ["little_endian"] }
|
||||
form-data = { version = "0.6.0", features = ["sync"], default-features = false }
|
||||
mime = "0.3.17"
|
||||
percent-encoding = "2.3.2"
|
||||
hashify = { version = "0.2" }
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = "1.1"
|
||||
|
||||
[features]
|
||||
test_mode = []
|
||||
dev_mode = []
|
||||
enterprise = ["scim/enterprise"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,756 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::{
|
||||
queue::{HostOrIp, MxConfig},
|
||||
resolver::{Policy, Tlsa},
|
||||
},
|
||||
};
|
||||
use hyper::body::{Bytes, Frame};
|
||||
use mail_auth::{IpLookupStrategy, mta_sts::TlsRpt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smtp::outbound::{
|
||||
client::{SmtpClient, StartTlsResult},
|
||||
dane::{
|
||||
dnssec::{TlsaLookup, TlsaResult},
|
||||
verify::TlsaVerify,
|
||||
},
|
||||
error::ClientError,
|
||||
lookup::{DnsLookup, SourceIp, ToNextHop},
|
||||
mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy},
|
||||
};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{io::AsyncWriteExt, sync::mpsc};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum DeliveryStage {
|
||||
MxLookupStart {
|
||||
domain: String,
|
||||
},
|
||||
MxLookupSuccess {
|
||||
mxs: Vec<MX>,
|
||||
elapsed: u64,
|
||||
},
|
||||
MxLookupError {
|
||||
reason: String,
|
||||
elapsed: u64,
|
||||
},
|
||||
MtaStsFetchStart,
|
||||
MtaStsFetchSuccess {
|
||||
policy: Policy,
|
||||
elapsed: u64,
|
||||
},
|
||||
MtaStsFetchError {
|
||||
reason: String,
|
||||
elapsed: u64,
|
||||
},
|
||||
MtaStsNotFound {
|
||||
elapsed: u64,
|
||||
},
|
||||
TlsRptLookupStart,
|
||||
TlsRptLookupSuccess {
|
||||
rua: Vec<ReportUri>,
|
||||
elapsed: u64,
|
||||
},
|
||||
TlsRptLookupError {
|
||||
reason: String,
|
||||
elapsed: u64,
|
||||
},
|
||||
TlsRptNotFound {
|
||||
elapsed: u64,
|
||||
},
|
||||
DeliveryAttemptStart {
|
||||
hostname: String,
|
||||
},
|
||||
MtaStsVerifySuccess,
|
||||
MtaStsVerifyError {
|
||||
reason: String,
|
||||
},
|
||||
TlsaLookupStart,
|
||||
TlsaLookupSuccess {
|
||||
record: Tlsa,
|
||||
elapsed: u64,
|
||||
},
|
||||
TlsaNotFound {
|
||||
elapsed: u64,
|
||||
reason: String,
|
||||
},
|
||||
TlsaLookupError {
|
||||
elapsed: u64,
|
||||
reason: String,
|
||||
},
|
||||
IpLookupStart,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
IpLookupSuccess {
|
||||
remote_ips: Vec<IpAddr>,
|
||||
elapsed: u64,
|
||||
},
|
||||
IpLookupError {
|
||||
reason: String,
|
||||
elapsed: u64,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ConnectionStart {
|
||||
remote_ip: IpAddr,
|
||||
},
|
||||
ConnectionSuccess {
|
||||
elapsed: u64,
|
||||
},
|
||||
ConnectionError {
|
||||
elapsed: u64,
|
||||
reason: String,
|
||||
},
|
||||
ReadGreetingStart,
|
||||
ReadGreetingSuccess {
|
||||
elapsed: u64,
|
||||
},
|
||||
ReadGreetingError {
|
||||
elapsed: u64,
|
||||
reason: String,
|
||||
},
|
||||
EhloStart,
|
||||
EhloSuccess {
|
||||
elapsed: u64,
|
||||
},
|
||||
EhloError {
|
||||
elapsed: u64,
|
||||
reason: String,
|
||||
},
|
||||
StartTlsStart,
|
||||
StartTlsSuccess {
|
||||
elapsed: u64,
|
||||
},
|
||||
StartTlsError {
|
||||
elapsed: u64,
|
||||
reason: String,
|
||||
},
|
||||
DaneVerifySuccess,
|
||||
DaneVerifyError {
|
||||
reason: String,
|
||||
},
|
||||
MailFromStart,
|
||||
MailFromSuccess {
|
||||
elapsed: u64,
|
||||
},
|
||||
MailFromError {
|
||||
reason: String,
|
||||
elapsed: u64,
|
||||
},
|
||||
RcptToStart,
|
||||
RcptToSuccess {
|
||||
elapsed: u64,
|
||||
},
|
||||
RcptToError {
|
||||
reason: String,
|
||||
elapsed: u64,
|
||||
},
|
||||
QuitStart,
|
||||
QuitCompleted {
|
||||
elapsed: u64,
|
||||
},
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct MX {
|
||||
pub exchanges: Vec<String>,
|
||||
pub preference: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ReportUri {
|
||||
Mail { email: String },
|
||||
Http { url: String },
|
||||
}
|
||||
|
||||
impl DeliveryStage {
|
||||
pub fn to_frame(&self) -> Frame<Bytes> {
|
||||
let payload = format!(
|
||||
"event: event\ndata: [{}]\n\n",
|
||||
serde_json::to_string(self).unwrap_or_default()
|
||||
);
|
||||
Frame::data(Bytes::from(payload))
|
||||
}
|
||||
}
|
||||
|
||||
trait ElapsedMs {
|
||||
fn elapsed_ms(&self) -> u64;
|
||||
}
|
||||
|
||||
impl ElapsedMs for Instant {
|
||||
fn elapsed_ms(&self) -> u64 {
|
||||
self.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
pub(crate) fn spawn_delivery_diagnose(
|
||||
server: Server,
|
||||
domain_or_email: String,
|
||||
timeout: Duration,
|
||||
) -> mpsc::Receiver<DeliveryStage> {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = delivery_diagnose(tx, server, domain_or_email, timeout).await;
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
async fn delivery_diagnose(
|
||||
tx: mpsc::Sender<DeliveryStage>,
|
||||
server: Server,
|
||||
domain_or_email: String,
|
||||
timeout: Duration,
|
||||
) -> Result<(), mpsc::error::SendError<DeliveryStage>> {
|
||||
let (domain, email) = if let Some((_, domain)) = domain_or_email.rsplit_once('@') {
|
||||
(domain.to_string(), Some(domain_or_email))
|
||||
} else {
|
||||
(domain_or_email, None)
|
||||
};
|
||||
|
||||
let local_host = &server.core.network.server_name;
|
||||
let conn_strategy = server.get_connection_or_default("default", 0);
|
||||
|
||||
tx.send(DeliveryStage::MxLookupStart {
|
||||
domain: domain.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Lookup MX
|
||||
let now = Instant::now();
|
||||
let mxs = match server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.mx_lookup(&domain, Some(&server.inner.cache.dns_mx))
|
||||
.await
|
||||
{
|
||||
Ok(mxs) => mxs,
|
||||
Err(err) => {
|
||||
tx.send(DeliveryStage::MxLookupError {
|
||||
reason: err.to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Obtain remote host list
|
||||
let mx_config = MxConfig {
|
||||
max_mx: mxs.rrset.len(),
|
||||
max_multi_homed: 10,
|
||||
ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6,
|
||||
};
|
||||
let hosts = if let Some(hosts) = mxs.to_remote_hosts(&domain, &mx_config) {
|
||||
tx.send(DeliveryStage::MxLookupSuccess {
|
||||
mxs: mxs
|
||||
.rrset
|
||||
.iter()
|
||||
.map(|mx| MX {
|
||||
exchanges: mx.exchanges.iter().map(|e| e.to_string()).collect(),
|
||||
preference: mx.preference,
|
||||
})
|
||||
.collect(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
hosts
|
||||
} else {
|
||||
tx.send(DeliveryStage::MxLookupError {
|
||||
reason: "Null MX record".to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Fetch MTA-STS policy
|
||||
let now = Instant::now();
|
||||
tx.send(DeliveryStage::MtaStsFetchStart).await?;
|
||||
let mta_sts_policy = match server.lookup_mta_sts_policy(&domain, timeout).await {
|
||||
Ok(policy) => {
|
||||
tx.send(DeliveryStage::MtaStsFetchSuccess {
|
||||
policy: policy.as_ref().clone(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
Some(policy)
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
&err,
|
||||
smtp::outbound::mta_sts::Error::Dns(mail_auth::Error::Dns(
|
||||
mail_auth::DnsError::RecordNotFound(_)
|
||||
))
|
||||
) {
|
||||
tx.send(DeliveryStage::MtaStsNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
tx.send(DeliveryStage::MtaStsFetchError {
|
||||
reason: err.to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch TLS-RPT settings
|
||||
let now = Instant::now();
|
||||
tx.send(DeliveryStage::TlsRptLookupStart).await?;
|
||||
match server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.txt_lookup::<TlsRpt>(
|
||||
format!("_smtp._tls.{domain}."),
|
||||
Some(&server.inner.cache.dns_txt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => {
|
||||
tx.send(DeliveryStage::TlsRptLookupSuccess {
|
||||
rua: record
|
||||
.rua
|
||||
.iter()
|
||||
.map(|r| match r {
|
||||
mail_auth::mta_sts::ReportUri::Mail(email) => ReportUri::Mail {
|
||||
email: email.clone(),
|
||||
},
|
||||
mail_auth::mta_sts::ReportUri::Http(url) => {
|
||||
ReportUri::Http { url: url.clone() }
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
&err,
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))
|
||||
) {
|
||||
tx.send(DeliveryStage::TlsRptNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
tx.send(DeliveryStage::TlsRptLookupError {
|
||||
reason: err.to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try with each host
|
||||
'outer: for host in hosts {
|
||||
let hostname = host.hostname();
|
||||
|
||||
tx.send(DeliveryStage::DeliveryAttemptStart {
|
||||
hostname: hostname.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Verify MTA-STS policy
|
||||
if let Some(mta_sts_policy) = &mta_sts_policy {
|
||||
if mta_sts_policy.verify(hostname) {
|
||||
tx.send(DeliveryStage::MtaStsVerifySuccess).await?;
|
||||
} else {
|
||||
tx.send(DeliveryStage::MtaStsVerifyError {
|
||||
reason: "Not authorized by policy".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch TLSA record
|
||||
tx.send(DeliveryStage::TlsaLookupStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let dane_policy = match server.tlsa_lookup(format!("_25._tcp.{hostname}.")).await {
|
||||
Ok(TlsaResult::Secure(tlsa)) if tlsa.has_end_entities => {
|
||||
tx.send(DeliveryStage::TlsaLookupSuccess {
|
||||
record: tlsa.as_ref().clone(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Some(tlsa)
|
||||
}
|
||||
Ok(TlsaResult::Secure(_)) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "TLSA record does not have end entities".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Ok(TlsaResult::Bogus) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "Bogus TLSA record".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Ok(TlsaResult::Missing) => {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "No TLSA DNSSEC records found".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
&err,
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))
|
||||
) {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "No TLSA records found for MX".to_string(),
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
tx.send(DeliveryStage::IpLookupStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let remote_ips = match host.fqdn_hostname() {
|
||||
HostOrIp::Host(hostname) => {
|
||||
match server
|
||||
.ip_lookup(&hostname, IpLookupStrategy::Ipv4thenIpv6, usize::MAX, false)
|
||||
.await
|
||||
{
|
||||
Ok((remote_ips, _)) if !remote_ips.is_empty() => remote_ips,
|
||||
Ok(_) => {
|
||||
tx.send(DeliveryStage::IpLookupError {
|
||||
reason: "No IP addresses found for host".to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(DeliveryStage::IpLookupError {
|
||||
reason: err.to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
HostOrIp::Ip(ip) => vec![ip],
|
||||
};
|
||||
|
||||
tx.send(DeliveryStage::IpLookupSuccess {
|
||||
remote_ips: remote_ips.clone(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
for remote_ip in remote_ips {
|
||||
// Start connection
|
||||
tx.send(DeliveryStage::ConnectionStart { remote_ip })
|
||||
.await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let connect = if let Some(ip_host) = conn_strategy.source_ip(remote_ip.is_ipv4()) {
|
||||
SmtpClient::connect_using(ip_host.ip, SocketAddr::new(remote_ip, 25), timeout, 0)
|
||||
.await
|
||||
} else {
|
||||
SmtpClient::connect(SocketAddr::new(remote_ip, 25), timeout, 0).await
|
||||
};
|
||||
match connect {
|
||||
Ok(mut client) => {
|
||||
tx.send(DeliveryStage::ConnectionSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Read greeting
|
||||
tx.send(DeliveryStage::ReadGreetingStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
if let Err(status) = client.read_greeting(hostname).await {
|
||||
tx.send(DeliveryStage::ReadGreetingError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: status.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
tx.send(DeliveryStage::ReadGreetingSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Say EHLO
|
||||
tx.send(DeliveryStage::EhloStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let capabilities = match tokio::time::timeout(timeout, async {
|
||||
client
|
||||
.stream
|
||||
.write_all(format!("EHLO {local_host}\r\n",).as_bytes())
|
||||
.await?;
|
||||
client.stream.flush().await?;
|
||||
client.read_ehlo().await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(capabilities)) => {
|
||||
tx.send(DeliveryStage::EhloSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
capabilities
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
tx.send(DeliveryStage::EhloError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
tx.send(DeliveryStage::EhloError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "Timed out reading response".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Start TLS
|
||||
tx.send(DeliveryStage::StartTlsStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let mut client = match client
|
||||
.try_start_tls(
|
||||
&server.inner.data.smtp_connectors.pki_verify,
|
||||
hostname,
|
||||
&capabilities,
|
||||
)
|
||||
.await
|
||||
{
|
||||
StartTlsResult::Success { smtp_client } => {
|
||||
tx.send(DeliveryStage::StartTlsSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
smtp_client
|
||||
}
|
||||
StartTlsResult::Error { error } => {
|
||||
tx.send(DeliveryStage::StartTlsError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: error.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
StartTlsResult::Unavailable { response, .. } => {
|
||||
tx.send(DeliveryStage::StartTlsError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: response.map(|r| r.to_string()).unwrap_or_else(|| {
|
||||
"STARTTLS not advertised by host".to_string()
|
||||
}),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify DANE policy
|
||||
if let Some(dane_policy) = &dane_policy {
|
||||
if let Err(err) = dane_policy.verify(
|
||||
0,
|
||||
hostname,
|
||||
&[hostname],
|
||||
client.tls_connection().peer_certificates(),
|
||||
) {
|
||||
tx.send(DeliveryStage::DaneVerifyError {
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
tx.send(DeliveryStage::DaneVerifySuccess).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Say EHLO again (some SMTP servers require this)
|
||||
tx.send(DeliveryStage::EhloStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
match tokio::time::timeout(timeout, async {
|
||||
client
|
||||
.stream
|
||||
.write_all(format!("EHLO {local_host}\r\n",).as_bytes())
|
||||
.await?;
|
||||
client.stream.flush().await?;
|
||||
client.read_ehlo().await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
tx.send(DeliveryStage::EhloSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
tx.send(DeliveryStage::EhloError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
tx.send(DeliveryStage::EhloError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "Timed out reading response".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify recipient
|
||||
let mut is_success = email.is_none();
|
||||
if let Some(email) = &email {
|
||||
// MAIL FROM
|
||||
tx.send(DeliveryStage::MailFromStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
match client.cmd(b"MAIL FROM:<>\r\n").await.and_then(|r| {
|
||||
if r.is_positive_completion() {
|
||||
Ok(r)
|
||||
} else {
|
||||
Err(ClientError::UnexpectedReply(Box::new(r)))
|
||||
}
|
||||
}) {
|
||||
Ok(_) => {
|
||||
tx.send(DeliveryStage::MailFromSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// RCPT TO
|
||||
tx.send(DeliveryStage::RcptToStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
match client
|
||||
.cmd(format!("RCPT TO:<{email}>\r\n").as_bytes())
|
||||
.await
|
||||
.and_then(|r| {
|
||||
if r.is_positive_completion() {
|
||||
Ok(r)
|
||||
} else {
|
||||
Err(ClientError::UnexpectedReply(Box::new(r)))
|
||||
}
|
||||
}) {
|
||||
Ok(_) => {
|
||||
is_success = true;
|
||||
tx.send(DeliveryStage::RcptToSuccess {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(DeliveryStage::RcptToError {
|
||||
reason: err.to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(DeliveryStage::MailFromError {
|
||||
reason: err.to_string(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QUIT
|
||||
tx.send(DeliveryStage::QuitStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
client.quit().await;
|
||||
tx.send(DeliveryStage::QuitCompleted {
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
if is_success {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(DeliveryStage::ConnectionError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod diagnose;
|
||||
|
||||
use crate::{
|
||||
api::diagnose::{DeliveryStage, spawn_delivery_diagnose},
|
||||
auth::{
|
||||
authenticate::Authenticator, oauth::auth::OAuthApiHandler, permissions::AccountApiHandler,
|
||||
},
|
||||
};
|
||||
use common::{
|
||||
Server,
|
||||
auth::{AccessToken, oauth::GrantType},
|
||||
manager::application::Resource,
|
||||
};
|
||||
use groupware::calendar::itip::{ItipIngest, RsvpRequest};
|
||||
use http_body_util::{StreamBody, combinators::BoxBody};
|
||||
use http_proto::{
|
||||
HttpRequest, HttpResponse, HttpSessionData, JsonResponse, ToHttpResponse,
|
||||
request::{decode_path_element, fetch_body},
|
||||
};
|
||||
use hyper::{
|
||||
Method, StatusCode,
|
||||
header::{self, CONTENT_ENCODING},
|
||||
};
|
||||
use jmap::api::{ToJmapHttpResponse, ToRequestError};
|
||||
use jmap_proto::error::request::RequestError;
|
||||
use registry::schema::enums::Permission;
|
||||
use std::time::Duration;
|
||||
use utils::url_params::UrlParams;
|
||||
|
||||
pub trait ManagementApi: Sync + Send {
|
||||
fn handle_api_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn management_access_token(
|
||||
&self,
|
||||
req: &HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<AccessToken>> + Send;
|
||||
}
|
||||
|
||||
impl ManagementApi for Server {
|
||||
#[allow(unused_variables)]
|
||||
async fn handle_api_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let is_post = req.method() == Method::POST;
|
||||
let body = if is_post {
|
||||
fetch_body(req, 1024 * 1024, session.session_id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let path = req.uri().path().split('/').skip(2).collect::<Vec<_>>();
|
||||
|
||||
match path.first().copied().unwrap_or_default() {
|
||||
"auth" if is_post => {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
Box::pin(self.handle_login_request(
|
||||
session,
|
||||
body.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?,
|
||||
))
|
||||
.await
|
||||
}
|
||||
"calendar"
|
||||
if is_post
|
||||
&& path.get(1).copied() == Some("rsvp")
|
||||
&& self.core.groupware.itip_http_rsvp_url.is_some() =>
|
||||
{
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
let request = serde_json::from_slice::<RsvpRequest>(
|
||||
&body.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?,
|
||||
)
|
||||
.map_err(|err| {
|
||||
trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err)
|
||||
})?;
|
||||
|
||||
self.http_rsvp_handle(request, accept_language(req), session.remote_ip)
|
||||
.await
|
||||
.map(|response| JsonResponse::new(response).no_cache().into_http_response())
|
||||
}
|
||||
"discover" => {
|
||||
if let Some(email) = path.get(1).copied() {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
self.handle_discover_request(session, decode_path_element(email).as_ref())
|
||||
.await
|
||||
} else {
|
||||
Err(trc::ResourceEvent::NotFound.into_err())
|
||||
}
|
||||
}
|
||||
"account" => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||
self.handle_account_request(&access_token).await
|
||||
}
|
||||
"schema" => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||
static SCHEMA_JSON: &[u8] =
|
||||
include_bytes!("../../../../resources/schema/schema.json.gz");
|
||||
const SCHEMA_HASH: &str =
|
||||
include_str!("../../../../resources/schema/schema.json.sha256");
|
||||
|
||||
if path.get(1).is_some_and(|hash| hash == &SCHEMA_HASH) {
|
||||
Ok(Resource::new("application/json", SCHEMA_JSON.to_vec())
|
||||
.into_http_response()
|
||||
.with_immutable_cache()
|
||||
.with_header(CONTENT_ENCODING, "gzip"))
|
||||
} else {
|
||||
Ok(HttpResponse::redirect(format!("/api/schema/{SCHEMA_HASH}")))
|
||||
}
|
||||
}
|
||||
"token" => {
|
||||
let access_token = self.management_access_token(req, session).await?;
|
||||
let account_id = access_token.account_id();
|
||||
match path.get(1).copied() {
|
||||
Some("delivery") => {
|
||||
// Validate the access token
|
||||
access_token.enforce_permission(Permission::LiveDeliveryTest)?;
|
||||
|
||||
// Issue a live telemetry token valid for 60 seconds
|
||||
Ok(HttpResponse::new(StatusCode::OK)
|
||||
.with_no_cache()
|
||||
.with_text_body(
|
||||
self.encode_access_token(
|
||||
GrantType::LiveDelivery,
|
||||
account_id,
|
||||
self.account(account_id).await?.name(),
|
||||
60,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
Some("tracing") | Some("metrics") => {
|
||||
Err(trc::ResourceEvent::NotFound
|
||||
.ctx(trc::Key::Details, "Enterprise feature"))
|
||||
}
|
||||
_ => Err(trc::ResourceEvent::NotFound.into_err()),
|
||||
}
|
||||
}
|
||||
"live" => {
|
||||
let access_token = self.management_access_token(req, session).await?;
|
||||
let params = UrlParams::new(req.uri().query());
|
||||
let account_id = access_token.account_id();
|
||||
|
||||
match (
|
||||
path.get(1).copied().unwrap_or_default(),
|
||||
path.get(2).copied(),
|
||||
req.method(),
|
||||
) {
|
||||
("delivery", Some(target), &Method::GET) => {
|
||||
// Validate the access token
|
||||
access_token.enforce_permission(Permission::LiveDeliveryTest)?;
|
||||
|
||||
let timeout = Duration::from_secs(
|
||||
params
|
||||
.parse::<u64>("timeout")
|
||||
.filter(|interval| *interval >= 1)
|
||||
.unwrap_or(30),
|
||||
);
|
||||
|
||||
let mut rx = spawn_delivery_diagnose(
|
||||
self.clone(),
|
||||
decode_path_element(target).to_lowercase(),
|
||||
timeout,
|
||||
);
|
||||
|
||||
Ok(HttpResponse::new(StatusCode::OK)
|
||||
.with_content_type("text/event-stream")
|
||||
.with_cache_control("no-store")
|
||||
.with_stream_body(BoxBody::new(StreamBody::new(
|
||||
async_stream::stream! {
|
||||
while let Some(stage) = rx.recv().await {
|
||||
yield Ok(stage.to_frame());
|
||||
}
|
||||
yield Ok(DeliveryStage::Completed.to_frame());
|
||||
},
|
||||
))))
|
||||
}
|
||||
("tracing" | "metrics", _, &Method::GET) => {
|
||||
Err(trc::ResourceEvent::NotFound
|
||||
.ctx(trc::Key::Details, "Enterprise feature"))
|
||||
}
|
||||
_ => Err(trc::ResourceEvent::NotFound.into_err()),
|
||||
}
|
||||
}
|
||||
_ => Err(trc::ResourceEvent::NotFound.into_err()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn management_access_token(
|
||||
&self,
|
||||
req: &HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<AccessToken> {
|
||||
let params = UrlParams::new(req.uri().query());
|
||||
if let Some(token) = params.get("token") {
|
||||
let path = req.uri().path();
|
||||
let grant = if path.starts_with("/api/live/delivery") {
|
||||
Some((GrantType::LiveDelivery, Permission::LiveDeliveryTest))
|
||||
} else {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((grant_type, permission)) = grant {
|
||||
self.validate_access_token(grant_type.into(), token)
|
||||
.await
|
||||
.map(|token_info| {
|
||||
AccessToken::from_permissions(token_info.account_id, [permission])
|
||||
})
|
||||
} else {
|
||||
self.authenticate_headers(req, session)
|
||||
.await
|
||||
.map(|(_, token)| token)
|
||||
}
|
||||
} else {
|
||||
self.authenticate_headers(req, session)
|
||||
.await
|
||||
.map(|(_, token)| token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToManageHttpResponse {
|
||||
fn into_http_response(self, challenge: AuthChallenge) -> HttpResponse;
|
||||
}
|
||||
|
||||
impl ToManageHttpResponse for &trc::Error {
|
||||
fn into_http_response(self, challenge: AuthChallenge) -> HttpResponse {
|
||||
match self.as_ref() {
|
||||
trc::EventType::Auth(
|
||||
trc::AuthEvent::Failed | trc::AuthEvent::Error | trc::AuthEvent::TokenExpired,
|
||||
) => HttpResponse::unauthorized(challenge),
|
||||
_ => self.to_request_error().into_http_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accept_language(req: &HttpRequest) -> &str {
|
||||
req.headers()
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|language| {
|
||||
let language = language.split_once(',').map_or(language, |(l, _)| l);
|
||||
language.split_once(';').map_or(language, |(l, _)| l).trim()
|
||||
})
|
||||
.filter(|language| !language.is_empty())
|
||||
.unwrap_or("en")
|
||||
}
|
||||
|
||||
const BEARER_CHALLENGE: &str = concat!(
|
||||
"Bearer realm=\"Stalwart Server\", ",
|
||||
"resource_metadata=\"/.well-known/oauth-protected-resource\""
|
||||
);
|
||||
const BASIC_CHALLENGE: &str = "Basic realm=\"Stalwart Server\"";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthChallenge {
|
||||
Bearer,
|
||||
BearerAndBasic,
|
||||
}
|
||||
|
||||
pub trait UnauthorizedResponse {
|
||||
fn unauthorized(challenge: AuthChallenge) -> Self;
|
||||
}
|
||||
|
||||
impl UnauthorizedResponse for HttpResponse {
|
||||
fn unauthorized(challenge: AuthChallenge) -> Self {
|
||||
let response = HttpResponse::new(StatusCode::UNAUTHORIZED)
|
||||
.with_header(header::WWW_AUTHENTICATE, BEARER_CHALLENGE);
|
||||
|
||||
if challenge == AuthChallenge::BearerAndBasic {
|
||||
response.with_header(header::WWW_AUTHENTICATE, BASIC_CHALLENGE)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
.with_content_type("application/problem+json")
|
||||
.with_text_body(serde_json::to_string(&RequestError::unauthorized()).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::auth::AccessToken;
|
||||
use common::{HttpAuthCache, Server, auth::AuthRequest, network::limiter::InFlight};
|
||||
use directory::Credentials;
|
||||
use http_proto::{HttpRequest, HttpSessionData};
|
||||
use hyper::header;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub trait Authenticator: Sync + Send {
|
||||
fn authenticate_headers(
|
||||
&self,
|
||||
req: &HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<(Option<InFlight>, AccessToken)>> + Send;
|
||||
}
|
||||
|
||||
impl Authenticator for Server {
|
||||
async fn authenticate_headers(
|
||||
&self,
|
||||
req: &HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<(Option<InFlight>, AccessToken)> {
|
||||
if let Some((mechanism, token)) = req.authorization() {
|
||||
// Check if the credentials are cached
|
||||
if let Some(http_cache) = self.inner.cache.http_auth.get(token) {
|
||||
// Make sure the revision is still valid
|
||||
if http_cache.expires > Instant::now() {
|
||||
let access_token = AccessToken::renew(
|
||||
self.access_token(http_cache.account_id).await?,
|
||||
http_cache.credential_id,
|
||||
session.remote_ip,
|
||||
)?;
|
||||
|
||||
if access_token.revision() == http_cache.revision {
|
||||
// Enforce authenticated rate limit
|
||||
return self
|
||||
.is_http_authenticated_request_allowed(&access_token, session.remote_ip)
|
||||
.await
|
||||
.map(|in_flight| (in_flight, access_token));
|
||||
}
|
||||
}
|
||||
|
||||
// If the revision is not valid, remove the cached credentials
|
||||
self.inner.cache.http_auth.remove(token);
|
||||
}
|
||||
|
||||
let credentials = if mechanism.eq_ignore_ascii_case("basic") {
|
||||
// Decode the base64 encoded credentials
|
||||
decode_plain_auth(token).ok_or_else(|| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Failed to decode Basic auth request.")
|
||||
.id(token.to_string())
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
} else if mechanism.eq_ignore_ascii_case("bearer") {
|
||||
// Enforce anonymous rate limit
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
Credentials::Bearer {
|
||||
username: None,
|
||||
token: token.to_string(),
|
||||
}
|
||||
} else {
|
||||
// Enforce anonymous rate limit
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.reason("Unsupported authentication mechanism.")
|
||||
.details(token.to_string())
|
||||
.caused_by(trc::location!()));
|
||||
};
|
||||
|
||||
// Authenticate
|
||||
let access_token = self
|
||||
.authenticate(&AuthRequest::from_credentials(
|
||||
credentials,
|
||||
session.session_id,
|
||||
session.remote_ip,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Cache credentials
|
||||
self.inner.cache.http_auth.insert(
|
||||
token.into(),
|
||||
HttpAuthCache {
|
||||
account_id: access_token.account_id(),
|
||||
revision: access_token.revision(),
|
||||
credential_id: access_token.credential_id(),
|
||||
expires: Instant::now()
|
||||
+ Duration::from_secs(self.core.oauth.oauth_expiry_token),
|
||||
},
|
||||
);
|
||||
|
||||
// Enforce authenticated rate limit
|
||||
self.is_http_authenticated_request_allowed(&access_token, session.remote_ip)
|
||||
.await
|
||||
.map(|in_flight| (in_flight, access_token))
|
||||
} else {
|
||||
// Enforce anonymous rate limit
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Missing Authorization header.")
|
||||
.caused_by(trc::location!()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HttpHeaders {
|
||||
fn authorization(&self) -> Option<(&str, &str)>;
|
||||
fn authorization_basic(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
impl HttpHeaders for HttpRequest {
|
||||
fn authorization(&self) -> Option<(&str, &str)> {
|
||||
self.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.split_once(' ').map(|(l, t)| (l, t.trim())))
|
||||
}
|
||||
|
||||
fn authorization_basic(&self) -> Option<&str> {
|
||||
self.authorization().and_then(|(l, t)| {
|
||||
if l.eq_ignore_ascii_case("basic") {
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_plain_auth(token: &str) -> Option<Credentials> {
|
||||
base64_decode(token.as_bytes())
|
||||
.and_then(|token| String::from_utf8(token).ok())
|
||||
.and_then(|token| {
|
||||
token
|
||||
.split_once(':')
|
||||
.map(|(login, secret)| Credentials::Basic {
|
||||
username: login.trim().to_lowercase(),
|
||||
secret: secret.to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod authenticate;
|
||||
pub mod oauth;
|
||||
pub mod permissions;
|
||||
@@ -0,0 +1,649 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode, PkceCodeChallenge};
|
||||
use crate::auth::oauth::{
|
||||
OAuthStatus, openid::OpenIdHandler, registration::ClientRegistrationHandler,
|
||||
};
|
||||
use common::{
|
||||
KV_OAUTH, Server,
|
||||
auth::{
|
||||
AuthRequest,
|
||||
authentication::UsernameParts,
|
||||
oauth::{
|
||||
CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, SUPPORTED_SCOPES, USER_CODE_ALPHABET,
|
||||
USER_CODE_LEN,
|
||||
client_id::{decode_client_id, scopes_to_mask},
|
||||
registration::redirect_uri_matches,
|
||||
},
|
||||
},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use http_proto::*;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
Serialize,
|
||||
dispatch::lookup::KeyValue,
|
||||
write::{Archive, Archiver},
|
||||
};
|
||||
use store::{
|
||||
rand::{
|
||||
RngExt,
|
||||
distr::{Alphanumeric, StandardUniform},
|
||||
rng,
|
||||
},
|
||||
write::AlignedBytes,
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ProtectedResourceMetadata {
|
||||
pub resource: String,
|
||||
pub authorization_servers: [String; 1],
|
||||
pub scopes_supported: &'static [&'static str],
|
||||
pub bearer_methods_supported: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct OAuthMetadata {
|
||||
pub issuer: String,
|
||||
pub token_endpoint: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub device_authorization_endpoint: String,
|
||||
pub registration_endpoint: String,
|
||||
pub introspection_endpoint: String,
|
||||
pub grant_types_supported: &'static [&'static str],
|
||||
pub response_types_supported: &'static [&'static str],
|
||||
pub scopes_supported: &'static [&'static str],
|
||||
pub token_endpoint_auth_methods_supported: &'static [&'static str],
|
||||
pub code_challenge_methods_supported: &'static [&'static str],
|
||||
pub authorization_response_iss_parameter_supported: bool,
|
||||
}
|
||||
|
||||
pub trait OAuthApiHandler: Sync + Send {
|
||||
fn handle_discover_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
account_name: &str,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_login_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
body: Vec<u8>,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_device_auth(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oauth_metadata(&self) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oauth_protected_resource(
|
||||
&self,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LoginRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AuthCode {
|
||||
account_name: String,
|
||||
account_secret: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
mfa_token: Option<String>,
|
||||
client_id: String,
|
||||
#[serde(default)]
|
||||
redirect_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
nonce: Option<String>,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
#[serde(default)]
|
||||
code_challenge: Option<String>,
|
||||
#[serde(default)]
|
||||
code_challenge_method: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
resource: Vec<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AuthDevice {
|
||||
account_name: String,
|
||||
account_secret: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
mfa_token: Option<String>,
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LoginResponse {
|
||||
Authenticated { client_code: String, iss: String },
|
||||
Verified,
|
||||
MfaRequired,
|
||||
Failure,
|
||||
}
|
||||
|
||||
impl OAuthApiHandler for Server {
|
||||
async fn handle_discover_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
account_name: &str,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let username = UsernameParts::new(account_name.trim());
|
||||
let auth_as = username.auth_as();
|
||||
let is_recovery_admin = self
|
||||
.registry()
|
||||
.recovery_admin()
|
||||
.is_some_and(|(user, _)| user.trim().eq_ignore_ascii_case(auth_as.address()));
|
||||
|
||||
if !is_recovery_admin
|
||||
&& let Some(domain_name) = auth_as.domain().filter(|domain| !domain.is_empty())
|
||||
&& let Some(endpoint) = self
|
||||
.get_directory_for_domain(domain_name)
|
||||
.await?
|
||||
.and_then(|directory| directory.oidc_discovery_document())
|
||||
{
|
||||
Ok(JsonResponse::new(&endpoint.document)
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
} else {
|
||||
self.handle_oidc_metadata(!session.is_tls).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_login_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
body: Vec<u8>,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let request = serde_json::from_slice::<LoginRequest>(&body).map_err(|err| {
|
||||
trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err)
|
||||
})?;
|
||||
|
||||
let response = match request {
|
||||
LoginRequest::AuthCode {
|
||||
account_name,
|
||||
account_secret,
|
||||
mfa_token,
|
||||
client_id,
|
||||
redirect_uri,
|
||||
nonce,
|
||||
scope,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
resource,
|
||||
..
|
||||
} => {
|
||||
// Validate clientId
|
||||
if client_id.len() > CLIENT_ID_MAX_LEN {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Client ID is too long."));
|
||||
} else if redirect_uri
|
||||
.as_ref()
|
||||
.is_some_and(|uri| uri.starts_with("http://"))
|
||||
{
|
||||
#[cfg(not(feature = "dev_mode"))]
|
||||
if !self.registry().is_recovery_mode() && code_challenge.is_none() {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Redirect URI must be HTTPS."));
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the client and validate the redirect URI against the registration.
|
||||
// Stateless client ids are self-describing; otherwise fall back to the registry.
|
||||
let redirect_uri = redirect_uri.ok_or_else(|| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("A redirect URI is required.")
|
||||
})?;
|
||||
let stateless_client =
|
||||
decode_client_id(self.core.oauth.oauth_key.as_bytes(), &client_id);
|
||||
let granted_scope = match &stateless_client {
|
||||
Some(meta) => {
|
||||
if !meta
|
||||
.redirect_uris
|
||||
.iter()
|
||||
.any(|uri| redirect_uri_matches(uri, &redirect_uri))
|
||||
{
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Redirect URI does not match the client registration."));
|
||||
}
|
||||
grant_scope(scope.as_deref(), meta.scope_mask)
|
||||
}
|
||||
None => scope,
|
||||
};
|
||||
|
||||
// Validate Resource Indicators (RFC 8707)
|
||||
for resource in &resource {
|
||||
if !is_known_resource(
|
||||
[self.core.network.server_name.as_str()]
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.services
|
||||
.values()
|
||||
.filter_map(|v| v.hostname.as_deref()),
|
||||
)
|
||||
.chain(
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.mxs
|
||||
.iter()
|
||||
.filter_map(|mx| mx.hostname.as_deref()),
|
||||
),
|
||||
resource,
|
||||
) {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(format!("Unknown resource indicator: {}", resource)));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate PKCE challenge (RFC 7636).
|
||||
let pkce_challenge = match code_challenge {
|
||||
Some(challenge) => match code_challenge_method.as_deref().unwrap_or("plain") {
|
||||
"S256" => PkceCodeChallenge::S256(challenge),
|
||||
"plain" if stateless_client.is_none() => {
|
||||
PkceCodeChallenge::Plain(challenge)
|
||||
}
|
||||
_ => {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Unsupported PKCE code_challenge_method."));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if stateless_client.is_some() {
|
||||
return Err(trc::AuthEvent::Error.into_err().details(
|
||||
"A PKCE code_challenge with the S256 method is required.",
|
||||
));
|
||||
}
|
||||
PkceCodeChallenge::None
|
||||
}
|
||||
};
|
||||
|
||||
// Authenticate
|
||||
match self
|
||||
.authenticate(&AuthRequest {
|
||||
credentials: Credentials::Basic {
|
||||
username: account_name,
|
||||
secret: account_secret,
|
||||
mfa_token,
|
||||
},
|
||||
session_id: session.session_id,
|
||||
remote_ip: session.remote_ip,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(access_token) => {
|
||||
// Registry-backed clients are validated once the account is known
|
||||
if stateless_client.is_none()
|
||||
&& self
|
||||
.validate_client_registration(
|
||||
&client_id,
|
||||
Some(redirect_uri.as_str()),
|
||||
access_token.account_id(),
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Invalid client registration."));
|
||||
}
|
||||
|
||||
// Generate client code
|
||||
let client_code = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(DEVICE_CODE_LEN)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
// Serialize OAuth code
|
||||
let value = Archiver::new(OAuthCode {
|
||||
status: OAuthStatus::Authorized,
|
||||
account_id: access_token.account_id(),
|
||||
client_id,
|
||||
nonce,
|
||||
params: redirect_uri,
|
||||
code_challenge: pkce_challenge,
|
||||
scope: granted_scope,
|
||||
resources: resource,
|
||||
})
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Insert client code
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(KV_OAUTH, client_code.as_bytes(), value)
|
||||
.expires(self.core.oauth.oauth_expiry_auth_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
LoginResponse::Authenticated {
|
||||
client_code,
|
||||
iss: self.core.network.http.url_https.clone(),
|
||||
}
|
||||
}
|
||||
Err(err) => match *err.as_ref() {
|
||||
trc::EventType::Auth(trc::AuthEvent::MfaRequired) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
LoginResponse::MfaRequired
|
||||
}
|
||||
trc::EventType::Auth(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
LoginResponse::Failure
|
||||
}
|
||||
trc::EventType::Security(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
LoginResponse::Failure
|
||||
}
|
||||
_ => {
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
LoginRequest::AuthDevice {
|
||||
account_name,
|
||||
account_secret,
|
||||
mfa_token,
|
||||
code,
|
||||
} => {
|
||||
// Obtain code
|
||||
let mut result = LoginResponse::Failure;
|
||||
if let Some(auth_code_) = self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let oauth = auth_code_
|
||||
.unarchive::<OAuthCode>()
|
||||
.caused_by(trc::location!())?;
|
||||
if oauth.status == OAuthStatus::Pending {
|
||||
// Authenticate
|
||||
match self
|
||||
.authenticate(&AuthRequest {
|
||||
credentials: Credentials::Basic {
|
||||
username: account_name,
|
||||
secret: account_secret,
|
||||
mfa_token,
|
||||
},
|
||||
session_id: session.session_id,
|
||||
remote_ip: session.remote_ip,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(access_token) => {
|
||||
let new_oauth_code = OAuthCode {
|
||||
status: OAuthStatus::Authorized,
|
||||
account_id: access_token.account_id(),
|
||||
client_id: oauth.client_id.to_string(),
|
||||
nonce: oauth.nonce.as_ref().map(|s| s.to_string()),
|
||||
params: Default::default(),
|
||||
code_challenge: PkceCodeChallenge::None,
|
||||
scope: oauth.scope.as_ref().map(|s| s.to_string()),
|
||||
resources: oauth
|
||||
.resources
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Delete issued user code
|
||||
self.in_memory_store()
|
||||
.key_delete(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Update device code status
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_OAUTH,
|
||||
oauth.params.as_bytes(),
|
||||
Archiver::new(new_oauth_code)
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?,
|
||||
)
|
||||
.expires(self.core.oauth.oauth_expiry_auth_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
result = LoginResponse::Verified;
|
||||
}
|
||||
Err(err) => match *err.as_ref() {
|
||||
trc::EventType::Auth(trc::AuthEvent::MfaRequired) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
result = LoginResponse::MfaRequired;
|
||||
}
|
||||
trc::EventType::Auth(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
result = LoginResponse::Failure;
|
||||
}
|
||||
trc::EventType::Security(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
result = LoginResponse::Failure;
|
||||
}
|
||||
_ => {
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
Ok(JsonResponse::new(response).no_cache().into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_device_auth(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse form
|
||||
let mut form_data = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?;
|
||||
let client_id = form_data
|
||||
.remove("client_id")
|
||||
.filter(|client_id| client_id.len() <= CLIENT_ID_MAX_LEN)
|
||||
.ok_or_else(|| {
|
||||
trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Client ID is missing.")
|
||||
})?;
|
||||
let nonce = form_data.remove("nonce");
|
||||
let scope = form_data
|
||||
.remove("scope")
|
||||
.and_then(|scope| grant_scope(Some(&scope), u64::MAX));
|
||||
|
||||
// Generate device code
|
||||
let device_code = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(DEVICE_CODE_LEN)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
// Generate user code
|
||||
let mut user_code = String::with_capacity(USER_CODE_LEN + 1);
|
||||
for (pos, ch) in rng()
|
||||
.sample_iter(StandardUniform)
|
||||
.take(USER_CODE_LEN)
|
||||
.map(|v: u64| char::from(USER_CODE_ALPHABET[v as usize % USER_CODE_ALPHABET.len()]))
|
||||
.enumerate()
|
||||
{
|
||||
if pos == USER_CODE_LEN / 2 {
|
||||
user_code.push('-');
|
||||
}
|
||||
user_code.push(ch);
|
||||
}
|
||||
|
||||
// Add OAuth status
|
||||
let oauth_code = Archiver::new(OAuthCode {
|
||||
status: OAuthStatus::Pending,
|
||||
account_id: u32::MAX,
|
||||
client_id,
|
||||
nonce,
|
||||
params: device_code.clone(),
|
||||
code_challenge: PkceCodeChallenge::None,
|
||||
scope,
|
||||
resources: Vec::new(),
|
||||
})
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Insert device code
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(KV_OAUTH, device_code.as_bytes(), oauth_code.clone())
|
||||
.expires(self.core.oauth.oauth_expiry_user_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert user code
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(KV_OAUTH, user_code.as_bytes(), oauth_code)
|
||||
.expires(self.core.oauth.oauth_expiry_user_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Build response
|
||||
let base_url = &self.core.network.http.url_https;
|
||||
Ok(JsonResponse::new(DeviceAuthResponse {
|
||||
verification_uri: format!("{base_url}/device"),
|
||||
verification_uri_complete: format!("{base_url}/device/?code={user_code}"),
|
||||
device_code,
|
||||
user_code,
|
||||
expires_in: self.core.oauth.oauth_expiry_user_code,
|
||||
interval: 5,
|
||||
})
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_oauth_metadata(&self) -> trc::Result<HttpResponse> {
|
||||
let base_url = &self.core.network.http.url_https;
|
||||
|
||||
Ok(JsonResponse::new(OAuthMetadata {
|
||||
authorization_endpoint: format!("{base_url}/login",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
device_authorization_endpoint: format!("{base_url}/auth/device"),
|
||||
introspection_endpoint: format!("{base_url}/auth/introspect"),
|
||||
registration_endpoint: format!("{base_url}/auth/register"),
|
||||
grant_types_supported: &[
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
],
|
||||
response_types_supported: &["code"],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
token_endpoint_auth_methods_supported: &[
|
||||
"none",
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
code_challenge_methods_supported: &["S256"],
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
issuer: base_url.to_string(),
|
||||
})
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted())
|
||||
}
|
||||
|
||||
async fn handle_oauth_protected_resource(&self) -> trc::Result<HttpResponse> {
|
||||
let base_url = &self.core.network.http.url_https;
|
||||
|
||||
Ok(JsonResponse::new(ProtectedResourceMetadata {
|
||||
resource: base_url.to_string(),
|
||||
authorization_servers: [base_url.to_string()],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
bearer_methods_supported: &["header"],
|
||||
})
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted())
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_scope(requested: Option<&str>, registered_mask: u64) -> Option<String> {
|
||||
let mut granted = String::new();
|
||||
for scope in requested.unwrap_or_default().split_ascii_whitespace() {
|
||||
let bit = scopes_to_mask(scope);
|
||||
if bit != 0 && registered_mask & bit == bit {
|
||||
if !granted.is_empty() {
|
||||
granted.push(' ');
|
||||
}
|
||||
granted.push_str(scope);
|
||||
}
|
||||
}
|
||||
|
||||
(!granted.is_empty()).then_some(granted)
|
||||
}
|
||||
|
||||
fn is_known_resource<'x>(hostnames: impl IntoIterator<Item = &'x str>, uri: &str) -> bool {
|
||||
let Some((scheme, rest)) = uri.split_once("://") else {
|
||||
return false;
|
||||
};
|
||||
let supported = hashify::tiny_map!(scheme.as_bytes(),
|
||||
b"http" => true,
|
||||
b"https" => true,
|
||||
b"smtp" => true,
|
||||
b"smtps" => true,
|
||||
b"imap" => true,
|
||||
b"imaps" => true,
|
||||
b"pop3" => true,
|
||||
b"pop3s" => true,
|
||||
b"caldav" => true,
|
||||
b"caldavs" => true,
|
||||
b"webdav" => true,
|
||||
b"webdavs" => true,
|
||||
b"carddav" => true,
|
||||
b"carddavs" => true,
|
||||
b"sieve" => true,
|
||||
b"sieves" => true
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
let authority = rest.split_once('/').map_or(rest, |(auth, _)| auth);
|
||||
let host = authority
|
||||
.rsplit_once(':')
|
||||
.filter(|(_, port)| !port.is_empty() && port.as_bytes().iter().all(|c| c.is_ascii_digit()))
|
||||
.map_or(authority, |(host, _)| host);
|
||||
|
||||
supported
|
||||
&& hostnames
|
||||
.into_iter()
|
||||
.any(|hostname| host.eq_ignore_ascii_case(hostname))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use http_proto::{HttpRequest, request::fetch_body};
|
||||
use hyper::header::CONTENT_TYPE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub mod auth;
|
||||
pub mod openid;
|
||||
pub mod registration;
|
||||
pub mod token;
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
)]
|
||||
#[rkyv(compare(PartialEq))]
|
||||
pub enum OAuthStatus {
|
||||
Authorized,
|
||||
TokenIssued,
|
||||
Pending,
|
||||
}
|
||||
|
||||
const MAX_POST_LEN: usize = 2048;
|
||||
|
||||
pub struct OAuth {
|
||||
pub key: String,
|
||||
pub expiry_user_code: u64,
|
||||
pub expiry_auth_code: u64,
|
||||
pub expiry_token: u64,
|
||||
pub expiry_refresh_token: u64,
|
||||
pub expiry_refresh_token_renew: u64,
|
||||
pub max_auth_attempts: u32,
|
||||
pub metadata: String,
|
||||
}
|
||||
|
||||
#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)]
|
||||
pub struct OAuthCode {
|
||||
pub status: OAuthStatus,
|
||||
pub account_id: u32,
|
||||
pub client_id: String,
|
||||
pub nonce: Option<String>,
|
||||
pub params: String,
|
||||
pub code_challenge: PkceCodeChallenge,
|
||||
pub scope: Option<String>,
|
||||
pub resources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Clone,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
)]
|
||||
#[rkyv(compare(PartialEq))]
|
||||
pub enum PkceCodeChallenge {
|
||||
None,
|
||||
S256(String),
|
||||
Plain(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthGet {
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthPost {
|
||||
code: Option<String>,
|
||||
email: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthRequest {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub verification_uri_complete: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CodeAuthRequest {
|
||||
response_type: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
scope: Option<String>,
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CodeAuthForm {
|
||||
code: String,
|
||||
email: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub grant_type: String,
|
||||
pub code: Option<String>,
|
||||
pub device_code: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum TokenResponse {
|
||||
Granted(OAuthResponse),
|
||||
Error { error: ErrorType },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OAuthResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ErrorType {
|
||||
#[serde(rename = "invalid_grant")]
|
||||
InvalidGrant,
|
||||
#[serde(rename = "invalid_client")]
|
||||
InvalidClient,
|
||||
#[serde(rename = "invalid_scope")]
|
||||
InvalidScope,
|
||||
#[serde(rename = "invalid_request")]
|
||||
InvalidRequest,
|
||||
#[serde(rename = "unauthorized_client")]
|
||||
UnauthorizedClient,
|
||||
#[serde(rename = "unsupported_grant_type")]
|
||||
UnsupportedGrantType,
|
||||
#[serde(rename = "authorization_pending")]
|
||||
AuthorizationPending,
|
||||
#[serde(rename = "slow_down")]
|
||||
SlowDown,
|
||||
#[serde(rename = "access_denied")]
|
||||
AccessDenied,
|
||||
#[serde(rename = "expired_token")]
|
||||
ExpiredToken,
|
||||
}
|
||||
|
||||
impl TokenResponse {
|
||||
pub fn error(error: ErrorType) -> Self {
|
||||
TokenResponse::Error { error }
|
||||
}
|
||||
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(self, TokenResponse::Error { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FormData {
|
||||
fields: VecMap<String, String>,
|
||||
}
|
||||
|
||||
impl FormData {
|
||||
pub async fn from_request(
|
||||
req: &mut HttpRequest,
|
||||
max_len: usize,
|
||||
session_id: u64,
|
||||
) -> trc::Result<Self> {
|
||||
match (
|
||||
req.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|val| val.parse::<mime::Mime>().ok()),
|
||||
fetch_body(req, max_len, session_id).await,
|
||||
) {
|
||||
(Some(content_type), Some(body)) => {
|
||||
let mut fields = VecMap::new();
|
||||
if let Some(boundary) = content_type.get_param(mime::BOUNDARY) {
|
||||
for mut field in
|
||||
form_data::FormData::new(&body[..], boundary.as_str()).flatten()
|
||||
{
|
||||
let value = String::from_utf8_lossy(&field.bytes().unwrap_or_default())
|
||||
.into_owned();
|
||||
fields.append(field.name, value);
|
||||
}
|
||||
} else {
|
||||
for (key, value) in http_proto::form_urlencoded::parse(&body) {
|
||||
fields.append(key.into_owned(), value.into_owned());
|
||||
}
|
||||
}
|
||||
Ok(FormData { fields })
|
||||
}
|
||||
_ => Err(trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Invalid post request")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.fields.get(key).map(|v| v.as_str())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<String> {
|
||||
self.fields.remove(key)
|
||||
}
|
||||
|
||||
pub fn has_field(&self, key: &str) -> bool {
|
||||
self.fields.get(key).is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> impl Iterator<Item = (&String, &String)> {
|
||||
self.fields.iter()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::oauth::SUPPORTED_SCOPES, auth::oauth::oidc::Userinfo};
|
||||
use http_proto::*;
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OpenIdMetadata {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub userinfo_endpoint: String,
|
||||
pub jwks_uri: String,
|
||||
pub registration_endpoint: String,
|
||||
pub device_authorization_endpoint: String,
|
||||
pub scopes_supported: &'static [&'static str],
|
||||
pub response_types_supported: &'static [&'static str],
|
||||
pub subject_types_supported: &'static [&'static str],
|
||||
pub grant_types_supported: &'static [&'static str],
|
||||
pub token_endpoint_auth_methods_supported: &'static [&'static str],
|
||||
pub id_token_signing_alg_values_supported: &'static [&'static str],
|
||||
pub claims_supported: &'static [&'static str],
|
||||
pub code_challenge_methods_supported: &'static [&'static str],
|
||||
pub authorization_response_iss_parameter_supported: bool,
|
||||
}
|
||||
|
||||
pub trait OpenIdHandler: Sync + Send {
|
||||
fn handle_userinfo_request(
|
||||
&self,
|
||||
account_id: u32,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oidc_metadata(
|
||||
&self,
|
||||
strip_base_url: bool,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl OpenIdHandler for Server {
|
||||
async fn handle_userinfo_request(&self, account_id: u32) -> trc::Result<HttpResponse> {
|
||||
let account = self.account(account_id).await?;
|
||||
|
||||
Ok(JsonResponse::new(Userinfo {
|
||||
sub: Some(account_id.to_string()),
|
||||
name: account.description().map(|d| d.to_string()),
|
||||
preferred_username: Some(account.name().to_string()),
|
||||
email: account.name().to_string().into(),
|
||||
email_verified: true,
|
||||
..Default::default()
|
||||
})
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_oidc_metadata(&self, strip_base_url: bool) -> trc::Result<HttpResponse> {
|
||||
let base_url = if strip_base_url {
|
||||
#[cfg(feature = "dev_mode")]
|
||||
{
|
||||
"http://127.0.0.1:8080"
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "dev_mode"))]
|
||||
{
|
||||
""
|
||||
}
|
||||
} else {
|
||||
&self.core.network.http.url_https
|
||||
};
|
||||
|
||||
Ok(JsonResponse::new(OpenIdMetadata {
|
||||
authorization_endpoint: format!("{base_url}/login",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
userinfo_endpoint: format!("{base_url}/auth/userinfo"),
|
||||
jwks_uri: format!("{base_url}/auth/jwks.json"),
|
||||
registration_endpoint: format!("{base_url}/auth/register"),
|
||||
device_authorization_endpoint: format!("{base_url}/auth/device"),
|
||||
response_types_supported: &["code"],
|
||||
grant_types_supported: &[
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
subject_types_supported: &["public"],
|
||||
token_endpoint_auth_methods_supported: &[
|
||||
"none",
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
id_token_signing_alg_values_supported: &[
|
||||
"RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "PS384", "PS512", "HS256",
|
||||
"HS384", "HS512",
|
||||
],
|
||||
claims_supported: &[
|
||||
"sub",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"email_verified",
|
||||
],
|
||||
code_challenge_methods_supported: &["S256"],
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
issuer: base_url.to_string(),
|
||||
})
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::ErrorType;
|
||||
use crate::auth::authenticate::Authenticator;
|
||||
use common::{
|
||||
Server,
|
||||
auth::{
|
||||
BuildAccessToken,
|
||||
oauth::{
|
||||
client_id::{ClientMeta, decode_client_id, encode_client_id, scopes_to_mask},
|
||||
registration::{
|
||||
ClientRegistrationError, ClientRegistrationRequest, ClientRegistrationResponse,
|
||||
TokenEndpointAuthMethod, redirect_uri_matches, validate_grant_metadata,
|
||||
validate_redirect_uri,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
use directory::core::secret::{hash_secret, verify_secret_hash};
|
||||
use http_proto::{request::fetch_body, *};
|
||||
use hyper::StatusCode;
|
||||
use registry::schema::{
|
||||
enums::{PasswordHashAlgorithm, Permission},
|
||||
prelude::{ObjectType, Property, UTCDateTime},
|
||||
structs::OAuthClient,
|
||||
};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
rand::{RngExt, distr::Alphanumeric, rng},
|
||||
registry::write::{RegistryWrite, RegistryWriteResult},
|
||||
write::now,
|
||||
};
|
||||
use trc::{AddContext, AuthEvent};
|
||||
use types::id::Id;
|
||||
|
||||
pub trait ClientRegistrationHandler: Sync + Send {
|
||||
fn handle_oauth_registration_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn validate_client_registration(
|
||||
&self,
|
||||
client_id: &str,
|
||||
redirect_uri: Option<&str>,
|
||||
account_id: u32,
|
||||
) -> impl Future<Output = trc::Result<Option<ErrorType>>> + Send;
|
||||
|
||||
fn verify_client_secret(
|
||||
&self,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
) -> impl Future<Output = trc::Result<Option<ErrorType>>> + Send;
|
||||
}
|
||||
impl ClientRegistrationHandler for Server {
|
||||
async fn handle_oauth_registration_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse request
|
||||
let body = fetch_body(req, 20 * 1024, session.session_id).await;
|
||||
let request = serde_json::from_slice::<ClientRegistrationRequest>(
|
||||
body.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err)
|
||||
})?;
|
||||
|
||||
// Validate redirect URIs and grant metadata (RFC 7591 + OAuth Public Clients profile)
|
||||
if request.redirect_uris.is_empty() {
|
||||
return Ok(registration_error(
|
||||
ClientRegistrationError::invalid_redirect_uri(
|
||||
"At least one redirect URI is required.",
|
||||
),
|
||||
));
|
||||
}
|
||||
for uri in &request.redirect_uris {
|
||||
if let Err(err) = validate_redirect_uri(uri) {
|
||||
return Ok(registration_error(err));
|
||||
}
|
||||
}
|
||||
if let Err(err) = validate_grant_metadata(&request) {
|
||||
return Ok(registration_error(err));
|
||||
}
|
||||
|
||||
let is_public = matches!(
|
||||
request.token_endpoint_auth_method,
|
||||
None | Some(TokenEndpointAuthMethod::None)
|
||||
);
|
||||
|
||||
if is_public {
|
||||
// Public client: issue a stateless, self-describing client id with no database write
|
||||
if self.core.oauth.allow_anonymous_client_registration {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
} else {
|
||||
let (_, access_token) = self.authenticate_headers(req, &session).await?;
|
||||
access_token.enforce_permission(Permission::OAuthClientRegistration)?;
|
||||
}
|
||||
|
||||
let client_id = encode_client_id(
|
||||
self.core.oauth.oauth_key.as_bytes(),
|
||||
&ClientMeta {
|
||||
redirect_uris: request.redirect_uris.clone(),
|
||||
scope_mask: scopes_to_mask(request.scope.as_deref().unwrap_or_default()),
|
||||
client_name: request.client_name.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Failed to encode client id.")
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
})?;
|
||||
|
||||
trc::event!(
|
||||
Auth(AuthEvent::ClientRegistration),
|
||||
Id = client_id.clone(),
|
||||
RemoteIp = session.remote_ip
|
||||
);
|
||||
|
||||
return Ok(JsonResponse::with_status(
|
||||
StatusCode::CREATED,
|
||||
ClientRegistrationResponse {
|
||||
client_id_issued_at: Some(now()),
|
||||
client_id,
|
||||
request,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.no_cache()
|
||||
.into_http_response());
|
||||
}
|
||||
|
||||
// Confidential client: authenticate and persist the registration
|
||||
let (_, access_token) = self.authenticate_headers(req, &session).await?;
|
||||
access_token.enforce_permission(Permission::OAuthClientRegistration)?;
|
||||
let tenant_id = access_token.tenant_id();
|
||||
|
||||
// Generate client ID
|
||||
let client_id = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(20)
|
||||
.map(|ch| char::from(ch.to_ascii_lowercase()))
|
||||
.collect::<String>();
|
||||
|
||||
// Generate client secret
|
||||
let client_secret = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(48)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
let secret_hash = hash_secret(
|
||||
PasswordHashAlgorithm::Argon2id,
|
||||
client_secret.clone().into_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let result = self
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(
|
||||
&OAuthClient {
|
||||
client_id: client_id.clone(),
|
||||
description: request.client_name.clone(),
|
||||
contacts: request.contacts.clone().into(),
|
||||
member_tenant_id: tenant_id.map(|id| Id::new(id as u64)),
|
||||
redirect_uris: request.redirect_uris.clone().into(),
|
||||
logo: request.logo_uri.clone(),
|
||||
secret: Some(secret_hash),
|
||||
created_at: UTCDateTime::now(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
if !matches!(result, RegistryWriteResult::Success(_)) {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to register OAuth client.")
|
||||
.reason(result.to_string())
|
||||
.caused_by(trc::location!()));
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Auth(AuthEvent::ClientRegistration),
|
||||
Id = client_id.to_string(),
|
||||
RemoteIp = session.remote_ip
|
||||
);
|
||||
|
||||
Ok(JsonResponse::with_status(
|
||||
StatusCode::CREATED,
|
||||
ClientRegistrationResponse {
|
||||
client_id,
|
||||
client_secret: Some(client_secret),
|
||||
client_id_issued_at: Some(now()),
|
||||
client_secret_expires_at: Some(0),
|
||||
request,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn validate_client_registration(
|
||||
&self,
|
||||
client_id: &str,
|
||||
redirect_uri: Option<&str>,
|
||||
account_id: u32,
|
||||
) -> trc::Result<Option<ErrorType>> {
|
||||
// Stateless client ids are self-describing and validated at the authorization endpoint
|
||||
if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.core.oauth.require_client_authentication {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Fetch client registration
|
||||
let found_registration = if let Some(client_id) = self
|
||||
.registry()
|
||||
.primary_key(
|
||||
ObjectType::OAuthClient.into(),
|
||||
Property::ClientId,
|
||||
client_id.as_bytes().to_vec(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
if let Some(redirect_uri) = redirect_uri {
|
||||
let client = self
|
||||
.registry()
|
||||
.object::<OAuthClient>(client_id.id())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("OAuth client not found.")
|
||||
.caused_by(trc::location!())
|
||||
.ctx(trc::Key::Id, client_id.id().id())
|
||||
})?;
|
||||
if client
|
||||
.redirect_uris
|
||||
.iter()
|
||||
.any(|uri| redirect_uri_matches(uri, redirect_uri))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
// Device flow does not require a redirect URI
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Check if the account is allowed to override client registration
|
||||
if self
|
||||
.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.has_permission(Permission::OAuthClientOverride)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(if found_registration {
|
||||
ErrorType::InvalidClient
|
||||
} else {
|
||||
ErrorType::InvalidRequest
|
||||
}))
|
||||
}
|
||||
|
||||
async fn verify_client_secret(
|
||||
&self,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
) -> trc::Result<Option<ErrorType>> {
|
||||
// Stateless and unregistered clients have no secret to verify
|
||||
if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(client_id) = self
|
||||
.registry()
|
||||
.primary_key(
|
||||
ObjectType::OAuthClient.into(),
|
||||
Property::ClientId,
|
||||
client_id.as_bytes().to_vec(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(client) = self
|
||||
.registry()
|
||||
.object::<OAuthClient>(client_id.id())
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match client.secret.as_deref() {
|
||||
Some(hash) if !hash.is_empty() => match client_secret {
|
||||
Some(secret)
|
||||
if verify_secret_hash(hash, secret.as_bytes())
|
||||
.await
|
||||
.caused_by(trc::location!())? =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
_ => Ok(Some(ErrorType::InvalidClient)),
|
||||
},
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn registration_error(error: ClientRegistrationError) -> HttpResponse {
|
||||
JsonResponse::with_status(StatusCode::BAD_REQUEST, error)
|
||||
.no_cache()
|
||||
.into_http_response()
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{
|
||||
ArchivedOAuthStatus, ArchivedPkceCodeChallenge, ErrorType, FormData, MAX_POST_LEN, OAuthCode,
|
||||
OAuthResponse, OAuthStatus, TokenResponse, registration::ClientRegistrationHandler,
|
||||
};
|
||||
use crate::auth::authenticate::HttpHeaders;
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use common::{
|
||||
KV_OAUTH, Server,
|
||||
auth::{
|
||||
AccessToken,
|
||||
oauth::{GrantType, oidc::StandardClaims},
|
||||
},
|
||||
};
|
||||
use http_proto::*;
|
||||
use hyper::StatusCode;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{borrow::Cow, future::Future};
|
||||
use store::{
|
||||
dispatch::lookup::KeyValue,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
pub trait TokenHandler: Sync + Send {
|
||||
fn handle_token_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_token_introspect(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
access_token: &AccessToken,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn issue_token(
|
||||
&self,
|
||||
account_id: u32,
|
||||
client_id: &str,
|
||||
issuer: String,
|
||||
nonce: Option<String>,
|
||||
scope: Option<String>,
|
||||
with_refresh_token: bool,
|
||||
with_id_token: bool,
|
||||
) -> impl Future<Output = trc::Result<OAuthResponse>> + Send;
|
||||
}
|
||||
|
||||
impl TokenHandler for Server {
|
||||
// Token endpoint
|
||||
async fn handle_token_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse form
|
||||
let params = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?;
|
||||
let grant_type = params.get("grant_type").unwrap_or_default();
|
||||
let (client_id_cred, client_secret_cred) = client_credentials(req, ¶ms);
|
||||
|
||||
let mut response = TokenResponse::error(ErrorType::InvalidGrant);
|
||||
|
||||
let issuer = self.core.network.http.url_https.to_string();
|
||||
|
||||
if grant_type.eq_ignore_ascii_case("authorization_code") {
|
||||
response = if let (Some(code), Some(client_id), Some(redirect_uri)) = (
|
||||
params.get("code"),
|
||||
client_id_cred.as_deref(),
|
||||
params.get("redirect_uri"),
|
||||
) {
|
||||
// Obtain code
|
||||
match self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(auth_code_) => {
|
||||
let oauth = auth_code_
|
||||
.unarchive::<OAuthCode>()
|
||||
.caused_by(trc::location!())?;
|
||||
if client_id != oauth.client_id || redirect_uri != oauth.params {
|
||||
TokenResponse::error(ErrorType::InvalidClient)
|
||||
} else if !verify_pkce(&oauth.code_challenge, params.get("code_verifier")) {
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
} else if oauth.status == OAuthStatus::Authorized {
|
||||
// Validate client id
|
||||
if let Some(error) = self
|
||||
.validate_client_registration(
|
||||
client_id,
|
||||
redirect_uri.into(),
|
||||
oauth.account_id.into(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
TokenResponse::error(error)
|
||||
} else if let Some(error) = self
|
||||
.verify_client_secret(client_id, client_secret_cred.as_deref())
|
||||
.await?
|
||||
{
|
||||
TokenResponse::error(error)
|
||||
} else {
|
||||
// Mark this token as issued
|
||||
self.in_memory_store()
|
||||
.key_delete(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Issue token
|
||||
self.issue_token(
|
||||
oauth.account_id.into(),
|
||||
&oauth.client_id,
|
||||
issuer,
|
||||
oauth.nonce.as_ref().map(|s| s.as_str().into()),
|
||||
oauth.scope.as_ref().map(|s| s.as_str().into()),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(err)
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
}
|
||||
} else {
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
}
|
||||
}
|
||||
None => TokenResponse::error(ErrorType::AccessDenied),
|
||||
}
|
||||
} else {
|
||||
TokenResponse::error(ErrorType::InvalidClient)
|
||||
};
|
||||
} else if grant_type.eq_ignore_ascii_case("urn:ietf:params:oauth:grant-type:device_code") {
|
||||
response = TokenResponse::error(ErrorType::ExpiredToken);
|
||||
|
||||
if let (Some(device_code), Some(client_id)) =
|
||||
(params.get("device_code"), params.get("client_id"))
|
||||
{
|
||||
// Obtain code
|
||||
if let Some(auth_code_) = self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
device_code.as_bytes(),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let oauth = auth_code_
|
||||
.unarchive::<OAuthCode>()
|
||||
.caused_by(trc::location!())?;
|
||||
response = if oauth.client_id != client_id {
|
||||
TokenResponse::error(ErrorType::InvalidClient)
|
||||
} else {
|
||||
match oauth.status {
|
||||
ArchivedOAuthStatus::Authorized => {
|
||||
if let Some(error) = self
|
||||
.validate_client_registration(
|
||||
client_id,
|
||||
None,
|
||||
oauth.account_id.into(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
TokenResponse::error(error)
|
||||
} else {
|
||||
// Mark this token as issued
|
||||
self.in_memory_store()
|
||||
.key_delete(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
device_code.as_bytes(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Issue token
|
||||
self.issue_token(
|
||||
oauth.account_id.into(),
|
||||
&oauth.client_id,
|
||||
issuer,
|
||||
oauth.nonce.as_ref().map(|s| s.as_str().into()),
|
||||
oauth.scope.as_ref().map(|s| s.as_str().into()),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(err)
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
}
|
||||
}
|
||||
ArchivedOAuthStatus::Pending => {
|
||||
TokenResponse::error(ErrorType::AuthorizationPending)
|
||||
}
|
||||
ArchivedOAuthStatus::TokenIssued => {
|
||||
TokenResponse::error(ErrorType::ExpiredToken)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if grant_type.eq_ignore_ascii_case("refresh_token") {
|
||||
if let Some(refresh_token) = params.get("refresh_token") {
|
||||
if let Some(client_id) = client_id_cred.as_deref()
|
||||
&& let Some(error) = self
|
||||
.verify_client_secret(client_id, client_secret_cred.as_deref())
|
||||
.await?
|
||||
{
|
||||
return Ok(JsonResponse::with_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
TokenResponse::error(error),
|
||||
)
|
||||
.into_http_response());
|
||||
}
|
||||
response = match self
|
||||
.validate_access_token(GrantType::RefreshToken.into(), refresh_token)
|
||||
.await
|
||||
{
|
||||
Ok(token_info) => self
|
||||
.issue_token(
|
||||
token_info.account_id,
|
||||
"",
|
||||
issuer,
|
||||
None,
|
||||
None,
|
||||
token_info.expires_in
|
||||
<= self.core.oauth.oauth_expiry_refresh_token_renew,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(err)
|
||||
.caused_by(trc::location!())
|
||||
})?,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to validate refresh token")
|
||||
.span_id(session.session_id)
|
||||
);
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
response = TokenResponse::error(ErrorType::InvalidRequest);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(JsonResponse::with_status(
|
||||
if response.is_error() {
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
StatusCode::OK
|
||||
},
|
||||
response,
|
||||
)
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_token_introspect(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
access_token: &AccessToken,
|
||||
session_id: u64,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse token
|
||||
let token = FormData::from_request(req, 1024, session_id)
|
||||
.await?
|
||||
.remove("token")
|
||||
.ok_or_else(|| {
|
||||
trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Client ID is missing.")
|
||||
})?;
|
||||
|
||||
self.introspect_access_token(&token, access_token)
|
||||
.await
|
||||
.map(|response| JsonResponse::new(response).no_cache().into_http_response())
|
||||
}
|
||||
|
||||
async fn issue_token(
|
||||
&self,
|
||||
account_id: u32,
|
||||
client_id: &str,
|
||||
issuer: String,
|
||||
nonce: Option<String>,
|
||||
scope: Option<String>,
|
||||
with_refresh_token: bool,
|
||||
with_id_token: bool,
|
||||
) -> trc::Result<OAuthResponse> {
|
||||
let credential_version = self
|
||||
.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.credential_version();
|
||||
let account = self.account(account_id).await.caused_by(trc::location!())?;
|
||||
let account_name = account.name();
|
||||
|
||||
Ok(OAuthResponse {
|
||||
access_token: self
|
||||
.encode_access_token(
|
||||
GrantType::AccessToken,
|
||||
account_id,
|
||||
account_name,
|
||||
self.core.oauth.oauth_expiry_token,
|
||||
None,
|
||||
credential_version.into(),
|
||||
)
|
||||
.await?,
|
||||
token_type: "bearer".to_string(),
|
||||
expires_in: self.core.oauth.oauth_expiry_token,
|
||||
refresh_token: if with_refresh_token {
|
||||
self.encode_access_token(
|
||||
GrantType::RefreshToken,
|
||||
account_id,
|
||||
account_name,
|
||||
self.core.oauth.oauth_expiry_refresh_token,
|
||||
None,
|
||||
credential_version.into(),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
id_token: if with_id_token {
|
||||
match self.issue_id_token(
|
||||
account_id.to_string(),
|
||||
issuer,
|
||||
client_id,
|
||||
StandardClaims {
|
||||
nonce,
|
||||
preferred_username: account.name().to_string().into(),
|
||||
email: account.name().to_string().into(),
|
||||
description: account.description().map(|d| d.to_string()),
|
||||
},
|
||||
) {
|
||||
Ok(id_token) => Some(id_token),
|
||||
Err(err) => {
|
||||
trc::error!(err);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn client_credentials<'x>(
|
||||
req: &'x HttpRequest,
|
||||
params: &'x FormData,
|
||||
) -> (Option<Cow<'x, str>>, Option<Cow<'x, str>>) {
|
||||
let mut client_id = params.get("client_id").map(Cow::Borrowed);
|
||||
let mut client_secret = params.get("client_secret").map(Cow::Borrowed);
|
||||
|
||||
if (client_id.is_none() || client_secret.is_none())
|
||||
&& let Some((id, secret)) = req
|
||||
.authorization_basic()
|
||||
.and_then(|token| STANDARD.decode(token).ok())
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.and_then(|creds| {
|
||||
creds
|
||||
.split_once(':')
|
||||
.map(|(id, secret)| (id.to_string(), secret.to_string()))
|
||||
})
|
||||
{
|
||||
if client_id.is_none() {
|
||||
client_id = Some(Cow::Owned(id));
|
||||
}
|
||||
if client_secret.is_none() {
|
||||
client_secret = Some(Cow::Owned(secret));
|
||||
}
|
||||
}
|
||||
|
||||
(client_id, client_secret)
|
||||
}
|
||||
|
||||
fn verify_pkce(stored: &ArchivedPkceCodeChallenge, verifier: Option<&str>) -> bool {
|
||||
let is_valid_pkce_challenge = |challenge: &str| {
|
||||
(43..=128).contains(&challenge.len())
|
||||
&& challenge
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'))
|
||||
};
|
||||
let constant_time_eq = |a: &[u8], b: &[u8]| {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff: u8 = 0;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
};
|
||||
|
||||
match (stored, verifier) {
|
||||
(ArchivedPkceCodeChallenge::None, None) => true,
|
||||
(ArchivedPkceCodeChallenge::Plain(expected), Some(verifier))
|
||||
if is_valid_pkce_challenge(verifier) =>
|
||||
{
|
||||
constant_time_eq(expected.as_bytes(), verifier.as_bytes())
|
||||
}
|
||||
(ArchivedPkceCodeChallenge::S256(expected), Some(verifier))
|
||||
if is_valid_pkce_challenge(verifier) =>
|
||||
{
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
let computed = URL_SAFE_NO_PAD.encode(digest);
|
||||
constant_time_eq(expected.as_bytes(), computed.as_bytes())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{
|
||||
Server,
|
||||
auth::{AccessToken, RECOVERY_ADMIN_ID, permissions::PermissionsListBuilder},
|
||||
};
|
||||
use http_proto::{HttpResponse, JsonResponse, ToHttpResponse};
|
||||
use registry::{
|
||||
schema::enums::{Locale, Permission},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use utils::DomainPart;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Account {
|
||||
pub permissions: Vec<Permission>,
|
||||
pub edition: &'static str,
|
||||
pub locale: Locale,
|
||||
}
|
||||
|
||||
pub trait AccountApiHandler: Sync + Send {
|
||||
fn handle_account_request(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl AccountApiHandler for Server {
|
||||
async fn handle_account_request(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let edition = "oss";
|
||||
|
||||
|
||||
let account_info = self.account_info(access_token.account_id()).await?;
|
||||
let is_external_directory = if let Some(domain_name) = account_info.name().try_domain_part()
|
||||
&& self.get_directory_for_domain(domain_name).await?.is_some()
|
||||
{
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let is_recovery_admin = access_token.account_id() == RECOVERY_ADMIN_ID;
|
||||
let permissions = if !self.registry().is_bootstrap_mode()
|
||||
&& let Some(scope) = access_token.access_scope()
|
||||
{
|
||||
let mut permissions = scope.permissions.clone();
|
||||
|
||||
for p in [
|
||||
Permission::SysDmarcInternalReportUpdate,
|
||||
Permission::SysDmarcInternalReportCreate,
|
||||
Permission::SysDmarcExternalReportUpdate,
|
||||
Permission::SysDmarcExternalReportCreate,
|
||||
Permission::SysTlsInternalReportUpdate,
|
||||
Permission::SysTlsInternalReportCreate,
|
||||
Permission::SysTlsExternalReportUpdate,
|
||||
Permission::SysTlsExternalReportCreate,
|
||||
Permission::SysArfExternalReportCreate,
|
||||
Permission::SysArfExternalReportUpdate,
|
||||
Permission::SysQueuedMessageCreate,
|
||||
Permission::SysLogCreate,
|
||||
Permission::SysLogDestroy,
|
||||
Permission::SysLogUpdate,
|
||||
Permission::SysClusterNodeCreate,
|
||||
Permission::SysClusterNodeUpdate,
|
||||
Permission::SysClusterNodeDestroy,
|
||||
Permission::SysBootstrapGet,
|
||||
Permission::SysBootstrapUpdate,
|
||||
] {
|
||||
permissions.clear(p.to_id() as usize);
|
||||
}
|
||||
|
||||
if !self.core.groupware.allow_directory_query {
|
||||
for p in [
|
||||
Permission::JmapPrincipalQuery,
|
||||
Permission::JmapPrincipalQueryChanges,
|
||||
Permission::JmapPrincipalGet,
|
||||
Permission::JmapPrincipalGetAvailability,
|
||||
Permission::JmapPrincipalChanges,
|
||||
] {
|
||||
permissions.clear(p.to_id() as usize);
|
||||
}
|
||||
}
|
||||
|
||||
if is_external_directory || is_recovery_admin {
|
||||
permissions.clear(Permission::SysAccountPasswordGet.to_id() as usize);
|
||||
permissions.clear(Permission::SysAccountPasswordUpdate.to_id() as usize);
|
||||
}
|
||||
|
||||
if is_recovery_admin {
|
||||
for p in [
|
||||
Permission::SysAccountSettingsGet,
|
||||
Permission::SysAccountSettingsUpdate,
|
||||
Permission::SysApiKeyCreate,
|
||||
Permission::SysApiKeyUpdate,
|
||||
Permission::SysApiKeyDestroy,
|
||||
Permission::SysApiKeyQuery,
|
||||
Permission::SysApiKeyGet,
|
||||
Permission::SysAppPasswordCreate,
|
||||
Permission::SysAppPasswordUpdate,
|
||||
Permission::SysAppPasswordDestroy,
|
||||
Permission::SysAppPasswordQuery,
|
||||
Permission::SysAppPasswordGet,
|
||||
] {
|
||||
permissions.clear(p.to_id() as usize);
|
||||
}
|
||||
}
|
||||
|
||||
permissions.build_permissions_list()
|
||||
} else if self.registry().is_bootstrap_mode() {
|
||||
vec![Permission::SysBootstrapGet, Permission::SysBootstrapUpdate]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(JsonResponse::new(Account {
|
||||
permissions,
|
||||
edition,
|
||||
locale: account_info.locale(),
|
||||
})
|
||||
.into_http_response())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::auth::oauth::FormData;
|
||||
use chrono::Utc;
|
||||
use common::{
|
||||
KV_RATE_LIMIT_CONTACT, Server,
|
||||
config::network::{ContactForm, FieldOrDefault},
|
||||
network::ip_to_bytes,
|
||||
psl,
|
||||
};
|
||||
use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery};
|
||||
use http_proto::*;
|
||||
use hyper::StatusCode;
|
||||
use mail_auth::common::cache::NoCache;
|
||||
use mail_builder::{
|
||||
MessageBuilder,
|
||||
headers::{
|
||||
HeaderType,
|
||||
address::{Address, EmailAddress},
|
||||
},
|
||||
mime::make_boundary,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{borrow::Cow, fmt::Write, future::Future};
|
||||
use store::write::BatchBuilder;
|
||||
use trc::AddContext;
|
||||
|
||||
pub trait FormHandler: Sync + Send {
|
||||
fn handle_contact_form(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
form: &ContactForm,
|
||||
form_data: FormData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl FormHandler for Server {
|
||||
async fn handle_contact_form(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
form: &ContactForm,
|
||||
form_data: FormData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Validate rate
|
||||
if let Some(rate) = &form.rate
|
||||
&& !session.remote_ip.is_loopback()
|
||||
&& self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(
|
||||
KV_RATE_LIMIT_CONTACT,
|
||||
&ip_to_bytes(&session.remote_ip),
|
||||
rate,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.is_some()
|
||||
{
|
||||
return Err(trc::LimitEvent::TooManyRequests.into_err());
|
||||
}
|
||||
|
||||
// Validate honeypot
|
||||
if form
|
||||
.field_honey_pot
|
||||
.as_ref()
|
||||
.is_some_and(|field| form_data.has_field(field))
|
||||
{
|
||||
return Err(trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Honey pot field present"));
|
||||
}
|
||||
|
||||
// Obtain fields
|
||||
let from_email = form_data
|
||||
.get_or_default(&form.from_email)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let from_subject = form_data.get_or_default(&form.from_subject).trim();
|
||||
let from_name = form_data.get_or_default(&form.from_name).trim();
|
||||
|
||||
// Validate email
|
||||
let mut failure = None;
|
||||
let mut has_success = false;
|
||||
if form.validate_domain && from_email != form.from_email.default {
|
||||
if let Some(domain) = from_email.rsplit_once('@').and_then(|(local, domain)| {
|
||||
if !local.is_empty()
|
||||
&& domain.contains('.')
|
||||
&& psl::domain(domain.as_bytes()).is_some_and(|d| d.suffix().typ().is_some())
|
||||
{
|
||||
Some(domain)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
if self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.mx_lookup(domain, None::<&NoCache<_, _>>)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
failure = Some(format!("No MX records found for domain {domain:?}. Please enter a valid email address.", ).into());
|
||||
}
|
||||
} else {
|
||||
failure = Some(Cow::Borrowed("Please enter a valid email address."));
|
||||
}
|
||||
}
|
||||
|
||||
// Discard empty forms
|
||||
if failure.is_none() && form_data.fields().all(|(_, value)| value.trim().is_empty()) {
|
||||
failure = Some(Cow::Borrowed("Empty form"));
|
||||
}
|
||||
|
||||
if failure.is_none() {
|
||||
// Build body
|
||||
let mut body = String::with_capacity(1024);
|
||||
for (field, value) in form_data.fields() {
|
||||
if !value.is_empty() {
|
||||
body.push_str(field);
|
||||
body.push_str(": ");
|
||||
body.push_str(value);
|
||||
body.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
let _ = write!(
|
||||
&mut body,
|
||||
"Date: {}\r\n",
|
||||
Utc::now().format("%a, %d %b %Y %T %z")
|
||||
);
|
||||
let _ = write!(
|
||||
&mut body,
|
||||
"IP: {}:{}\r\n",
|
||||
session.remote_ip, session.remote_port
|
||||
);
|
||||
|
||||
// Build message
|
||||
let message = MessageBuilder::new()
|
||||
.from((from_name, from_email.as_str()))
|
||||
.header(
|
||||
"To",
|
||||
HeaderType::Address(Address::List(
|
||||
form.rcpt_to
|
||||
.iter()
|
||||
.map(|rcpt| {
|
||||
Address::Address(EmailAddress {
|
||||
name: None,
|
||||
email: rcpt.into(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
)
|
||||
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
|
||||
.message_id(format!(
|
||||
"{}@{}",
|
||||
make_boundary("."),
|
||||
self.core.network.server_name
|
||||
))
|
||||
.subject(from_subject)
|
||||
.text_body(body)
|
||||
.write_to_vec()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Reserve and write blob
|
||||
let (message_blob, blob_hold) = self
|
||||
.put_temporary_blob(u32::MAX, &message, 60)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for result in self
|
||||
.deliver_message(IngestMessage {
|
||||
sender_address: from_email,
|
||||
sender_authenticated: false,
|
||||
recipients: form
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|address| IngestRecipient {
|
||||
address: address.clone(),
|
||||
orcpt: None,
|
||||
spam_percentage: None,
|
||||
})
|
||||
.collect(),
|
||||
message_blob,
|
||||
message_size: message.len() as u64,
|
||||
session_id: session.session_id,
|
||||
})
|
||||
.await
|
||||
.status
|
||||
{
|
||||
match result {
|
||||
LocalDeliveryStatus::Success => {
|
||||
has_success = true;
|
||||
}
|
||||
LocalDeliveryStatus::TemporaryFailure { reason }
|
||||
| LocalDeliveryStatus::PermanentFailure { reason, .. } => {
|
||||
failure = Some(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove blob hold
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(blob_hold);
|
||||
self.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Suppress errors if there is at least one success
|
||||
if has_success {
|
||||
failure = None;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(JsonResponse::with_status(
|
||||
if has_success {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
},
|
||||
json!({
|
||||
"data": {
|
||||
"success": has_success,
|
||||
"details": failure,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.into_http_response())
|
||||
}
|
||||
}
|
||||
|
||||
impl FormData {
|
||||
pub fn get_or_default<'x>(&'x self, field: &'x FieldOrDefault) -> &'x str {
|
||||
if let Some(field_name) = &field.field {
|
||||
self.get(field_name)
|
||||
.filter(|f| !f.is_empty())
|
||||
.unwrap_or(field.default.as_str())
|
||||
} else {
|
||||
field.default.as_str()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
#![warn(clippy::large_futures)]
|
||||
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod form;
|
||||
pub mod request;
|
||||
|
||||
use common::Inner;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HttpSessionManager {
|
||||
pub inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl HttpSessionManager {
|
||||
pub fn new(inner: Arc<Inner>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
HttpSessionManager,
|
||||
api::{AuthChallenge, ManagementApi, ToManageHttpResponse},
|
||||
auth::{
|
||||
authenticate::{Authenticator, HttpHeaders},
|
||||
oauth::{
|
||||
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
||||
registration::ClientRegistrationHandler, token::TokenHandler,
|
||||
},
|
||||
},
|
||||
form::FormHandler,
|
||||
};
|
||||
use common::{
|
||||
BuildServer, Inner, KV_ACME, Server,
|
||||
ipc::PushEvent,
|
||||
manager::application::Resource,
|
||||
network::{SessionData, SessionManager, SessionStream},
|
||||
};
|
||||
use dav::{DavMethod, request::DavRequestHandler};
|
||||
use groupware::DavResourceName;
|
||||
use http_proto::{
|
||||
DownloadResponse, HttpContext, HttpRequest, HttpResponse, HttpResponseBody, HttpSessionData,
|
||||
JsonProblemResponse, ToHttpResponse, form_urlencoded, request::fetch_body,
|
||||
};
|
||||
use hyper::{
|
||||
Method, StatusCode, body,
|
||||
header::{self, CONTENT_ENCODING, CONTENT_TYPE},
|
||||
server::conn::http1,
|
||||
service::service_fn,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use jmap::{
|
||||
api::{
|
||||
ToJmapHttpResponse, event_source::EventSourceHandler, request::RequestHandler,
|
||||
session::SessionHandler,
|
||||
},
|
||||
blob::{download::BlobDownload, upload::BlobUpload},
|
||||
websocket::upgrade::WebSocketUpgrade,
|
||||
};
|
||||
use jmap_proto::request::{Request, capability::Session};
|
||||
use percent_encoding::percent_decode_str;
|
||||
use registry::schema::enums::Permission;
|
||||
use std::{net::IpAddr, str::FromStr, sync::Arc};
|
||||
use store::dispatch::lookup::KeyValue;
|
||||
use trc::SecurityEvent;
|
||||
use types::{blob::BlobId, id::Id};
|
||||
|
||||
static RSVP_PAGE: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/html-templates/calendar-rsvp.html.min.gz"
|
||||
));
|
||||
|
||||
static LOGIN_PAGE: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/html-templates/login.html.min.gz"
|
||||
));
|
||||
|
||||
pub trait ParseHttp: Sync + Send {
|
||||
fn parse_http_request(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl ParseHttp for Server {
|
||||
async fn parse_http_request(
|
||||
&self,
|
||||
mut req: HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let mut path = req.uri().path().split('/');
|
||||
path.next();
|
||||
|
||||
// Validate endpoint access
|
||||
let ctx = HttpContext::new(&session, &req);
|
||||
match ctx.has_endpoint_access(self).await {
|
||||
StatusCode::OK => (),
|
||||
status => {
|
||||
// Allow loopback address to avoid lockouts
|
||||
if !session.remote_ip.is_loopback() {
|
||||
return Ok(JsonProblemResponse(status).into_http_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match path.next().unwrap_or_default() {
|
||||
"jmap" => {
|
||||
match (path.next().unwrap_or_default(), req.method()) {
|
||||
("", &Method::POST) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
if let Some(content_type) = req.headers().get(CONTENT_TYPE) {
|
||||
let is_json = content_type
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|ct| {
|
||||
ct.split_once(';')
|
||||
.map_or(ct, |(m, _)| m)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("application/json")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_json {
|
||||
return Err(trc::JmapEvent::NotJson
|
||||
.into_err()
|
||||
.details("The Content-Type header must be application/json."));
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = fetch_body(
|
||||
&mut req,
|
||||
if !access_token.has_permission(Permission::UnlimitedUploads) {
|
||||
self.core.jmap.upload_max_size
|
||||
} else {
|
||||
0
|
||||
},
|
||||
session.session_id,
|
||||
)
|
||||
.await
|
||||
.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?;
|
||||
|
||||
return Ok(self
|
||||
.handle_jmap_request(
|
||||
Request::parse(
|
||||
&bytes,
|
||||
self.core.jmap.request_max_calls,
|
||||
self.core.jmap.request_max_size,
|
||||
)?,
|
||||
&access_token,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.into_http_response());
|
||||
}
|
||||
("download", &Method::GET) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
if let (Some(_), Some(blob_id), Some(name)) = (
|
||||
path.next().and_then(|p| Id::from_str(p).ok()),
|
||||
path.next().and_then(BlobId::from_base32),
|
||||
path.next(),
|
||||
) {
|
||||
return match self.blob_download(&blob_id, &access_token).await? {
|
||||
Some(blob) => Ok(DownloadResponse {
|
||||
filename: name.to_string(),
|
||||
content_type: req
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| {
|
||||
form_urlencoded::parse(q.as_bytes())
|
||||
.find(|(k, _)| k == "accept")
|
||||
.map(|(_, v)| v.into_owned())
|
||||
})
|
||||
.unwrap_or("application/octet-stream".to_string()),
|
||||
blob,
|
||||
}
|
||||
.into_http_response()),
|
||||
None => Err(trc::ResourceEvent::NotFound.into_err()),
|
||||
};
|
||||
}
|
||||
}
|
||||
("upload", &Method::POST) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
if let Some(account_id) = path.next().and_then(|p| Id::from_str(p).ok()) {
|
||||
return match fetch_body(
|
||||
&mut req,
|
||||
if !access_token.has_permission(Permission::UnlimitedUploads) {
|
||||
self.core.jmap.upload_max_size
|
||||
} else {
|
||||
0
|
||||
},
|
||||
session.session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(bytes) => Ok(self
|
||||
.blob_upload(
|
||||
account_id,
|
||||
req.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("application/octet-stream"),
|
||||
&bytes,
|
||||
&access_token,
|
||||
)
|
||||
.await?
|
||||
.into_http_response()),
|
||||
None => Err(trc::LimitEvent::SizeUpload.into_err()),
|
||||
};
|
||||
}
|
||||
}
|
||||
("eventsource", &Method::GET) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
return self.handle_event_source(req, access_token).await;
|
||||
}
|
||||
("ws", &Method::GET) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
return self
|
||||
.upgrade_websocket_connection(req, access_token, session)
|
||||
.await;
|
||||
}
|
||||
("session", &Method::GET) => {
|
||||
return if req.headers().contains_key(header::AUTHORIZATION) {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
self.handle_session_resource(
|
||||
self.core.network.http.url_https.to_string(),
|
||||
&access_token,
|
||||
)
|
||||
.await
|
||||
.map(|s| s.into_http_response())
|
||||
} else {
|
||||
Ok(Session::new(
|
||||
&self.core.network.http.url_https,
|
||||
&self.core.jmap.capabilities,
|
||||
)
|
||||
.into_http_response())
|
||||
};
|
||||
}
|
||||
(_, &Method::OPTIONS) => {
|
||||
return Ok(HttpResponse::new(StatusCode::NO_CONTENT));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
"dav" => {
|
||||
let response = match (
|
||||
path.next().and_then(DavResourceName::parse),
|
||||
DavMethod::parse(req.method()),
|
||||
) {
|
||||
(Some(_), Some(DavMethod::OPTIONS)) => HttpResponse::new(StatusCode::OK)
|
||||
.with_header(
|
||||
"DAV",
|
||||
concat!(
|
||||
"1, 2, 3, access-control, extended-mkcol, calendar-access, ",
|
||||
"calendar-auto-schedule, calendar-no-timezone, addressbook"
|
||||
),
|
||||
)
|
||||
.with_header(
|
||||
"Allow",
|
||||
concat!(
|
||||
"OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, MKCALENDAR, ",
|
||||
"MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL"
|
||||
),
|
||||
),
|
||||
(Some(resource), Some(method)) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
self.handle_dav_request(req, access_token, &session, resource, method)
|
||||
.await
|
||||
}
|
||||
(_, None) => HttpResponse::new(StatusCode::METHOD_NOT_ALLOWED),
|
||||
(None, _) => HttpResponse::new(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
".well-known" => match (path.next().unwrap_or_default(), req.method()) {
|
||||
("jmap", &Method::GET) => {
|
||||
return Ok(HttpResponse::new(StatusCode::TEMPORARY_REDIRECT)
|
||||
.with_no_cache()
|
||||
.with_location("/jmap/session"));
|
||||
}
|
||||
("caldav", _) => {
|
||||
return Ok(HttpResponse::new(StatusCode::TEMPORARY_REDIRECT)
|
||||
.with_no_cache()
|
||||
.with_location(DavResourceName::Cal.base_path()));
|
||||
}
|
||||
("carddav", _) => {
|
||||
return Ok(HttpResponse::new(StatusCode::TEMPORARY_REDIRECT)
|
||||
.with_no_cache()
|
||||
.with_location(DavResourceName::Card.base_path()));
|
||||
}
|
||||
("oauth-authorization-server", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self.handle_oauth_metadata().await;
|
||||
}
|
||||
("oauth-protected-resource", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self.handle_oauth_protected_resource().await;
|
||||
}
|
||||
("openid-configuration", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self.handle_oidc_metadata(false).await;
|
||||
}
|
||||
("acme-challenge", &Method::GET) if self.has_acme_http_providers() => {
|
||||
if let Some(token) = path.next() {
|
||||
return match self
|
||||
.in_memory_store()
|
||||
.key_get::<String>(KeyValue::<()>::build_key(KV_ACME, token))
|
||||
.await?
|
||||
{
|
||||
Some(proof) => Ok(Resource::new("text/plain", proof.into_bytes())
|
||||
.into_http_response()),
|
||||
None => Err(trc::ResourceEvent::NotFound.into_err()),
|
||||
};
|
||||
}
|
||||
}
|
||||
("mta-sts.txt", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return if let Some(policy) = &self.core.smtp.session.mta_sts_policy {
|
||||
Ok(Resource::new("text/plain", policy.to_string().into_bytes())
|
||||
.into_http_response())
|
||||
} else {
|
||||
Err(trc::ResourceEvent::NotFound.into_err())
|
||||
};
|
||||
}
|
||||
("user-agent-configuration.json", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
return Ok(Resource::new(
|
||||
"application/json",
|
||||
self.get_pacc_for_domain(
|
||||
req.headers()
|
||||
.get(header::HOST)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|h| h.rsplit_once(':').map_or(h, |(h, _)| h))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
.into_bytes(),
|
||||
)
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted());
|
||||
}
|
||||
("mail-v1.xml", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self
|
||||
.handle_autoconfig_request(req.uri().query())
|
||||
.await
|
||||
.map(|resource| resource.into_http_response());
|
||||
}
|
||||
("autoconfig", &Method::GET)
|
||||
if path.next().unwrap_or_default() == "mail"
|
||||
&& path.next().unwrap_or_default() == "config-v1.1.xml" =>
|
||||
{
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self
|
||||
.handle_autoconfig_request(req.uri().query())
|
||||
.await
|
||||
.map(|resource| resource.into_http_response().with_cors_unrestricted());
|
||||
}
|
||||
(_, &Method::OPTIONS) => {
|
||||
return Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_cors_unrestricted());
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
"auth" => match (path.next().unwrap_or_default(), req.method()) {
|
||||
("device", &Method::POST) => {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self.handle_device_auth(&mut req, &session).await;
|
||||
}
|
||||
("token", &Method::POST) => {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self.handle_token_request(&mut req, session).await;
|
||||
}
|
||||
("introspect", &Method::POST) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
return self
|
||||
.handle_token_introspect(&mut req, &access_token, session.session_id)
|
||||
.await;
|
||||
}
|
||||
("userinfo", &Method::GET) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
return self
|
||||
.handle_userinfo_request(access_token.account_id())
|
||||
.await;
|
||||
}
|
||||
("register", &Method::POST) => {
|
||||
return self
|
||||
.handle_oauth_registration_request(&mut req, session)
|
||||
.await;
|
||||
}
|
||||
("jwks.json", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return Ok(self.core.oauth.oidc_jwks.clone().into_http_response());
|
||||
}
|
||||
(_, &Method::OPTIONS) => {
|
||||
return Ok(HttpResponse::new(StatusCode::NO_CONTENT));
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
"api" => {
|
||||
// Allow CORS preflight requests
|
||||
if req.method() == Method::OPTIONS {
|
||||
return Ok(HttpResponse::new(StatusCode::NO_CONTENT));
|
||||
}
|
||||
|
||||
return Ok(match self.handle_api_request(&mut req, &session).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
let response = err.into_http_response(AuthChallenge::Bearer);
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
response
|
||||
}
|
||||
});
|
||||
}
|
||||
"mail" => {
|
||||
if req.method() == Method::GET
|
||||
&& path.next().unwrap_or_default() == "config-v1.1.xml"
|
||||
{
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self
|
||||
.handle_autoconfig_request(req.uri().query())
|
||||
.await
|
||||
.map(|resource| resource.into_http_response());
|
||||
}
|
||||
}
|
||||
"calendar" => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
if self.core.groupware.itip_http_rsvp_url.is_some()
|
||||
&& req.method() == Method::GET
|
||||
&& path.next().unwrap_or_default() == "rsvp"
|
||||
{
|
||||
|
||||
return Ok(HttpResponse::new(StatusCode::OK)
|
||||
.with_content_type("text/html; charset=utf-8")
|
||||
.with_header(CONTENT_ENCODING, "gzip")
|
||||
.with_binary_body(RSVP_PAGE)
|
||||
.with_no_store());
|
||||
}
|
||||
}
|
||||
"autodiscover" | "Autodiscover" | "AutoDiscover" => {
|
||||
let document_name = path.next().unwrap_or_default();
|
||||
if req.method() == Method::POST
|
||||
&& document_name.eq_ignore_ascii_case("autodiscover.xml")
|
||||
{
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return self
|
||||
.handle_autodiscover_request(
|
||||
fetch_body(&mut req, 8192, session.session_id).await,
|
||||
)
|
||||
.await
|
||||
.map(|resource| resource.into_http_response());
|
||||
} else if document_name.eq_ignore_ascii_case("autodiscover.json") {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
let path_email = path
|
||||
.map(|segment| percent_decode_str(segment).decode_utf8_lossy().into_owned())
|
||||
.find(|segment| segment.contains('@'));
|
||||
|
||||
return self
|
||||
.handle_autodiscover_v2_request(req.uri().query(), path_email.as_deref())
|
||||
.await
|
||||
.map(|result| match result {
|
||||
Ok(resource) => resource.into_http_response(),
|
||||
Err(err) => HttpResponse::new(StatusCode::BAD_REQUEST)
|
||||
.with_content_type("application/json; charset=utf-8")
|
||||
.with_text_body(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
"robots.txt" => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
return Ok(
|
||||
Resource::new("text/plain", b"User-agent: *\nDisallow: /\n".to_vec())
|
||||
.into_http_response(),
|
||||
);
|
||||
}
|
||||
"healthz" => {
|
||||
// Limit anonymous requests
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
match path.next().unwrap_or_default() {
|
||||
"live" => {
|
||||
return Ok(JsonProblemResponse(StatusCode::OK).into_http_response());
|
||||
}
|
||||
"ready" => {
|
||||
return Ok(JsonProblemResponse({
|
||||
if !self.core.storage.data.is_none() {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
})
|
||||
.into_http_response());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
"metrics" => match path.next().unwrap_or_default() {
|
||||
"prometheus" => {
|
||||
if let Some(prometheus) = &self.core.metrics.prometheus {
|
||||
if let Some(auth) = &prometheus.auth
|
||||
&& req
|
||||
.authorization_basic()
|
||||
.is_none_or(|secret| secret != auth)
|
||||
{
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Invalid or missing credentials.")
|
||||
.caused_by(trc::location!()));
|
||||
}
|
||||
|
||||
return Ok(Resource::new(
|
||||
"text/plain; version=0.0.4",
|
||||
self.export_prometheus_metrics().await?.into_bytes(),
|
||||
)
|
||||
.into_http_response());
|
||||
}
|
||||
}
|
||||
"otel" => {
|
||||
// Reserved for future use
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
"form" => {
|
||||
if let Some(form) = &self.core.network.contact_form {
|
||||
match *req.method() {
|
||||
Method::POST => {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
|
||||
let form_data =
|
||||
FormData::from_request(&mut req, form.max_size, session.session_id)
|
||||
.await?;
|
||||
|
||||
return self.handle_contact_form(&session, form, form_data).await;
|
||||
}
|
||||
Method::OPTIONS => {
|
||||
return Ok(HttpResponse::new(StatusCode::NO_CONTENT));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"login" | "device" => {
|
||||
return Ok(HttpResponse::new(StatusCode::OK)
|
||||
.with_content_type("text/html; charset=utf-8")
|
||||
.with_header(CONTENT_ENCODING, "gzip")
|
||||
.with_binary_body(LOGIN_PAGE));
|
||||
}
|
||||
external => {
|
||||
if path.next().is_none() {
|
||||
if !external.is_empty() {
|
||||
return Ok(HttpResponse::redirect(format!("/{external}/")));
|
||||
} else if let Some(url) = &self.core.network.http.redirect_root {
|
||||
return Ok(HttpResponse::redirect(url.clone()));
|
||||
}
|
||||
} else if let Some(resource) = self
|
||||
.inner
|
||||
.data
|
||||
.applications
|
||||
.serve(
|
||||
external,
|
||||
req.uri()
|
||||
.path()
|
||||
.get(external.len() + 2..)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let response = resource.resource.into_http_response();
|
||||
return Ok(if !resource.no_cache {
|
||||
response.with_immutable_cache()
|
||||
} else {
|
||||
response.with_no_cache()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Block dangerous URLs
|
||||
let path = req.uri().path();
|
||||
if self.is_http_banned_path(path, session.remote_ip).await? {
|
||||
trc::event!(
|
||||
Security(SecurityEvent::ScanBan),
|
||||
SpanId = session.session_id,
|
||||
RemoteIp = session.remote_ip,
|
||||
Path = path.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Err(trc::ResourceEvent::NotFound.into_err())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionData<T>) {
|
||||
let _in_flight = session.in_flight;
|
||||
let is_tls = session.stream.is_tls();
|
||||
|
||||
if let Err(http_err) = http1::Builder::new()
|
||||
.keep_alive(true)
|
||||
.serve_connection(
|
||||
TokioIo::new(session.stream),
|
||||
service_fn(|req: hyper::Request<body::Incoming>| {
|
||||
let instance = session.instance.clone();
|
||||
let inner = inner.clone();
|
||||
|
||||
async move {
|
||||
let server = inner.build_server();
|
||||
|
||||
// Obtain remote IP
|
||||
let remote_ip = if !server.core.network.http.use_forwarded {
|
||||
trc::event!(
|
||||
Http(trc::HttpEvent::RequestUrl),
|
||||
SpanId = session.session_id,
|
||||
Url = req.uri().to_string(),
|
||||
);
|
||||
|
||||
session.remote_ip
|
||||
} else if let Some(forwarded_for) = req
|
||||
.headers()
|
||||
.get(header::FORWARDED)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| {
|
||||
let h = h.to_ascii_lowercase();
|
||||
h.split_once("for=").and_then(|(_, rest)| {
|
||||
let mut start_ip = usize::MAX;
|
||||
let mut end_ip = usize::MAX;
|
||||
|
||||
for (pos, ch) in rest.char_indices() {
|
||||
match ch {
|
||||
'0'..='9' | 'a'..='f' | ':' | '.' => {
|
||||
if start_ip == usize::MAX {
|
||||
start_ip = pos;
|
||||
}
|
||||
end_ip = pos;
|
||||
}
|
||||
'"' | '[' | ' ' if start_ip == usize::MAX => {}
|
||||
_ => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rest.get(start_ip..=end_ip)
|
||||
.and_then(|h| h.parse::<IpAddr>().ok())
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
req.headers()
|
||||
.get("X-Forwarded-For")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|h| h.split_once(',').map_or(h, |(ip, _)| ip).trim())
|
||||
.and_then(|h| h.parse::<IpAddr>().ok())
|
||||
})
|
||||
{
|
||||
trc::event!(
|
||||
Http(trc::HttpEvent::RequestUrl),
|
||||
SpanId = session.session_id,
|
||||
RemoteIp = forwarded_for,
|
||||
Url = req.uri().to_string(),
|
||||
);
|
||||
|
||||
forwarded_for
|
||||
} else {
|
||||
trc::event!(
|
||||
Http(trc::HttpEvent::XForwardedMissing),
|
||||
SpanId = session.session_id,
|
||||
);
|
||||
session.remote_ip
|
||||
};
|
||||
|
||||
// Check if the remote IP has been blocked
|
||||
if server.is_ip_blocked(remote_ip) {
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::IpBlocked),
|
||||
ListenerId = instance.id.clone(),
|
||||
RemoteIp = remote_ip,
|
||||
SpanId = session.session_id,
|
||||
);
|
||||
|
||||
return Ok::<_, hyper::Error>(
|
||||
JsonProblemResponse(StatusCode::FORBIDDEN)
|
||||
.into_http_response()
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse HTTP request
|
||||
let response = match Box::pin(server.parse_http_request(
|
||||
req,
|
||||
HttpSessionData {
|
||||
instance,
|
||||
local_ip: session.local_ip,
|
||||
local_port: session.local_port,
|
||||
remote_ip,
|
||||
remote_port: session.remote_port,
|
||||
is_tls,
|
||||
session_id: session.session_id,
|
||||
},
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
let response = err.into_http_response(AuthChallenge::BearerAndBasic);
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
response
|
||||
}
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Http(trc::HttpEvent::ResponseBody),
|
||||
SpanId = session.session_id,
|
||||
Contents = match response.body() {
|
||||
HttpResponseBody::Text(value) =>
|
||||
trc::Value::String(value.as_str().into()),
|
||||
HttpResponseBody::Binary(_) =>
|
||||
trc::Value::String("[binary data]".into()),
|
||||
HttpResponseBody::Stream(_) => trc::Value::String("[stream]".into()),
|
||||
_ => trc::Value::None,
|
||||
},
|
||||
Code = response.status().as_u16(),
|
||||
Size = response.size(),
|
||||
);
|
||||
|
||||
// Build response
|
||||
let mut response = response.build();
|
||||
|
||||
// Add custom headers
|
||||
if !server.core.network.http.response_headers.is_empty() {
|
||||
let headers = response.headers_mut();
|
||||
|
||||
for (header, value) in &server.core.network.http.response_headers {
|
||||
headers.insert(header.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, hyper::Error>(response)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.with_upgrades()
|
||||
.await
|
||||
{
|
||||
if http_err.is_parse() {
|
||||
let server = inner.build_server();
|
||||
if !server.core.network.http.use_forwarded {
|
||||
match server.is_scanner_fail2banned(session.remote_ip).await {
|
||||
Ok(true) => {
|
||||
trc::event!(
|
||||
Security(SecurityEvent::ScanBan),
|
||||
SpanId = session.session_id,
|
||||
RemoteIp = session.remote_ip,
|
||||
Reason = http_err.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(session.session_id)
|
||||
.details("Failed to check for fail2ban")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Http(trc::HttpEvent::Error),
|
||||
SpanId = session.session_id,
|
||||
Reason = http_err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager for HttpSessionManager {
|
||||
fn handle<T: SessionStream>(self, session: SessionData<T>) -> impl Future<Output = ()> + Send {
|
||||
handle_session(self.inner, session)
|
||||
}
|
||||
|
||||
#[allow(clippy::manual_async_fn)]
|
||||
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send {
|
||||
async {
|
||||
let _ = self.inner.ipc.push_tx.send(PushEvent::Stop).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use flate2::read::GzDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
const PAGES: [(&str, &[u8], &str); 2] = [
|
||||
(
|
||||
"calendar-rsvp.html",
|
||||
super::RSVP_PAGE,
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/html-templates/calendar-rsvp.html.min"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"login.html",
|
||||
super::LOGIN_PAGE,
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/html-templates/login.html.min"
|
||||
)),
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn gzipped_static_pages_are_in_sync() {
|
||||
for (name, gzipped, minified) in PAGES {
|
||||
let mut decoded = String::new();
|
||||
GzDecoder::new(gzipped)
|
||||
.read_to_string(&mut decoded)
|
||||
.unwrap_or_else(|err| panic!("{name}.min.gz failed to decompress: {err}"));
|
||||
|
||||
assert_eq!(
|
||||
decoded, minified,
|
||||
"{name}.min.gz is stale, re-run resources/scripts/minify_html.sh --gzip"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user