rename ruma_wrapper to router

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk 2024-06-16 21:32:08 +00:00
parent 64705fa27d
commit 1c0ed91f6f
7 changed files with 4 additions and 3 deletions

261
src/api/router/auth.rs Normal file
View file

@ -0,0 +1,261 @@
use std::collections::BTreeMap;
use axum::RequestPartsExt;
use axum_extra::{
headers::{authorization::Bearer, Authorization},
typed_header::TypedHeaderRejectionReason,
TypedHeader,
};
use http::uri::PathAndQuery;
use ruma::{
api::{client::error::ErrorKind, AuthScheme, Metadata},
CanonicalJsonValue, OwnedDeviceId, OwnedServerName, OwnedUserId, UserId,
};
use tracing::warn;
use super::{request::Request, xmatrix::XMatrix};
use crate::{service::appservice::RegistrationInfo, services, Error, Result};
enum Token {
Appservice(Box<RegistrationInfo>),
User((OwnedUserId, OwnedDeviceId)),
Invalid,
None,
}
pub(super) struct Auth {
pub(super) origin: Option<OwnedServerName>,
pub(super) sender_user: Option<OwnedUserId>,
pub(super) sender_device: Option<OwnedDeviceId>,
pub(super) appservice_info: Option<RegistrationInfo>,
}
pub(super) async fn auth(
request: &mut Request, json_body: &Option<CanonicalJsonValue>, metadata: &Metadata,
) -> Result<Auth> {
let bearer: Option<TypedHeader<Authorization<Bearer>>> = request.parts.extract().await?;
let token = match &bearer {
Some(TypedHeader(Authorization(bearer))) => Some(bearer.token()),
None => request.query.access_token.as_deref(),
};
let token = if let Some(token) = token {
if let Some(reg_info) = services().appservice.find_from_token(token).await {
Token::Appservice(Box::new(reg_info))
} else if let Some((user_id, device_id)) = services().users.find_from_token(token)? {
Token::User((user_id, OwnedDeviceId::from(device_id)))
} else {
Token::Invalid
}
} else {
Token::None
};
if metadata.authentication == AuthScheme::None {
match request.parts.uri.path() {
// TODO: can we check this better?
"/_matrix/client/v3/publicRooms" | "/_matrix/client/r0/publicRooms" => {
if !services()
.globals
.config
.allow_public_room_directory_without_auth
{
match token {
Token::Appservice(_) | Token::User(_) => {
// we should have validated the token above
// already
},
Token::None | Token::Invalid => {
return Err(Error::BadRequest(ErrorKind::MissingToken, "Missing or invalid access token."));
},
}
}
},
_ => {},
};
}
match (metadata.authentication, token) {
(_, Token::Invalid) => Err(Error::BadRequest(
ErrorKind::UnknownToken {
soft_logout: false,
},
"Unknown access token.",
)),
(AuthScheme::AccessToken, Token::Appservice(info)) => Ok(auth_appservice(request, info)?),
(AuthScheme::None | AuthScheme::AccessTokenOptional | AuthScheme::AppserviceToken, Token::Appservice(info)) => {
Ok(Auth {
origin: None,
sender_user: None,
sender_device: None,
appservice_info: Some(*info),
})
},
(AuthScheme::AccessToken, Token::None) => match request.parts.uri.path() {
// TODO: can we check this better?
"/_matrix/client/v3/voip/turnServer" | "/_matrix/client/r0/voip/turnServer" => {
if services().globals.config.turn_allow_guests {
Ok(Auth {
origin: None,
sender_user: None,
sender_device: None,
appservice_info: None,
})
} else {
Err(Error::BadRequest(ErrorKind::MissingToken, "Missing access token."))
}
},
_ => Err(Error::BadRequest(ErrorKind::MissingToken, "Missing access token.")),
},
(
AuthScheme::AccessToken | AuthScheme::AccessTokenOptional | AuthScheme::None,
Token::User((user_id, device_id)),
) => Ok(Auth {
origin: None,
sender_user: Some(user_id),
sender_device: Some(device_id),
appservice_info: None,
}),
(AuthScheme::ServerSignatures, Token::None) => Ok(auth_server(request, json_body).await?),
(AuthScheme::None | AuthScheme::AppserviceToken | AuthScheme::AccessTokenOptional, Token::None) => Ok(Auth {
sender_user: None,
sender_device: None,
origin: None,
appservice_info: None,
}),
(AuthScheme::ServerSignatures, Token::Appservice(_) | Token::User(_)) => Err(Error::BadRequest(
ErrorKind::Unauthorized,
"Only server signatures should be used on this endpoint.",
)),
(AuthScheme::AppserviceToken, Token::User(_)) => Err(Error::BadRequest(
ErrorKind::Unauthorized,
"Only appservice access tokens should be used on this endpoint.",
)),
}
}
fn auth_appservice(request: &Request, info: Box<RegistrationInfo>) -> Result<Auth> {
let user_id = request
.query
.user_id
.clone()
.map_or_else(
|| {
UserId::parse_with_server_name(
info.registration.sender_localpart.as_str(),
services().globals.server_name(),
)
},
UserId::parse,
)
.map_err(|_| Error::BadRequest(ErrorKind::InvalidUsername, "Username is invalid."))?;
if !info.is_user_match(&user_id) {
return Err(Error::BadRequest(ErrorKind::Exclusive, "User is not in namespace."));
}
if !services().users.exists(&user_id)? {
return Err(Error::BadRequest(ErrorKind::forbidden(), "User does not exist."));
}
Ok(Auth {
origin: None,
sender_user: Some(user_id),
sender_device: None,
appservice_info: Some(*info),
})
}
async fn auth_server(request: &mut Request, json_body: &Option<CanonicalJsonValue>) -> Result<Auth> {
if !services().globals.allow_federation() {
return Err(Error::bad_config("Federation is disabled."));
}
let TypedHeader(Authorization(x_matrix)) = request
.parts
.extract::<TypedHeader<Authorization<XMatrix>>>()
.await
.map_err(|e| {
warn!("Missing or invalid Authorization header: {e}");
let msg = match e.reason() {
TypedHeaderRejectionReason::Missing => "Missing Authorization header.",
TypedHeaderRejectionReason::Error(_) => "Invalid X-Matrix signatures.",
_ => "Unknown header-related error",
};
Error::BadRequest(ErrorKind::forbidden(), msg)
})?;
let origin = &x_matrix.origin;
let signatures = BTreeMap::from_iter([(x_matrix.key.clone(), CanonicalJsonValue::String(x_matrix.sig))]);
let signatures = BTreeMap::from_iter([(origin.as_str().to_owned(), CanonicalJsonValue::Object(signatures))]);
let server_destination = services().globals.server_name().as_str().to_owned();
if let Some(destination) = x_matrix.destination.as_ref() {
if destination != &server_destination {
return Err(Error::BadRequest(ErrorKind::forbidden(), "Invalid authorization."));
}
}
let signature_uri = CanonicalJsonValue::String(
request
.parts
.uri
.path_and_query()
.unwrap_or(&PathAndQuery::from_static("/"))
.to_string(),
);
let mut request_map = BTreeMap::from_iter([
(
"method".to_owned(),
CanonicalJsonValue::String(request.parts.method.to_string()),
),
("uri".to_owned(), signature_uri),
("origin".to_owned(), CanonicalJsonValue::String(origin.as_str().to_owned())),
("destination".to_owned(), CanonicalJsonValue::String(server_destination)),
("signatures".to_owned(), CanonicalJsonValue::Object(signatures)),
]);
if let Some(json_body) = json_body {
request_map.insert("content".to_owned(), json_body.clone());
};
let keys_result = services()
.rooms
.event_handler
.fetch_signing_keys_for_server(origin, vec![x_matrix.key.clone()])
.await;
let keys = keys_result.map_err(|e| {
warn!("Failed to fetch signing keys: {e}");
Error::BadRequest(ErrorKind::forbidden(), "Failed to fetch signing keys.")
})?;
let pub_key_map = BTreeMap::from_iter([(origin.as_str().to_owned(), keys)]);
match ruma::signatures::verify_json(&pub_key_map, &request_map) {
Ok(()) => Ok(Auth {
origin: Some(origin.clone()),
sender_user: None,
sender_device: None,
appservice_info: None,
}),
Err(e) => {
warn!("Failed to verify json request from {origin}: {e}\n{request_map:?}");
if request.parts.uri.to_string().contains('@') {
warn!(
"Request uri contained '@' character. Make sure your reverse proxy gives Conduit the raw uri \
(apache: use nocanon)"
);
}
Err(Error::BadRequest(
ErrorKind::forbidden(),
"Failed to verify X-Matrix signatures.",
))
},
}
}

