diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs deleted file mode 100644 index 0c52dca..0000000 --- a/src/infrastructure/db.rs +++ /dev/null @@ -1,49 +0,0 @@ -use super::error::Result; -use super::user::{KeyCloakSub, UserEntity}; -use mongodb::{bson::doc, Client, Collection}; -pub struct Database { - client: Client, -} -impl Database { - pub async fn new(client: Client) -> Self { - Self { client } - } -} - -/// Impl of project related DB actions -impl Database {} - -/// Impl of user-related actions -impl Database { - async fn users_collection(&self) -> Collection { - self.client - .database("dashboard") - .collection::("users") - } - - pub async fn get_user_by_kc_sub(&self, kc_sub: KeyCloakSub) -> Result> { - let c = self.users_collection().await; - let result = c - .find_one(doc! { - "kc_sub" : kc_sub.0 - }) - .await?; - Ok(result) - } - - pub async fn get_user_by_id(&self, user_id: &str) -> Result> { - let c = self.users_collection().await; - - let user_id: mongodb::bson::oid::ObjectId = user_id.parse()?; - - let filter = doc! { "_id" : user_id }; - let result = c.find_one(filter).await?; - Ok(result) - } - - pub async fn insert_user(&self, user: &UserEntity) -> Result<()> { - let c = self.users_collection().await; - let _ = c.insert_one(user).await?; - Ok(()) - } -} diff --git a/src/infrastructure/login.rs b/src/infrastructure/login.rs deleted file mode 100644 index 6f75aac..0000000 --- a/src/infrastructure/login.rs +++ /dev/null @@ -1,302 +0,0 @@ -use super::error::Result; -use super::user::{KeyCloakSub, UserEntity}; -use crate::Route; -use axum::{ - extract::Query, - response::{IntoResponse, Redirect, Response}, - Extension, -}; -use reqwest::StatusCode; -use tracing::{info, warn}; -use url::form_urlencoded; - -#[derive(serde::Deserialize)] -pub struct CallbackCode { - code: Option, - error: Option, -} - -const LOGIN_REDIRECT_URL_SESSION_KEY: &str = "login.redirect.url"; -const TEST_USER_SUB: KeyCloakSub = KeyCloakSub(String::new()); - -#[derive(serde::Deserialize)] -pub struct LoginRedirectQuery { - redirect_url: Option, -} - -/// Handler that redirects the user to the login page of Keycloack. -#[axum::debug_handler] -pub async fn redirect_to_keycloack_login( - state: Extension, - user_session: super::auth::UserSession, - session: tower_sessions::Session, - query: Query, -) -> Result { - // check if already logged in before redirecting again - if user_session.data().is_ok() { - return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response()); - } - - if let Some(url) = &query.redirect_url { - if !url.is_empty() { - session.insert(LOGIN_REDIRECT_URL_SESSION_KEY, &url).await?; - } - } - - // if this is a test user then skip login - if state.keycloak_variables.enable_test_user { - return login_test_user(state, session).await; - } - - let kc_base_url = &state.keycloak_variables.base_url; - let kc_realm = &state.keycloak_variables.realm; - let kc_client_id = &state.keycloak_variables.client_id; - let redirect_uri = format!("http://localhost:8000/auth/callback"); - let encoded_redirect_uri: String = - form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect(); - - // Needed for running locally. - // This will not panic on production and it will return the original so we can keep it - let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost"); - - Ok(Redirect::to( - format!("{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/auth?client_id={kc_client_id}&response_type=code&scope=openid%20profile%20email&redirect_uri={encoded_redirect_uri}").as_str()) - .into_response()) -} - -/// Helper function that automatically logs the user in as a test user. -async fn login_test_user( - state: Extension, - session: tower_sessions::Session, -) -> Result { - let user = state.db.get_user_by_kc_sub(TEST_USER_SUB).await?; - - // if we do not have a test user already, create one - let user = if let Some(user) = user { - info!("Existing test user logged in"); - user - } else { - info!("Test User not found, inserting ..."); - - let user = UserEntity { - _id: mongodb::bson::oid::ObjectId::new(), - created_at: mongodb::bson::DateTime::now(), - kc_sub: TEST_USER_SUB, - email: "exampleuser@domain.com".to_string(), - }; - - state.db.insert_user(&user).await?; - user - }; - - info!("Test User successfuly logged in: {:?}", user); - - let data = super::auth::LoggedInData { - id: user._id.to_string(), - token_id: String::new(), - username: "tester".to_string(), - avatar_url: None, - }; - super::auth::login(&session, &data).await?; - - // redirect to the URL stored in the session if available - let redirect_url = session - .remove::(LOGIN_REDIRECT_URL_SESSION_KEY) - .await? - .unwrap_or_else(|| Route::OverviewPage {}.to_string()); - - Ok(Redirect::to(&redirect_url).into_response()) -} - -/// Handler function executed once KC redirects back to us. Creates database entries if -/// needed and initializes the user session to mark the user as "logged in". -#[axum::debug_handler] -pub async fn handle_login_callback( - state: Extension, - session: tower_sessions::Session, - Query(params): Query, -) -> Result { - // now make sure the user actually authorized the app and that there was no error - let Some(code) = params.code else { - warn!("Code was not provided, error: {:?}", params.error); - return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response()); - }; - - // if on dev environment we get the internal kc url - let kc_base_url = std::env::var("KEYCLOAK_ADMIN_URL") - .unwrap_or_else(|_| state.keycloak_variables.base_url.clone()); - let kc_realm = &state.keycloak_variables.realm; - let kc_client_id = &state.keycloak_variables.client_id; - let kc_client_secret = &state.keycloak_variables.client_secret; - let redirect_uri = format!("http://localhost:8000/auth/callback"); - - // exchange the code for an access token - let token = exchange_code( - &code, - &kc_base_url, - kc_realm, - kc_client_id, - kc_client_secret, - redirect_uri.as_str(), - ) - .await?; - - // use the access token to get the user information - let user_info = get_user_info(&token, &kc_base_url, kc_realm).await?; - - // Check if the user is a member of the organization (only on dev and demo environments) - let base_url = state.keycloak_variables.base_url.clone(); - let is_for_devs = base_url.contains("dev") || base_url.contains("demo"); - if is_for_devs { - let Some(github_login) = user_info.github_login.as_ref() else { - return Err(crate::infrastructure::error::Error::Forbidden( - "GitHub login not available.".to_string(), - )); - }; - if !is_org_member(github_login).await? { - return Err(crate::infrastructure::error::Error::Forbidden( - "You are not a member of the organization.".to_string(), - )); - } - } - - // now check if we have a user already - let kc_sub = KeyCloakSub(user_info.sub); - - let user = state.db.get_user_by_kc_sub(kc_sub.clone()).await?; - - // if we do not have a user already, create one - let user = if let Some(user) = user { - info!("Existing user logged in"); - user - } else { - info!("User not found, creating ..."); - - let user = UserEntity { - _id: mongodb::bson::oid::ObjectId::new(), - created_at: mongodb::bson::DateTime::now(), - kc_sub, - email: user_info.email.clone(), - }; - - state.db.insert_user(&user).await?; - user - }; - - info!("User successfuly logged in"); - - // we now have access token and information about the user that just logged in, as well as an - // existing or newly created user database entity. - // Store information in session storage that we want (eg name and avatar url + databae id) to make the user "logged in"! - // Redirect the user somewhere - let data = super::auth::LoggedInData { - id: user._id.to_string(), - token_id: token.id_token, - username: user_info.preferred_username, - avatar_url: user_info.picture, - }; - super::auth::login(&session, &data).await?; - - // redirect to the URL stored in the session if available - let redirect_url = session - .remove::(LOGIN_REDIRECT_URL_SESSION_KEY) - .await? - .unwrap_or_else(|| Route::OverviewPage {}.to_string()); - - Ok(Redirect::to(&redirect_url).into_response()) -} - -#[derive(serde::Deserialize)] -#[allow(dead_code)] // not all fields are currently used -struct AccessToken { - access_token: String, - expires_in: u64, - refresh_token: String, - refresh_expires_in: u64, - id_token: String, -} - -/// Exchange KC code for an access token -async fn exchange_code( - code: &str, - kc_base_url: &str, - kc_realm: &str, - kc_client_id: &str, - kc_client_secret: &str, - redirect_uri: &str, -) -> Result { - let res = reqwest::Client::new() - .post(format!( - "{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/token", - )) - .form(&[ - ("grant_type", "authorization_code"), - ("client_id", kc_client_id), - ("client_secret", kc_client_secret), - ("code", code), - ("redirect_uri", redirect_uri), - ]) - .send() - .await?; - - let res: AccessToken = res.json().await?; - Ok(res) -} - -/// Query the openid-connect endpoint to get the user info by using the access token. -async fn get_user_info(token: &AccessToken, kc_base_url: &str, kc_realm: &str) -> Result { - let client = reqwest::Client::new(); - let url = format!("{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/userinfo"); - - let mut request = client.get(&url).bearer_auth(token.access_token.clone()); - - // If KEYCLOAK_ADMIN_URL is NOT set (i.e. we're on the local Keycloak), - // add the HOST header for local testing. - if std::env::var("KEYCLOAK_ADMIN_URL").is_err() { - request = request.header("HOST", "localhost:8888"); - } - - let res = request.send().await?; - let res: UserInfo = res.json().await?; - Ok(res) -} - -/// Contains selected fields from the user information call to KC -/// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo -#[derive(serde::Deserialize)] -#[allow(dead_code)] // not all fields are currently used -struct UserInfo { - sub: String, // subject element of the ID Token - name: String, - given_name: String, - family_name: String, - preferred_username: String, - email: String, - picture: Option, - github_login: Option, -} - -/// Check if a user is a member of the organization -const GITHUB_ORG: &str = "etospheres-labs"; -async fn is_org_member(username: &str) -> Result { - let url = format!("https://api.github.com/orgs/{GITHUB_ORG}/members/{username}"); - let client = reqwest::Client::new(); - let response = client - .get(&url) - .header("Accept", "application/vnd.github+json") // GitHub requires a User-Agent header. - .header("User-Agent", "etopay-app") - .send() - .await?; - - match response.status() { - StatusCode::NO_CONTENT => Ok(true), - status => { - tracing::warn!( - "{}: User '{}' is not a member of the organization", - status.as_str(), - username - ); - Ok(false) - } - } -} diff --git a/src/infrastructure/server_state.rs b/src/infrastructure/server_state.rs deleted file mode 100644 index 5c3f017..0000000 --- a/src/infrastructure/server_state.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Implements a [`ServerState`] that is available in the dioxus server functions -//! as well as in axum handlers. -//! Taken from https://github.com/dxps/dioxus_playground/tree/44a4ddb223e6afe50ef195e61aa2b7182762c7da/dioxus-05-fullstack-routing-axum-pgdb - -use super::auth::KeycloakVariables; -use super::error::{Error, Result}; - -use axum::http; - -use std::ops::Deref; -use std::sync::Arc; - -/// This is stored as an "extension" object in the axum webserver -/// We can get it in the dioxus server functions using -/// ```rust -/// let state: crate::infrastructure::server_state::ServerState = extract().await?; -/// ``` -#[derive(Clone)] -pub struct ServerState(Arc); - -impl Deref for ServerState { - type Target = ServerStateInner; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -pub struct ServerStateInner { - pub db: crate::infrastructure::db::Database, - pub keycloak_variables: &'static KeycloakVariables, -} - -impl From for ServerState { - fn from(value: ServerStateInner) -> Self { - Self(Arc::new(value)) - } -} - -impl axum::extract::FromRequestParts for ServerState -where - S: std::marker::Sync + std::marker::Send, -{ - type Rejection = Error; - - async fn from_request_parts(parts: &mut http::request::Parts, _: &S) -> Result { - parts - .extensions - .get::() - .cloned() - .ok_or(Error::ServerStateError( - "ServerState extension should exist".to_string(), - )) - } -} diff --git a/src/infrastructure/user.rs b/src/infrastructure/user.rs deleted file mode 100644 index c26ffd1..0000000 --- a/src/infrastructure/user.rs +++ /dev/null @@ -1,21 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Wraps a `String` to store the sub from KC -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KeyCloakSub(pub String); - -/// database entity to store our users -#[derive(Debug, Serialize, Deserialize)] -pub struct UserEntity { - /// Our unique id of the user, for now this is just the mongodb assigned id - pub _id: mongodb::bson::oid::ObjectId, - - /// Time the user was created - pub created_at: mongodb::bson::DateTime, - - /// KC subject element of the ID Token - pub kc_sub: KeyCloakSub, - - /// User email as provided during signup with the identity provider - pub email: String, -}