3 Commits

16 changed files with 320 additions and 30 deletions

View File

@@ -3020,6 +3020,7 @@ dependencies = [
"ractor", "ractor",
"rand 0.9.2", "rand 0.9.2",
"serde", "serde",
"serde_json",
"sha2", "sha2",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",

View File

@@ -31,3 +31,4 @@ mailchecker = "6.0.19"
matrix-sdk = "0.14.0" matrix-sdk = "0.14.0"
url = "2.5.7" url = "2.5.7"
ractor = "0.15.9" ractor = "0.15.9"
serde_json = "1.0.145"

View File

@@ -220,6 +220,11 @@ impl AppConfig {
pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf { pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db-passphrase") self.user_directory(mail).join("matrix-db-passphrase")
} }
/// Get user Matrix session file path
pub fn user_matrix_session_file_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-session.json")
}
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]

View File

@@ -1,8 +1,9 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::controllers::{HttpFailure, HttpResult}; use crate::controllers::{HttpFailure, HttpResult};
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod}; use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::extractors::session_extractor::MatrixGWSession; use crate::extractors::session_extractor::MatrixGWSession;
use crate::users::{ExtendedUserInfo, User, UserEmail}; use crate::users::{User, UserEmail};
use actix_remote_ip::RemoteIP; use actix_remote_ip::RemoteIP;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use light_openid::primitives::OpenIDConfig; use light_openid::primitives::OpenIDConfig;
@@ -107,8 +108,8 @@ pub async fn finish_oidc(
} }
/// Get current user information /// Get current user information
pub async fn auth_info(auth: AuthExtractor) -> HttpResult { pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(ExtendedUserInfo::from_user(auth.user).await?)) Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
} }
/// Sign out user /// Sign out user

View File

@@ -1,5 +1,6 @@
use crate::controllers::HttpResult; use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor; use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_client::FinishMatrixAuth;
use actix_web::HttpResponse; use actix_web::HttpResponse;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -12,3 +13,18 @@ pub async fn start_auth(client: MatrixClientExtractor) -> HttpResult {
let url = client.client.initiate_login().await?.to_string(); let url = client.client.initiate_login().await?.to_string();
Ok(HttpResponse::Ok().json(StartAuthResponse { url })) Ok(HttpResponse::Ok().json(StartAuthResponse { url }))
} }
/// Finish user authentication on Matrix server
pub async fn finish_auth(client: MatrixClientExtractor) -> HttpResult {
match client
.client
.finish_login(client.auth.decode_json_body::<FinishMatrixAuth>()?)
.await
{
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to finish Matrix authentication: {e}");
Err(e.into())
}
}
}

View File

@@ -28,7 +28,9 @@ impl ResponseError for HttpFailure {
} }
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string()) HttpResponse::build(self.status_code())
.content_type("text/plain")
.body(self.to_string())
} }
} }

View File

@@ -11,6 +11,8 @@ use anyhow::Context;
use bytes::Bytes; use bytes::Bytes;
use jwt_simple::common::VerificationOptions; use jwt_simple::common::VerificationOptions;
use jwt_simple::prelude::{Duration, HS256Key, MACLike}; use jwt_simple::prelude::{Duration, HS256Key, MACLike};
use jwt_simple::reexports::serde_json;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt::Display; use std::fmt::Display;
use std::net::IpAddr; use std::net::IpAddr;
@@ -32,6 +34,16 @@ pub struct AuthExtractor {
pub payload: Option<Vec<u8>>, pub payload: Option<Vec<u8>>,
} }
impl AuthExtractor {
pub fn decode_json_body<E: DeserializeOwned + Send>(&self) -> anyhow::Result<E> {
let payload = self
.payload
.as_ref()
.context("Failed to decode request as json: missing payload!")?;
serde_json::from_slice(payload).context("Failed to decode request json!")
}
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub struct MatrixJWTKID { pub struct MatrixJWTKID {
pub user_email: UserEmail, pub user_email: UserEmail,

View File

@@ -1,6 +1,7 @@
use crate::extractors::auth_extractor::AuthExtractor; use crate::extractors::auth_extractor::AuthExtractor;
use crate::matrix_connection::matrix_client::MatrixClient; use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg; use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::ExtendedUserInfo;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, web}; use actix_web::{FromRequest, HttpRequest, web};
use ractor::ActorRef; use ractor::ActorRef;
@@ -10,6 +11,16 @@ pub struct MatrixClientExtractor {
pub client: MatrixClient, pub client: MatrixClient,
} }
impl MatrixClientExtractor {
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
Ok(ExtendedUserInfo {
user: self.auth.user.clone(),
matrix_user_id: self.client.client.user_id().map(|id| id.to_string()),
matrix_device_id: self.client.client.device_id().map(|id| id.to_string()),
})
}
}
impl FromRequest for MatrixClientExtractor { impl FromRequest for MatrixClientExtractor {
type Error = actix_web::Error; type Error = actix_web::Error;
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>; type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;

View File

@@ -55,7 +55,7 @@ async fn main() -> std::io::Result<()> {
let cors = Cors::default() let cors = Cors::default()
.allowed_origin(&AppConfig::get().website_origin) .allowed_origin(&AppConfig::get().website_origin)
.allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) .allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
.allowed_header(constants::API_AUTH_HEADER) .allowed_header(constants::API_AUTH_HEADER)
.allow_any_header() .allow_any_header()
.supports_credentials() .supports_credentials()
@@ -94,6 +94,10 @@ async fn main() -> std::io::Result<()> {
"/api/matrix_link/start_auth", "/api/matrix_link/start_auth",
web::post().to(matrix_link_controller::start_auth), web::post().to(matrix_link_controller::start_auth),
) )
.route(
"/api/matrix_link/finish_auth",
web::post().to(matrix_link_controller::finish_auth),
)
}) })
.workers(4) .workers(4)
.bind(&AppConfig::get().listen_address)? .bind(&AppConfig::get().listen_address)?