81
src/api/router/handler.rs Normal file
View file

@ -0,0 +1,81 @@
use std::future::Future;
use axum::{
extract::FromRequestParts,
response::IntoResponse,
routing::{on, MethodFilter},
Router,
};
use conduit::Result;
use http::Method;
use ruma::api::IncomingRequest;
use super::{Ruma, RumaResponse};
pub(in super::super) trait RouterExt {
fn ruma_route<H, T>(self, handler: H) -> Self
where
H: RumaHandler<T>;
}
impl RouterExt for Router {
fn ruma_route<H, T>(self, handler: H) -> Self
where
H: RumaHandler<T>,
{
handler.add_routes(self)
}
}
pub(in super::super) trait RumaHandler<T> {
fn add_routes(&self, router: Router) -> Router;
fn add_route(&self, router: Router, path: &str) -> Router;
}
macro_rules! ruma_handler {
( $($tx:ident),* $(,)? ) => {
#[allow(non_snake_case)]
impl<Req, Ret, Fut, Fun, $($tx,)*> RumaHandler<($($tx,)* Ruma<Req>,)> for Fun
where
Req: IncomingRequest + Send + 'static,
Ret: IntoResponse,
Fut: Future<Output = Result<Req::OutgoingResponse, Ret>> + Send,
Fun: FnOnce($($tx,)* Ruma<Req>) -> Fut + Clone + Send + Sync + 'static,
$( $tx: FromRequestParts<()> + Send + 'static, )*
{
fn add_routes(&self, router: Router) -> Router {
Req::METADATA
.history
.all_paths()
.fold(router, |router, path| self.add_route(router, path))
}
fn add_route(&self, router: Router, path: &str) -> Router {
let handle = self.clone();
let method = method_to_filter(&Req::METADATA.method);
let action = |$($tx,)* req| async { handle($($tx,)* req).await.map(RumaResponse) };
router.route(path, on(method, action))
}
}
}
}
ruma_handler!();
ruma_handler!(T1);
ruma_handler!(T1, T2);
ruma_handler!(T1, T2, T3);
ruma_handler!(T1, T2, T3, T4);
const fn method_to_filter(method: &Method) -> MethodFilter {
match *method {
Method::DELETE => MethodFilter::DELETE,
Method::GET => MethodFilter::GET,
Method::HEAD => MethodFilter::HEAD,
Method::OPTIONS => MethodFilter::OPTIONS,
Method::PATCH => MethodFilter::PATCH,
Method::POST => MethodFilter::POST,
Method::PUT => MethodFilter::PUT,
Method::TRACE => MethodFilter::TRACE,
_ => panic!("Unsupported HTTP method"),
}
}

130
src/api/router/mod.rs Normal file
View file

@ -0,0 +1,130 @@
mod auth;
mod handler;
mod request;
mod xmatrix;
use std::{mem, ops::Deref};
use axum::{async_trait, body::Body, extract::FromRequest};
use bytes::{BufMut, BytesMut};
pub(super) use conduit::error::RumaResponse;
use conduit::{debug, debug_warn, trace, warn};
use ruma::{
api::{client::error::ErrorKind, IncomingRequest},
CanonicalJsonValue, OwnedDeviceId, OwnedServerName, OwnedUserId, UserId,
};
pub(super) use self::handler::RouterExt;
use self::{auth::Auth, request::Request};
use crate::{service::appservice::RegistrationInfo, services, Error, Result};
/// Extractor for Ruma request structs
pub(crate) struct Ruma<T> {
/// Request struct body
pub(crate) body: T,
/// Federation server authentication: X-Matrix origin
/// None when not a federation server.
pub(crate) origin: Option<OwnedServerName>,
/// Local user authentication: user_id.
/// None when not an authenticated local user.
pub(crate) sender_user: Option<OwnedUserId>,
/// Local user authentication: device_id.
/// None when not an authenticated local user or no device.
pub(crate) sender_device: Option<OwnedDeviceId>,
/// Appservice authentication; registration info.
/// None when not an appservice.
pub(crate) appservice_info: Option<RegistrationInfo>,
/// Parsed JSON content.
/// None when body is not a valid string
pub(crate) json_body: Option<CanonicalJsonValue>,
}
#[async_trait]
impl<T, S> FromRequest<S, Body> for Ruma<T>
where
T: IncomingRequest,
{
type Rejection = Error;
async fn from_request(request: hyper::Request<Body>, _: &S) -> Result<Self, Self::Rejection> {
let mut request = request::from(request).await?;
let mut json_body = serde_json::from_slice::<CanonicalJsonValue>(&request.body).ok();
let auth = auth::auth(&mut request, &json_body, &T::METADATA).await?;
Ok(Self {
body: make_body::<T>(&mut request, &mut json_body, &auth)?,
origin: auth.origin,
sender_user: auth.sender_user,
sender_device: auth.sender_device,
appservice_info: auth.appservice_info,
json_body,
})
}
}
impl<T> Deref for Ruma<T> {
type Target = T;
fn deref(&self) -> &Self::Target { &self.body }
}
fn make_body<T>(request: &mut Request, json_body: &mut Option<CanonicalJsonValue>, auth: &Auth) -> Result<T>
where
T: IncomingRequest,
{
let body = if let Some(CanonicalJsonValue::Object(json_body)) = json_body {
let user_id = auth.sender_user.clone().unwrap_or_else(|| {
UserId::parse_with_server_name("", services().globals.server_name()).expect("we know this is valid")
});
let uiaa_request = json_body
.get("auth")
.and_then(|auth| auth.as_object())
.and_then(|auth| auth.get("session"))
.and_then(|session| session.as_str())
.and_then(|session| {
services().uiaa.get_uiaa_request(
&user_id,
&auth.sender_device.clone().unwrap_or_else(|| "".into()),
session,
)
});
if let Some(CanonicalJsonValue::Object(initial_request)) = uiaa_request {
for (key, value) in initial_request {
json_body.entry(key).or_insert(value);
}
}
let mut buf = BytesMut::new().writer();
serde_json::to_writer(&mut buf, &json_body).expect("value serialization can't fail");
buf.into_inner().freeze()
} else {
mem::take(&mut request.body)
};
let mut http_request = hyper::Request::builder()
.uri(request.parts.uri.clone())
.method(request.parts.method.clone());
*http_request.headers_mut().unwrap() = request.parts.headers.clone();
let http_request = http_request.body(body).unwrap();
debug!(
"{:?} {:?} {:?}",
http_request.method(),
http_request.uri(),
http_request.headers()
);
trace!("{:?} {:?} {:?}", http_request.method(), http_request.uri(), json_body);
let body = T::try_from_http_request(http_request, &request.path).map_err(|e| {
warn!("try_from_http_request failed: {e:?}",);
debug_warn!("JSON body: {:?}", json_body);
Error::BadRequest(ErrorKind::BadJson, "Failed to deserialize request.")
})?;
Ok(body)
}

49
src/api/router/request.rs Normal file
View file

@ -0,0 +1,49 @@
use std::str;
use axum::{extract::Path, RequestExt, RequestPartsExt};
use bytes::Bytes;
use http::request::Parts;
use ruma::api::client::error::ErrorKind;
use serde::Deserialize;
use crate::{services, Error, Result};
#[derive(Deserialize)]
pub(super) struct QueryParams {
pub(super) access_token: Option<String>,
pub(super) user_id: Option<String>,
}
pub(super) struct Request {
pub(super) path: Path<Vec<String>>,
pub(super) query: QueryParams,
pub(super) body: Bytes,
pub(super) parts: Parts,
}
pub(super) async fn from(request: hyper::Request<axum::body::Body>) -> Result<Request> {
let limited = request.with_limited_body();
let (mut parts, body) = limited.into_parts();
let path: Path<Vec<String>> = parts.extract().await?;
let query = serde_html_form::from_str(parts.uri.query().unwrap_or_default())
.map_err(|_| Error::BadRequest(ErrorKind::Unknown, "Failed to read query parameters"))?;
let max_body_size = services()
.globals
.config
.max_request_size
.try_into()
.expect("failed to convert max request size");
let body = axum::body::to_bytes(body, max_body_size)
.await
.map_err(|_| Error::BadRequest(ErrorKind::TooLarge, "Request body too large"))?;
Ok(Request {
path,
query,
body,
parts,
})
}

61
src/api/router/xmatrix.rs Normal file
View file

@ -0,0 +1,61 @@
use std::str;
use axum_extra::headers::authorization::Credentials;
use ruma::OwnedServerName;
use tracing::debug;
pub(crate) struct XMatrix {
pub(crate) origin: OwnedServerName,
pub(crate) destination: Option<String>,
pub(crate) key: String, // KeyName?
pub(crate) sig: String,
}
impl Credentials for XMatrix {
const SCHEME: &'static str = "X-Matrix";
fn decode(value: &http::HeaderValue) -> Option<Self> {
debug_assert!(
value.as_bytes().starts_with(b"X-Matrix "),
"HeaderValue to decode should start with \"X-Matrix ..\", received = {value:?}",
);
let parameters = str::from_utf8(&value.as_bytes()["X-Matrix ".len()..])
.ok()?
.trim_start();
let mut origin = None;
let mut destination = None;
let mut key = None;
let mut sig = None;
for entry in parameters.split_terminator(',') {
let (name, value) = entry.split_once('=')?;
// It's not at all clear why some fields are quoted and others not in the spec,
// let's simply accept either form for every field.
let value = value
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(value);
// FIXME: Catch multiple fields of the same name
match name {
"origin" => origin = Some(value.try_into().ok()?),
"key" => key = Some(value.to_owned()),
"sig" => sig = Some(value.to_owned()),
"destination" => destination = Some(value.to_owned()),
_ => debug!("Unexpected field `{name}` in X-Matrix Authorization header"),
}
}
Some(Self {
origin: origin?,
key: key?,
sig: sig?,
destination,
})
}
fn encode(&self) -> http::HeaderValue { todo!() }
}