ci: added basic workflows #2
@@ -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<UserEntity> {
|
||||
self.client
|
||||
.database("dashboard")
|
||||
.collection::<UserEntity>("users")
|
||||
}
|
||||
|
||||
pub async fn get_user_by_kc_sub(&self, kc_sub: KeyCloakSub) -> Result<Option<UserEntity>> {
|
||||
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<Option<UserEntity>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// Handler that redirects the user to the login page of Keycloack.
|
||||
#[axum::debug_handler]
|
||||
pub async fn redirect_to_keycloack_login(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
user_session: super::auth::UserSession,
|
||||
session: tower_sessions::Session,
|
||||
query: Query<LoginRedirectQuery>,
|
||||
) -> Result<Response> {
|
||||
// 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<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Result<Response> {
|
||||
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::<String>(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<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
Query(params): Query<CallbackCode>,
|
||||
) -> Result<Response> {
|
||||
// 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::<String>(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<AccessToken> {
|
||||
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<UserInfo> {
|
||||
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<String>,
|
||||
github_login: Option<String>,
|
||||
}
|
||||
|
||||
/// Check if a user is a member of the organization
|
||||
const GITHUB_ORG: &str = "etospheres-labs";
|
||||
async fn is_org_member(username: &str) -> Result<bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ServerStateInner>);
|
||||
|
||||
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<ServerStateInner> for ServerState {
|
||||
fn from(value: ServerStateInner) -> Self {
|
||||
Self(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> axum::extract::FromRequestParts<S> 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<Self> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ServerState>()
|
||||
.cloned()
|
||||
.ok_or(Error::ServerStateError(
|
||||
"ServerState extension should exist".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user