View File

@@ -1,15 +1,31 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::users::UserEmail; use crate::users::UserEmail;
use crate::utils::rand_utils::rand_string; use crate::utils::rand_utils::rand_string;
use matrix_sdk::authentication::oauth::OAuthError; use anyhow::Context;
use matrix_sdk::authentication::oauth::error::OAuthDiscoveryError; use matrix_sdk::authentication::oauth::error::OAuthDiscoveryError;
use matrix_sdk::authentication::oauth::{
ClientId, OAuthError, OAuthSession, UrlOrQuery, UserSession,
};
use matrix_sdk::ruma::serde::Raw; use matrix_sdk::ruma::serde::Raw;
use matrix_sdk::{Client, ClientBuildError}; use matrix_sdk::{Client, ClientBuildError};
use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
/// The full session to persist.
#[derive(Debug, Serialize, Deserialize, Clone)]
struct StoredSession {
/// The OAuth 2.0 user session.
user_session: UserSession,
/// The OAuth 2.0 client ID.
client_id: ClientId,
}
/// Matrix Gateway session errors /// Matrix Gateway session errors
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
enum MatrixClientError { enum MatrixClientError {
#[error("Failed to destroy previous client data! {0}")]
DestroyPreviousData(Box<MatrixClientError>),
#[error("Failed to create Matrix database storage directory! {0}")] #[error("Failed to create Matrix database storage directory! {0}")]
CreateMatrixDbDir(std::io::Error), CreateMatrixDbDir(std::io::Error),
#[error("Failed to create database passphrase! {0}")] #[error("Failed to create database passphrase! {0}")]
@@ -18,16 +34,34 @@ enum MatrixClientError {
ReadDbPassphrase(std::io::Error), ReadDbPassphrase(std::io::Error),
#[error("Failed to build Matrix client! {0}")] #[error("Failed to build Matrix client! {0}")]
BuildMatrixClient(ClientBuildError), BuildMatrixClient(ClientBuildError),
#[error("Failed to clear Matrix session file! {0}")]
ClearMatrixSessionFile(std::io::Error),
#[error("Failed to clear Matrix database storage directory! {0}")] #[error("Failed to clear Matrix database storage directory! {0}")]
ClearMatrixDbDir(std::io::Error), ClearMatrixDbDir(std::io::Error),
#[error("Failed to remove database passphrase! {0}")] #[error("Failed to remove database passphrase! {0}")]
ClearDbPassphrase(std::io::Error), ClearDbPassphrase(std::io::Error),
#[error("Failed to fetch server metadata! {0}")] #[error("Failed to fetch server metadata! {0}")]
FetchServerMetadata(OAuthDiscoveryError), FetchServerMetadata(OAuthDiscoveryError),
#[error("Failed to load stored session! {0}")]
LoadStoredSession(std::io::Error),
#[error("Failed to decode stored session! {0}")]
DecodeStoredSession(serde_json::Error),
#[error("Failed to restore stored session! {0}")]
RestoreSession(matrix_sdk::Error),
#[error("Failed to parse auth redirect URL! {0}")] #[error("Failed to parse auth redirect URL! {0}")]
ParseAuthRedirectURL(url::ParseError), ParseAuthRedirectURL(url::ParseError),
#[error("Failed to build auth request! {0}")] #[error("Failed to build auth request! {0}")]
BuildAuthRequest(OAuthError), BuildAuthRequest(OAuthError),
#[error("Failed to finalize authentication! {0}")]
FinishLogin(matrix_sdk::Error),
#[error("Failed to write session file! {0}")]
WriteSessionFile(std::io::Error),
}
#[derive(serde::Deserialize)]
pub struct FinishMatrixAuth {
code: String,
state: String,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -39,6 +73,14 @@ pub struct MatrixClient {
impl MatrixClient { impl MatrixClient {
/// Start to build Matrix client to initiate user authentication /// Start to build Matrix client to initiate user authentication
pub async fn build_client(email: &UserEmail) -> anyhow::Result<Self> { pub async fn build_client(email: &UserEmail) -> anyhow::Result<Self> {
// Check if we are restoring a previous state
let session_file_path = AppConfig::get().user_matrix_session_file_path(email);
let is_restoring = session_file_path.is_file();
if !is_restoring {
Self::destroy_data(email).map_err(MatrixClientError::DestroyPreviousData)?;
}
// Determine Matrix database path
let db_path = AppConfig::get().user_matrix_db_path(email); let db_path = AppConfig::get().user_matrix_db_path(email);
std::fs::create_dir_all(&db_path).map_err(MatrixClientError::CreateMatrixDbDir)?; std::fs::create_dir_all(&db_path).map_err(MatrixClientError::CreateMatrixDbDir)?;
@@ -61,37 +103,64 @@ impl MatrixClient {
.map_err(MatrixClientError::BuildMatrixClient)?; .map_err(MatrixClientError::BuildMatrixClient)?;
// Check metadata // Check metadata
let server_metadata = client let oauth = client.oauth();
.oauth() let server_metadata = oauth
.server_metadata() .server_metadata()
.await .await
.map_err(MatrixClientError::FetchServerMetadata)?; .map_err(MatrixClientError::FetchServerMetadata)?;
log::info!("OAuth2 server issuer: {:?}", server_metadata.issuer); log::info!("OAuth2 server issuer: {:?}", server_metadata.issuer);
// TODO : restore client if client already existed if is_restoring {
let session: StoredSession = serde_json::from_str(
std::fs::read_to_string(session_file_path)
.map_err(MatrixClientError::LoadStoredSession)?
.as_str(),
)
.map_err(MatrixClientError::DecodeStoredSession)?;
Ok(Self { // Restore data
client
.restore_session(OAuthSession {
client_id: session.client_id,
user: session.user_session,
})
.await
.map_err(MatrixClientError::RestoreSession)?;
}
let client = Self {
email: email.clone(), email: email.clone(),
client, client,
}) };
// Automatically save session when token gets refreshed
client.setup_background_session_save().await;
Ok(client)
} }
/// Destroy this Matrix client instance /// Destroy Matrix client related data
pub fn destroy(&self) -> anyhow::Result<()> { fn destroy_data(email: &UserEmail) -> anyhow::Result<(), Box<MatrixClientError>> {
let db_path = AppConfig::get().user_matrix_db_path(&self.email); let session_path = AppConfig::get().user_matrix_session_file_path(email);
if db_path.is_file() { if session_path.is_file() {
std::fs::remove_file(&session_path)
.map_err(MatrixClientError::ClearMatrixSessionFile)?;
}
let db_path = AppConfig::get().user_matrix_db_path(email);
if db_path.is_dir() {
std::fs::remove_dir_all(&db_path).map_err(MatrixClientError::ClearMatrixDbDir)?; std::fs::remove_dir_all(&db_path).map_err(MatrixClientError::ClearMatrixDbDir)?;
} }
let passphrase_path = AppConfig::get().user_matrix_passphrase_path(&self.email); let passphrase_path = AppConfig::get().user_matrix_passphrase_path(email);
if passphrase_path.is_file() { if passphrase_path.is_file() {
std::fs::remove_file(passphrase_path).map_err(MatrixClientError::ClearDbPassphrase)?; std::fs::remove_file(passphrase_path).map_err(MatrixClientError::ClearDbPassphrase)?;
} }
todo!() Ok(())
} }
/// Initiate oauth authentication /// Initiate OAuth authentication
pub async fn initiate_login(&self) -> anyhow::Result<Url> { pub async fn initiate_login(&self) -> anyhow::Result<Url> {
let oauth = self.client.oauth(); let oauth = self.client.oauth();
@@ -112,4 +181,77 @@ impl MatrixClient {
Ok(auth.url) Ok(auth.url)
} }
/// Finish OAuth authentication
pub async fn finish_login(&self, info: FinishMatrixAuth) -> anyhow::Result<()> {
let oauth = self.client.oauth();
oauth
.finish_login(UrlOrQuery::Query(format!(
"state={}&code={}",
info.state, info.code
)))
.await
.map_err(MatrixClientError::FinishLogin)?;
log::info!(
"User successfully authenticated as {}!",
self.client.user_id().unwrap()
);
// Persist session tokens
self.save_stored_session().await?;
Ok(())
}
/// Automatically persist session onto disk
pub async fn setup_background_session_save(&self) {
let this = self.clone();
tokio::spawn(async move {
while let Ok(update) = this.client.subscribe_to_session_changes().recv().await {
match update {
matrix_sdk::SessionChange::UnknownToken { soft_logout } => {
log::warn!("Received an unknown token error; soft logout? {soft_logout:?}");
}
matrix_sdk::SessionChange::TokensRefreshed => {
// The tokens have been refreshed, persist them to disk.
if let Err(err) = this.save_stored_session().await {
log::error!("Unable to store a session in the background: {err}");
}
}
}
}
});
}
/// Update the session stored on the filesystem.
async fn save_stored_session(&self) -> anyhow::Result<()> {
log::debug!("Save the stored session for {:?}...", self.email);
let user_session: UserSession = self
.client
.oauth()
.user_session()
.context("A logged in client must have a session")?;
let stored_session = StoredSession {
user_session,
client_id: self
.client
.oauth()
.client_id()
.context("Client ID should be set at this point!")?
.clone(),
};
let serialized_session = serde_json::to_string(&stored_session)?;
std::fs::write(
AppConfig::get().user_matrix_session_file_path(&self.email),
serialized_session,
)
.map_err(MatrixClientError::WriteSessionFile)?;
log::debug!("Updating the stored session: done!");
Ok(())
}
} }

View File

@@ -172,13 +172,5 @@ pub struct ExtendedUserInfo {
#[serde(flatten)] #[serde(flatten)]
pub user: User, pub user: User,
pub matrix_user_id: Option<String>, pub matrix_user_id: Option<String>,
} pub matrix_device_id: Option<String>,
impl ExtendedUserInfo {
pub async fn from_user(user: User) -> anyhow::Result<Self> {
Ok(Self {
user,
matrix_user_id: None, // TODO
})
}
} }

View File

@@ -14,6 +14,7 @@ import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute"; import { NotFoundRoute } from "./routes/NotFoundRoute";
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage"; import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage"; import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
interface AuthContext { interface AuthContext {
signedIn: boolean; signedIn: boolean;
@@ -39,6 +40,7 @@ export function App(): React.ReactElement {
<Route path="*" element={<BaseAuthenticatedPage />}> <Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} /> <Route path="" element={<HomeRoute />} />
<Route path="matrix_link" element={<MatrixLinkRoute />} /> <Route path="matrix_link" element={<MatrixLinkRoute />} />
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
<Route path="*" element={<NotFoundRoute />} /> <Route path="*" element={<NotFoundRoute />} />
</Route> </Route>
) : ( ) : (

View File

@@ -7,6 +7,7 @@ export interface UserInfo {
name: string; name: string;
email: string; email: string;
matrix_user_id?: string; matrix_user_id?: string;
matrix_device_id?: string;
} }
const TokenStateKey = "auth-state"; const TokenStateKey = "auth-state";

View File

@@ -12,4 +12,15 @@ export class MatrixLinkApi {
}) })
).data; ).data;
} }
/**
* Finish Matrix Account login
*/
static async FinishAuth(code: string, state: string): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/finish_auth",
method: "POST",
jsonData: { code, state },
});
}
} }

