1
0
Fork 0
mirror of https://gitlab.com/famedly/conduit.git synced 2025-06-27 16:35:59 +00:00

feat: base support

This commit is contained in:
avdb13 2024-07-15 12:24:06 +02:00
parent 139588b64c
commit 67c23d6dd4
9 changed files with 148 additions and 178 deletions

View file

@ -35,7 +35,7 @@ axum = { version = "0.7", default-features = false, features = [
"json",
"matched-path",
], optional = true }
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
axum-extra = { version = "0.9", features = ["cookie", "typed-header"] }
axum-server = { version = "0.6", features = ["tls-rustls"] }
tower = { version = "0.4.13", features = ["util"] }
tower-http = { version = "0.5", features = [
@ -49,15 +49,6 @@ tower-http = { version = "0.5", features = [
"trace",
"util",
] }
# tower-http = { version = "0.5", features = [
# "add-extension",
# "cors",
# "decompression-full",
# "sensitive-headers",
# "set-header",
# "trace",
# "util",
# ] }
tower-service = "0.3"
# Async runtime and utilities
@ -153,11 +144,6 @@ figment = { version = "0.10.8", features = ["env", "toml"] }
# Validating urls in config
url = { version = "2", features = ["serde"] }
# HTML
mas-oidc-client = { git = "https://github.com/matrix-org/matrix-authentication-service", default-features = false }
mas-http = { git = "https://github.com/matrix-org/matrix-authentication-service", features = ["client"] }
maud = { version = "0.26.0", default-features = false, features = ["axum"] }
async-trait = "0.1.68"
tikv-jemallocator = { version = "0.5.0", features = [
"unprefixed_malloc_on_supported_platforms",
@ -190,11 +176,21 @@ optional = true
package = "rust-rocksdb"
version = "0.25"
[dependencies.mas-http]
features = ["client"]
git = "https://github.com/matrix-org/matrix-authentication-service"
rev = "fbc360d1a94ef2ebf63d979bb403228a700f43c8"
[dependencies.mas-oidc-client]
features = []
git = "https://github.com/matrix-org/matrix-authentication-service"
rev = "fbc360d1a94ef2ebf63d979bb403228a700f43c8"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.28", features = ["resource"] }
[features]
default = ["backend_sqlite", "conduit_bin"]
default = ["backend_rocksdb", "backend_sqlite", "conduit_bin", "systemd"]
#backend_sled = ["sled"]
backend_persy = ["parking_lot", "persy"]
backend_sqlite = ["sqlite"]

View file

@ -124,25 +124,6 @@ Identity providers using OAuth such as Github are not supported yet.
| `name` | `string` | The name displayed on fallback pages. | `issuer` |
| `icon` | `Url` OR `MxcUri` | The icon displayed on fallback pages. | N/A |
| `scopes` | `array` | The scopes used to obtain extra claims which can be used for templates. | `["openid"]` |
<!-- | `pkce` | `bool` | | `true` | -->
<!-- | `backchannel_logout` | `bool` | | `true` | -->
<!-- | `unique_claim` | `string` | The key of the claim, used to uniquely identify users | `"sub"` | <!-1- TODO: claim_correlation? -1-> -->
<!-- | `credentials`* | `table` | See [Client Credentials](#client-credentials) | N/A | -->
| `client_id`* | `string` | The provider-supplied, unique ID for the client. | N/A |
| `client_secret`* | `string` | The provider-supplied, unique ID for the client. | N/A |
| `authentication_method`* | `"basic" | "post"` | The method used for client authentication. | N/A |
<!-- TODO -->
<!-- #### Example -->
<!-- ```toml -->
<!-- [global.sso.keycloak] -->
<!-- name = "A Mysterious KeyCloak Server" -->
<!-- icon = "mxc://matrix.org/tuKmXlmbHzYPFmdHafbZHOWj" -->
<!-- issuer = "https://oidc.conduit.rs:8443/realms/dev_team_realm" -->
<!-- scopes = ["openid", "profile"] -->
<!-- ``` -->
<!-- localpart = "userinfo.preferred_username" -->
<!-- displayname = "id_token.name" -->
<!-- avatar_url = "userinfo.picture" -->
<!-- email = "userinfo.email" -->
<!-- msisdn = "userinfo.phone_number" -->
| `authentication_method`* | `"basic" OR "post"` | The method used for client authentication. | N/A |

View file

@ -322,8 +322,6 @@ pub async fn change_password_route(
.ok_or_else(|| Error::BadRequest(ErrorKind::MissingToken, "Missing access token."))?;
let sender_device = body.sender_device.as_ref().expect("user is authenticated");
// if services().users.password_hash(sender_user)? == Some("");
let mut uiaainfo = UiaaInfo {
flows: vec![AuthFlow {
stages: vec![AuthType::Password],

View file

@ -100,6 +100,12 @@ pub async fn upload_signing_keys_route(
let sender_user = body.sender_user.as_ref().expect("user is authenticated");
let sender_device = body.sender_device.as_ref().expect("user is authenticated");
let master_key = services()
.users
.get_master_key(Some(sender_user), sender_user, &|other| {
sender_user == other
})?;
// UIAA
let mut uiaainfo = UiaaInfo {
flows: vec![AuthFlow {
@ -111,11 +117,15 @@ pub async fn upload_signing_keys_route(
auth_error: None,
};
let master_key = services()
.users
.get_master_key(None, sender_user, &|user_id| user_id == sender_user)?;
if let Some(auth) = &body.auth {
if let (Some(master_key), None) = (&body.master_key, master_key) {
services().users.add_cross_signing_keys(
sender_user,
master_key,
&body.self_signing_key,
&body.user_signing_key,
true,
)?;
} else if let Some(auth) = &body.auth {
let (worked, uiaainfo) =
services()
.uiaa
@ -130,20 +140,10 @@ pub async fn upload_signing_keys_route(
.uiaa
.create(sender_user, sender_device, &uiaainfo, &json)?;
return Err(Error::Uiaa(uiaainfo));
} else if master_key.is_some() {
} else {
return Err(Error::BadRequest(ErrorKind::NotJson, "Not json."));
}
if let Some(master_key) = &body.master_key {
services().users.add_cross_signing_keys(
sender_user,
master_key,
&body.self_signing_key,
&body.user_signing_key,
true, // notify so that other users see the new keys
)?;
}
Ok(upload_signing_keys::v3::Response {})
}

View file

@ -113,20 +113,24 @@ pub async fn login_route(body: Ruma<login::v3::Request>) -> Result<login::v3::Re
login::v3::LoginInfo::Token(login::v3::Token { token }) => {
match (
services().globals.jwt_decoding_key(),
services().sso.login_type().next().is_some(),
services().globals.config.idps.is_empty(),
) {
(_, false) => {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_nbf = false;
validation.set_required_spec_claims(&["sub", "exp", "aud", "iss"]);
let mut v = Validation::new(Algorithm::HS256);
v.set_required_spec_claims(&["sub", "exp", "aud", "iss"]);
v.validate_aud = false;
v.validate_nbf = false;
services()
.globals
.validate_claims::<LoginToken>(token, Some(validation))
.as_ref()
.validate_claims::<LoginToken>(token, Some(&v))
.map(LoginToken::audience)
.map(ToOwned::to_owned)
.map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Invalid token."))?
.map_err(|e| {
tracing::warn!("Invalid token: {}", e);
Error::BadRequest(ErrorKind::InvalidParam, "Invalid token.")
})?
}
(Some(jwt_decoding_key), _) => {
let token = jsonwebtoken::decode::<Claims>(

View file

@ -7,15 +7,7 @@ use crate::{
},
services, utils, Error, Result, Ruma,
};
use axum::{
response::{AppendHeaders, IntoResponse, Redirect},
RequestExt,
};
use axum_extra::{
headers::{self},
TypedHeader,
};
use http::header::{self};
use futures_util::TryFutureExt;
use mas_oidc_client::{
requests::{
authorization_code::{self, AuthorizationRequestData},
@ -24,7 +16,6 @@ use mas_oidc_client::{
},
types::{
client_credentials::ClientCredentials,
errors::ClientError,
iana::jose::JsonWebSignatureAlg,
requests::{AccessTokenResponse, AuthorizationResponse},
},
@ -33,6 +24,7 @@ use rand::{rngs::StdRng, Rng, SeedableRng};
use ruma::{
api::client::{
error::ErrorKind,
media::create_content,
session::{sso_login, sso_login_with_provider},
},
events::{room::message::RoomMessageEventContent, GlobalAccountDataEventType},
@ -46,7 +38,7 @@ pub const CALLBACK_PATH: &str = "/_matrix/client/unstable/conduit/callback";
/// # `GET /_matrix/client/v3/login/sso/redirect`
///
/// Redirect the user to the SSO interface.
/// Redirect the user to the SSO interfa.
/// TODO: this should be removed once Ruma supports trailing slashes.
pub async fn get_sso_redirect_route(
Ruma {
@ -148,37 +140,25 @@ pub async fn get_sso_redirect_with_provider_route(
})
}
async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::response::Response> {
let query = req.uri().query().ok_or_else(|| {
Error::BadRequest(ErrorKind::MissingParam, "Empty authorization callback.")
})?;
let AuthorizationResponse {
code,
access_token: _,
token_type: _,
id_token: _,
expires_in: _,
} = serde_html_form::from_str(query).map_err(|_| {
serde_html_form::from_str(query)
.map(ClientError::into)
.unwrap_or_else(|_| {
error!("Failed to deserialize authorization callback: {}", query);
Error::BadRequest(
ErrorKind::Unknown,
"Failed to deserialize authorization callback.",
)
})
})?;
let Ok(Some(cookie)): Result<Option<TypedHeader<headers::Cookie>>, _> = req.extract().await
else {
return Err(Error::BadRequest(
ErrorKind::MissingParam,
"Missing session cookie.",
));
};
/// # `GET /_conduit/client/sso/callback`
///
/// Validate the authorization response received from the identity provider.
/// On success, generate a login token, add it to `redirectUrl` as a query and perform the redirect.
/// If this is the first login, register the user, possibly interactively through a fallback page.
pub async fn handle_callback_route(
body: Ruma<sso_callback::Request>,
) -> Result<sso_login_with_provider::v3::Response> {
let sso_callback::Request {
response:
AuthorizationResponse {
code,
access_token: _,
token_type: _,
id_token: _,
expires_in: _,
},
cookie,
} = body.body;
let ValidationData {
provider,
@ -186,12 +166,7 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
inner: validation_data,
} = services()
.globals
.validate_claims(
cookie.get(SSO_SESSION_COOKIE).ok_or_else(|| {
Error::BadRequest(ErrorKind::MissingParam, "Missing value for session cookie.")
})?,
None,
)
.validate_claims(&cookie, None)
.map_err(|_| {
Error::BadRequest(ErrorKind::InvalidParam, "Invalid value for session cookie.")
})?;
@ -224,7 +199,7 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
let ref jwks = jose::fetch_jwks(services().sso.service(), provider.metadata.jwks_uri())
.await
.map_err(|_| Error::bad_config("Failed to fetch signing keys for token endpoint."))?;
let jwt_verification_data = Some(JwtVerificationData {
let idt_verification_data = Some(JwtVerificationData {
jwks,
issuer: &provider.config.issuer,
client_id: &provider.config.client_id,
@ -247,7 +222,7 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
provider.metadata.token_endpoint(),
code.unwrap_or_default(),
validation_data,
jwt_verification_data,
idt_verification_data,
SystemTime::now().into(),
&mut StdRng::from_entropy(),
)
@ -257,21 +232,28 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
unreachable!("ID token should never be empty")
};
let mut _userinfo = HashMap::default();
let mut userinfo = HashMap::default();
if let Some(endpoint) = provider.metadata.userinfo_endpoint.as_ref() {
_userinfo = userinfo::fetch_userinfo(
userinfo = userinfo::fetch_userinfo(
services().sso.service(),
endpoint,
&access_token,
jwt_verification_data,
None,
&id_token,
)
.await
.map_err(|_| Error::bad_config("Failed to fetch claims for userinfo endpoint."))?;
.map_err(|e| {
tracing::error!("Failed to fetch claims for userinfo endpoint: {:?}", e);
Error::bad_config("Failed to fetch claims for userinfo endpoint.")
})?;
}
let (_, id_token) = id_token.into_parts();
info!("userinfo: {:?}", &userinfo);
info!("id_token: {:?}", &id_token);
let subject = match id_token.get(SUBJECT_CLAIM_KEY) {
Some(Value::String(s)) => s.to_owned(),
Some(Value::Number(n)) => n.to_string(),
@ -299,8 +281,13 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
let user_id = loop {
match UserId::parse_with_server_name(&*localpart, services().globals.server_name())
{
Ok(user_id) if services().users.exists(&user_id)? => break user_id,
.map(|user_id| {
(
user_id.clone(),
services().users.exists(&user_id).unwrap_or(true),
)
}) {
Ok((user_id, false)) => break user_id,
_ => {
let n: u8 = rand::thread_rng().gen();
@ -310,12 +297,15 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
};
services().users.set_placeholder_password(&user_id)?;
let mut displayname = id_token
let displayname = id_token
.get("preferred_username")
.or(id_token.get("nickname"))
.or(id_token.get("nickname"));
let mut displayname = displayname
.as_deref()
.map(Value::to_string)
.unwrap_or(user_id.localpart().to_owned());
.map(Value::as_str)
.flatten()
.unwrap_or(user_id.localpart())
.to_owned();
// If enabled append lightning bolt to display name (default true)
if services().globals.enable_lightning_bolt() {
@ -326,6 +316,34 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
.users
.set_displayname(&user_id, Some(displayname.clone()))?;
if let Some(Value::String(url)) = userinfo.get("picture").or(id_token.get("picture")) {
let req = services()
.globals
.default_client()
.get(url)
.send()
.and_then(reqwest::Response::bytes);
if let Ok(file) = req.await {
let _ = crate::api::client_server::create_content_route(Ruma {
body: create_content::v3::Request::new(file.to_vec()),
sender_user: None,
sender_device: None,
sender_servername: None,
json_body: None,
appservice_info: None,
})
.await
.and_then(|res| {
tracing::info!("successfully imported avatar for {}", &user_id);
services()
.users
.set_avatar_url(&user_id, Some(res.content_uri))
});
}
}
// Initial account data
services().account_data.update(
None,
@ -355,7 +373,7 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
{
services()
.admin
.make_user_admin(&user_id, displayname)
.make_user_admin(&user_id, displayname.to_owned())
.await?;
warn!("Granting {} admin privileges as the first user", user_id);
@ -376,26 +394,10 @@ async fn handle_callback_helper(req: axum::extract::Request) -> Result<axum::res
.query_pairs_mut()
.append_pair("loginToken", &signed);
Ok((
AppendHeaders(vec![(
header::SET_COOKIE,
utils::build_cookie(SSO_SESSION_COOKIE, "", CALLBACK_PATH, None).to_string(),
)]),
Redirect::temporary(redirect_url.as_str()),
)
.into_response())
}
/// # `GET /_conduit/client/sso/callback`
///
/// Validate the authorization response received from the identity provider.
/// On success, generate a login token, add it to `redirectUrl` as a query and perform the redirect.
/// If this is the first login, register the user, possibly interactively through a fallback page.
pub async fn handle_callback_route(req: axum::extract::Request) -> axum::response::Response {
match handle_callback_helper(req).await {
Ok(res) => res,
Err(e) => e.into_response(),
}
Ok(sso_login_with_provider::v3::Response {
location: redirect_url.to_string(),
cookie: Some(utils::build_cookie(SSO_SESSION_COOKIE, "", CALLBACK_PATH, None).to_string()),
})
}
mod sso_callback {
@ -404,9 +406,9 @@ mod sso_callback {
use mas_oidc_client::types::requests::AuthorizationResponse;
use ruma::{
api::{
client::Error,
client::{session::sso_login_with_provider, Error},
error::{FromHttpRequestError, HeaderDeserializationError},
IncomingRequest, Metadata, OutgoingResponse,
IncomingRequest, Metadata,
},
metadata,
};
@ -423,15 +425,13 @@ mod sso_callback {
};
pub struct Request {
response: AuthorizationResponse,
cookie: String,
pub response: AuthorizationResponse,
pub cookie: String,
}
pub struct Response {}
impl IncomingRequest for Request {
type EndpointError = Error;
type OutgoingResponse = Response;
type OutgoingResponse = sso_login_with_provider::v3::Response;
const METADATA: Metadata = METADATA;
@ -470,12 +470,4 @@ mod sso_callback {
Ok(Self { response, cookie })
}
}
impl OutgoingResponse for Response {
fn try_into_http_response<T: Default + bytes::BufMut>(
self,
) -> Result<http::Response<T>, ruma::api::error::IntoHttpError> {
todo!()
}
}
}

View file

@ -9,10 +9,7 @@ use axum::{
Router,
};
use axum_server::{bind, bind_rustls, tls_rustls::RustlsConfig, Handle as ServerHandle};
use conduit::api::{
client_server::{self, CALLBACK_PATH},
server_server,
};
use conduit::api::{client_server, server_server};
use figment::{
providers::{Env, Format, Toml},
Figment,
@ -283,10 +280,7 @@ fn routes(config: &Config) -> Router {
.ruma_route(client_server::get_sso_redirect_with_provider_route)
// The specification will likely never introduce any endpoint for handling authorization callbacks.
// As a workaround, we use custom path that redirects the user to the default login handler.
.route(
&format!("/{CALLBACK_PATH}"),
get(client_server::handle_callback_route),
)
.ruma_route(client_server::handle_callback_route)
.ruma_route(client_server::get_capabilities_route)
.ruma_route(client_server::get_pushrules_all_route)
.ruma_route(client_server::set_pushrule_route)

View file

@ -18,7 +18,7 @@ use ruma::{
DeviceId, RoomVersionId, ServerName, UserId,
};
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, HashMap, HashSet},
error::Error as StdError,
fs,
future::{self, Future},
@ -522,7 +522,7 @@ impl Service {
pub fn validate_claims<T: DeserializeOwned>(
&self,
token: &str,
validation_data: Option<jsonwebtoken::Validation>,
validation_data: Option<&jsonwebtoken::Validation>,
) -> jsonwebtoken::errors::Result<T> {
let key = jsonwebtoken::DecodingKey::from_secret(
self.keypair().sign(PROBLEMATIC_CONST).as_bytes(),
@ -533,9 +533,9 @@ impl Service {
// these validations are redundant as all JWTs are stored in cookies
v.validate_exp = false;
v.validate_nbf = false;
v.required_spec_claims = Default::default();
v.required_spec_claims = HashSet::new();
jsonwebtoken::decode::<T>(token, &key, &validation_data.unwrap_or(v))
jsonwebtoken::decode::<T>(token, &key, validation_data.unwrap_or(&v))
.map(|data| data.claims)
}

View file

@ -11,6 +11,7 @@ use crate::{
utils, Error, Result,
};
use futures_util::future::{self};
use http::HeaderValue;
use mas_oidc_client::{
http_service::HttpService,
requests::{authorization_code::AuthorizationValidationData, discovery},
@ -20,6 +21,7 @@ use ruma::{api::client::session::get_login_types::v3::IdentityProvider, OwnedUse
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use tower::BoxError;
use tower_http::{set_header::SetRequestHeaderLayer, ServiceBuilderExt};
use tracing::error;
use url::Url;
@ -43,14 +45,17 @@ impl Service {
pub fn build(db: &'static dyn Data) -> Result<Arc<Self>> {
let client = tower::ServiceBuilder::new()
.map_err(BoxError::from)
.layer(tower_http::timeout::TimeoutLayer::new(
std::time::Duration::from_secs(10),
))
.layer(mas_http::BytesToBodyRequestLayer)
.layer(mas_http::BodyToBytesResponseLayer)
// .override_request_header(http::header::USER_AGENT, "conduit".to_owned())
// .concurrency_limit(10)
// .follow_redirects()
// .layer(tower_http::timeout::TimeoutLayer::new(
// std::time::Duration::from_secs(10),
// ))
.layer(SetRequestHeaderLayer::overriding(
http::header::USER_AGENT,
HeaderValue::from_static("conduit/0.9-alpha"),
))
.concurrency_limit(10)
.follow_redirects()
.service(mas_http::make_untraced_client());
Ok(Arc::new(Self {
@ -157,8 +162,8 @@ impl LoginToken {
.expect("time overflow"),
}
}
pub fn audience(&self) -> &UserId {
&self.aud
pub fn audience(self) -> OwnedUserId {
self.aud
}
}