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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+51
View File
@@ -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
+756
View File
@@ -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(())
}
+298
View File
@@ -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())
}
}
+158
View File
@@ -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,
})
})
}
+9
View File
@@ -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;
+649
View File
@@ -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))
}
+242
View File
@@ -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()
}
}
+113
View File
@@ -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())
}
}
+336
View File
@@ -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()
}
+439
View File
@@ -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, &params);
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,
}
}
+130
View File
@@ -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())
}
}
+248
View File
@@ -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()
}
}
}
+26
View File
@@ -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 }
}
}
+882
View File
@@ -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"
);
}
}
}