View File

@@ -0,0 +1,81 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import React from "react";
import { useNavigate, useSearchParams } from "react-router";
import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { RouterLink } from "../widgets/RouterLink";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
export function MatrixAuthCallback(): React.ReactElement {
const navigate = useNavigate();
const snackbar = useSnackbar();
const info = useUserInfo();
const [error, setError] = React.useState<null | string>(null);
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const state = searchParams.get("state");
const count = React.useRef("");
React.useEffect(() => {
const load = async () => {
try {
if (count.current === code) {
return;
}
count.current = code!;
await MatrixLinkApi.FinishAuth(code!, state!);
snackbar("Successfully linked to Matrix account!");
navigate("/matrix_link");
} catch (e) {
console.error(e);
setError(String(e));
} finally {
info.reloadUserInfo();
}
};
load();
}, [code, state]);
if (error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
Failed to finalize Matrix authentication!
<br />
<br />
{error}
</Alert>
<Button>
<RouterLink to="/matrix_link">Go back</RouterLink>
</Button>
</Box>
);
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
}

View File

@@ -79,9 +79,17 @@ function ConnectedCard(): React.ReactElement {
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
<p> <p>
MatrixGW is currently connected to your account with ID{" "} MatrixGW is currently connected to your account with the following
<i>{user.info.matrix_user_id}</i>. information:
</p> </p>
<ul>
<li>
User id: <i>{user.info.matrix_user_id}</i>
</li>
<li>
Device id: <i>{user.info.matrix_device_id}</i>
</li>
</ul>
<p> <p>
If you encounter issues with your Matrix account you can try to If you encounter issues with your Matrix account you can try to
disconnect and connect back again. disconnect and connect back again.