This commit is contained in:
Hlars 2025-01-05 13:57:23 +01:00
commit 4ebb2a21d2
41 changed files with 6836 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/target
.env
config.toml
*.log
*.db
/bindings
.DS_Store

18
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"cSpell.words": [
"apikey",
"axum",
"chrono",
"color",
"Conn",
"dotenv",
"hmac",
"oneshot",
"openapi",
"recv",
"repr",
"Servable",
"sqlx",
"utoipa"
]
}

View File

@ -0,0 +1,9 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND noninteractive
# Install Required packages
RUN apt-get update && apt-get -y install samba krb5-config winbind smbclient iproute2 openssl \
&& rm /etc/krb5.conf \
&& mkdir -p /opt/ad-scripts
WORKDIR /opt/ad-scripts
COPY *.sh /opt/ad-scripts
CMD chmod +x /opt/ad-scripts/*.sh && ./samba-ad-setup.sh && ./samba-ad-run.sh

View File

@ -0,0 +1,17 @@
# https://github.com/amardeep2006/ldap-activedirectory-samba/
version: "3.4"
services:
ldap.example.org:
restart: "no"
hostname: "ldap.example.org"
build:
context: .
dockerfile: Dockerfile
privileged: true
expose:
- "636"
ports:
- "636:636"
environment:
SMB_ADMIN_PASSWORD: "admin123!"
AD_DOMAIN: "DEV-AD"

View File

@ -0,0 +1,10 @@
#!/bin/bash
set -e
[ -f /var/lib/samba/.setup ] || {
>&2 echo "[ERROR] Samba is not setup yet, which should happen automatically. Look for errors!"
exit 127
}
samba -i -s /var/lib/samba/private/smb.conf

View File

@ -0,0 +1,31 @@
#!/bin/bash
set -e
info () {
echo "[INFO] $@"
}
info "Running setup"
# Check if samba is setup
[ -f /var/lib/samba/.setup ] && info "Already setup..." && exit 0
info "Provisioning domain controller..."
info "Given admin password: ${SMB_ADMIN_PASSWORD}"
rm /etc/samba/smb.conf
# Setting up Samba AD server
samba-tool domain provision\
--server-role=dc\
--use-rfc2307\
--dns-backend=SAMBA_INTERNAL\
--realm=$(hostname | cut -d '.' -f 2-)\
--domain=${AD_DOMAIN}\
--adminpass=${SMB_ADMIN_PASSWORD}
mv /etc/samba/smb.conf /var/lib/samba/private/smb.conf
touch /var/lib/samba/.setup

3600
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

66
Cargo.toml Normal file
View File

@ -0,0 +1,66 @@
[package]
name = "axum-api-test"
version = "0.1.0"
edition = "2021"
[dependencies]
# Logging
# ========================================
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = [
"local-time",
"env-filter",
] }
tracing-appender = "0.2.3"
tracing-rolling-file = "0.1.2"
error-stack = "0.5.0"
# CLI
# ========================================
dotenv = "0.15"
clap = { version = "4.5.23", features = ["derive"] }
config = "0.15.4"
# User Authentication
# ========================================
uuid = { version = "1.11.0", features = ["v4"] }
sha2 = "0.10.8"
hmac = "0.12.1"
# axum-jwt-login = { path = "../axum-login-jwt" }
axum-jwt-login = { version = "0.1.0", registry = "kellnr" }
rust-argon2 = "2.1.0"
rand = "0.8.5"
ldap3 = "0.11.5"
# Service
# ========================================
windows-service = "0.7.0"
axum = { version = "0.8.1", features = ["macros"] }
strum = { version = "0.26", features = ["derive"] }
# utoipa = { version = "5.3.0", features = ["axum_extras"] }
utoipa = { git = "https://github.com/juhaku/utoipa", features = [
"axum_extras",
] }
# utoipa-axum = "0.1.3"
utoipa-axum = { git = "https://github.com/juhaku/utoipa" }
# utoipa-swagger-ui = { version = "8.1.0", features = ["axum"] }
# utoipa-redoc = { version = "*", features = ["axum"] }
# utoipa-scalar = { version = "*", features = ["axum"] }
utoipa-swagger-ui = { git = "https://github.com/juhaku/utoipa", features = [
"axum",
] }
utoipa-redoc = { git = "https://github.com/juhaku/utoipa", features = ["axum"] }
utoipa-scalar = { git = "https://github.com/juhaku/utoipa", features = [
"axum",
] }
ts-rs = { version = "10.1.0", features = ["chrono-impl"] }
# Utilities
# ========================================
serde = { version = "1.0.216", features = ["derive"] }
tokio = { version = "1.42.0", features = ["full"] }
tokio-util = { version = "0.7.13", features = ["rt"] }
once_cell = "1.20.2"
sqlx = { version = "0.8.3", features = ["runtime-tokio", "postgres", "chrono"] }
chrono = { version = "0.4.39", features = ["serde"] }

206
src/api/backend/ldap.rs Normal file
View File

@ -0,0 +1,206 @@
use std::collections::HashMap;
use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use utoipa::ToSchema;
use crate::{
config::{Configuration, LDAP},
errors::ApiError,
};
#[derive(Debug, Clone, Serialize, Deserialize, TS, ToSchema)]
#[ts(export)]
pub struct ActiveDirectoryUser {
pub name: String,
pub surname: String,
pub email: String,
pub id: String,
}
pub struct LDAPBackend {
connection: Ldap,
config: LDAP,
bound: bool,
}
impl LDAPBackend {
pub async fn from_config(config: &Configuration) -> Result<Self, ApiError> {
// create connection to LDAP server
let ldap_settings = LdapConnSettings::new().set_no_tls_verify(config.ldap.skip_tls_verify);
let (connection, ldap) = LdapConnAsync::with_settings(ldap_settings, &config.ldap.server)
.await
.map_err(|e| ApiError::InternalError(format!("LDAP Server connection: {e}")))?;
ldap3::drive!(connection);
Ok(Self {
config: config.ldap.clone(),
connection: ldap,
bound: false,
})
}
pub async fn unbind(&mut self) {
let _ = self.connection.unbind().await;
self.bound = false;
}
pub async fn ad_bind(&mut self, user: &str, password: &str) -> Result<(), ApiError> {
self.connection
.simple_bind(&format!("{}\\{user}", self.config.ad_domain), &password)
.await
.map_err(|_e| ApiError::InvalidCredentials)?;
self.bound = true;
Ok(())
}
pub async fn get_user_detail(
&mut self,
user_id: &str,
) -> Result<HashMap<String, Vec<String>>, ApiError> {
// check if elevated user has to be used
if self.config.elevated_search {
let id = self.config.elevated_user_id.clone();
let password = self.config.elevated_user_pw.clone();
self.ad_bind(&id, &password).await?;
}
let (search_result, _) = self
.connection
.search(
&self.config.user_search_base,
Scope::Subtree,
// "(objectClass=*)",
&format!("(&(ObjectCategory=Person)(sAMAccountName={user_id}))"),
vec!["givenName", "sn", "mail"],
// vec!["*"],
)
.await
.map_err(|e| ApiError::InternalError(format!("LDAP search error: {e}")))?
.success()
.map_err(|e| ApiError::InternalError(format!("LDAP search error: {e}")))?;
let attributes = if let Some(entry) = search_result.first().cloned() {
SearchEntry::construct(entry).attrs
} else {
HashMap::new()
};
Ok(attributes)
}
pub async fn get_ad_user_list(&mut self) -> Result<Vec<ActiveDirectoryUser>, ApiError> {
// check if elevated user has to be used
if self.config.elevated_search {
let id = self.config.elevated_user_id.clone();
let password = self.config.elevated_user_pw.clone();
self.ad_bind(&id, &password).await?;
}
let (search_result, _) = self
.connection
.search(
&self.config.user_search_base,
Scope::Subtree,
&format!("(&(ObjectCategory=Person))"),
vec!["sAMAccountName", "givenName", "sn", "mail"],
)
.await
.map_err(|e| ApiError::InternalError(format!("LDAP search error: {e}")))?
.success()
.map_err(|e| ApiError::InternalError(format!("LDAP search error: {e}")))?;
let result = search_result
.into_iter()
.map(|entry| {
let user_info = SearchEntry::construct(entry).attrs;
ActiveDirectoryUser {
name: user_info
.get("givenName")
.map(|v| v.first().cloned())
.flatten()
.unwrap_or_default(),
surname: user_info
.get("sn")
.map(|v| v.first().cloned())
.flatten()
.unwrap_or_default(),
email: user_info
.get("mail")
.map(|v| v.first().cloned())
.flatten()
.unwrap_or_default(),
id: user_info
.get("sAMAccountName")
.map(|v| v.first().cloned())
.flatten()
.unwrap_or_default(),
}
})
.collect();
Ok(result)
}
}
#[cfg(test)]
mod test {
use ldap3::{exop::WhoAmI, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
#[tokio::test]
async fn test_ldap() {
let (connection, mut ldap) = LdapConnAsync::with_settings(
LdapConnSettings::new().set_no_tls_verify(true),
"ldaps://localhost:636",
)
.await
.unwrap();
ldap3::drive!(connection);
// bind to server
let res = ldap
// .simple_bind("cn=read-only-admin,dc=example,dc=com", "password")
.simple_bind(
// "CN=Abel Austin,OU=Accounting,OU=Mylab Users,DC=mylab,DC=local",
// "MYLAB\\A0H67123",
// "cn=read-only-admin,dc=example,dc=com",
// "DEV-AD\\Administrator",
// "admin123!",
"DEV-AD\\einstein",
"einstein",
)
.await;
println!("{res:?}");
let (res, re) = ldap
.search(
// "CN=Abel Austin,OU=Accounting,OU=Mylab Users,DC=mylab,DC=local",
"OU=DevUsers,DC=example,DC=org",
Scope::Subtree,
"(objectClass=*)",
// "(&(ObjectCategory=Person)(sAMAccountName=A0H67123))",
vec!["givenName", "sn"],
)
.await
.unwrap()
.success()
.unwrap();
for entry in res {
println!("{:?}", SearchEntry::construct(entry));
}
let test = ldap.extended(WhoAmI).await.unwrap().0;
println!("{test:?}");
// let whoami: ldap3::exop::WhoAmIResp = test.parse();
// println!("{whoami:?}");
let _ = ldap.unbind().await;
}
}

70
src/api/backend/mod.rs Normal file
View File

@ -0,0 +1,70 @@
use axum_jwt_login::{AuthBackend, UserPermissions};
use ldap::LDAPBackend;
use sqlx::PgPool;
use crate::{
api::routes::users::{models::UserStatus, sql::get_users},
config::Configuration,
errors::ApiError,
};
use super::routes::{auth::models::Credentials, users::models::User};
pub mod ldap;
#[derive(Debug, Clone)]
pub struct ApiBackend {
pool: PgPool,
config: Configuration,
}
impl ApiBackend {
pub fn new(pool: PgPool, config: &Configuration) -> Self {
Self {
pool,
config: config.clone(),
}
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
pub fn config(&self) -> &Configuration {
&self.config
}
}
impl AuthBackend<User> for ApiBackend {
type Credentials = Credentials;
type Error = ApiError;
async fn authenticate(
&self,
Credentials { id, password }: Self::Credentials,
) -> Result<Option<User>, Self::Error> {
// get user from Database
let user = get_users(&self.pool, Some(UserStatus::Active), Some(id)).await?;
let user = user.first().ok_or(ApiError::InvalidCredentials)?;
// authenticate user
if user.active_directory_auth {
// authenticate against LDAP (AD) server
// --
let mut ldap = LDAPBackend::from_config(&self.config).await?;
ldap.ad_bind(&user.id(), &password).await?;
// update user information
let details = ldap.get_user_detail(&user.id()).await?;
user.update_with_ad_details(&self.pool, details).await?;
// terminate connection
ldap.unbind().await;
} else {
argon2::verify_encoded(&user.password, password.as_bytes())
.map_err(|_| ApiError::InvalidCredentials)?;
}
Ok(Some(user.clone()))
}
}

41
src/api/description.rs Normal file
View File

@ -0,0 +1,41 @@
use utoipa::{
openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme},
Modify, OpenApi,
};
pub const AUTH_TAG: &str = "Authentication";
pub const USERS_TAG: &str = "Users";
pub const ORDER_TAG: &str = "order";
pub const API_KEY_TAG: &str = "API Keys";
#[derive(OpenApi)]
#[openapi(
modifiers(&SecurityAddon),
tags(
(name = AUTH_TAG, description = "API Authentication endpoints"),
(name = ORDER_TAG, description = "Order API endpoints")
),
)]
pub struct ApiDocumentation;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("x-api-key"))),
);
components.add_security_scheme(
"user_auth",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}

202
src/api/mod.rs Normal file
View File

@ -0,0 +1,202 @@
use std::{future::IntoFuture, net::Ipv4Addr, time::Duration};
use axum::http::HeaderName;
use axum_jwt_login::{AuthSessionWithApiKeyBuilder, JWTKeyPair};
use backend::ApiBackend;
use error_stack::{Report, ResultExt};
use routes::api_keys::models::ApiKey;
use sqlx::PgPool;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
use tracing::{info, level_filters::LevelFilter, Level};
use tracing_rolling_file::{RollingConditionBase, RollingFileAppenderBase};
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
use crate::APP_NAME;
use crate::{config::Configuration, errors::AppError, ROOT_PATH};
pub mod backend;
mod description;
pub mod routes;
pub async fn start(
config: &Configuration,
cancellation_token: CancellationToken,
) -> Result<(), Report<AppError>> {
// Set Report Colour Mode to NONE
Report::set_color_mode(error_stack::fmt::ColorMode::None);
let level_filter = LevelFilter::from_level(match config.debug {
true => Level::DEBUG,
false => Level::INFO,
});
// Prepare logging to file
let file_appender = RollingFileAppenderBase::new(
ROOT_PATH.with_file_name(format!("{}.log", env!("CARGO_PKG_NAME"))),
RollingConditionBase::new().max_size(1024 * 1024 * 2),
5,
)
.change_context(AppError)?;
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// prepare initialization of logging
let log_layers = tracing_subscriber::registry().with(level_filter).with(
fmt::Layer::default()
.with_target(false)
.with_ansi(false)
.with_writer(non_blocking),
);
// also log to console in debug mode
#[cfg(debug_assertions)]
let stdout_log = tracing_subscriber::fmt::layer().pretty();
#[cfg(debug_assertions)]
let log_layers = log_layers.with(stdout_log);
// Initialize logging
log_layers.init();
// Initialize database pool
let pool = PgPool::connect(&config.database_url)
.await
.change_context(AppError)?;
// Get API Keys from database
let api_keys = ApiKey::get_all_with_secret_attached(&pool, config)
.await
.change_context(AppError)?;
// Initialize API Backend
let backend = ApiBackend::new(pool.clone(), config);
// Create Session storage for API
let session = AuthSessionWithApiKeyBuilder::new()
.backend(backend)
.api_header_name(HeaderName::from_static("x-api-key"))
.initial_api_keys(api_keys)
.jwt_key_pair(JWTKeyPair::from_secret(&config.token_secret))
.session_length(Duration::from_secs(config.session_length))
.build()
.change_context(AppError)?;
// Create routes
let router = routes::create_routes(session);
// Define listener
let port = config.port;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port))
.await
.change_context(AppError)?;
// Serve API
let server = axum::serve(listener, router.into_make_service())
.with_graceful_shutdown(shutdown_signal(cancellation_token));
tokio::spawn(server.into_future());
info!("{APP_NAME} up and running on port '{port}'.");
Ok(())
}
async fn shutdown_signal(stop_signal: CancellationToken) {
// Wait for shutdown event.
stop_signal.cancelled().await;
info!("Shutting down {APP_NAME}...");
}
// // Set Report Colour Mode to NONE
// Report::set_color_mode(error_stack::fmt::ColorMode::None);
// // Enable the `INFO` level for anything in `darl` as default
// let level_filter =
// filter::Targets::new().with_target(env!("CARGO_PKG_NAME"), DEFAULT_LOG_LEVEL_FILTER);
// let (level_filter, tracing_target_reload_handle) = reload::Layer::new(level_filter);
// // Prepare logging to file
// let file_appender = RollingFileAppenderBase::new(
// ROOT_PATH.with_file_name(format!("{}.log", env!("CARGO_PKG_NAME"))),
// RollingConditionBase::new().max_size(1024 * 1024 * 2),
// 5,
// )
// .change_context(ServiceError::Starting)?;
// let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// // Prepare live logging to config webserver
// let (log_receiver, live_log_layer) = LiveLogLayer::new();
// // prepare initialization of logging
// let log_layers = tracing_subscriber::registry()
// .with(level_filter)
// // .with(filter::LevelFilter::DEBUG)
// .with(live_log_layer)
// .with(
// fmt::Layer::default()
// .with_target(false)
// .with_ansi(false)
// .with_writer(non_blocking),
// );
// // also log to console in debug mode
// #[cfg(debug_assertions)]
// let stdout_log = tracing_subscriber::fmt::layer().pretty();
// #[cfg(debug_assertions)]
// let log_layers = log_layers.with(stdout_log);
// // Initialize logging
// log_layers.init();
// // Initialize local database
// let local_database = LocalDatabase::init()
// .await
// .change_context(ServiceError::Starting)?;
// // Load configuration from config files
// let (config, external_database) = Configuration::initialize(&local_database)
// .await
// .change_context(ServiceError::Starting)?;
// let standalone_external_db = StandaloneExternalDatabase::from(&external_database);
// // change log level to configured value
// if let Err(error) = tracing_target_reload_handle.modify(|filter| {
// *filter = filter::Targets::new().with_target(env!("CARGO_PKG_NAME"), &config.log_level)
// }) {
// error!("{error}");
// }
// // prepare and start connections
// let connections = MachineConnections::init(&local_database, &standalone_external_db, &config)
// .await
// .change_context(ServiceError::Starting)?;
// // start config server
// ConfigServer::start(
// config.extended_config.webserver.config_server_port,
// config.opc_configuration.clone(),
// &local_database,
// standalone_external_db,
// connections,
// tracing_target_reload_handle,
// log_receiver,
// )
// .await;
// // start webserver
// WebServer::start(&config, &local_database).await;
// // initialize Logging to external database
// external_database.start_writer().await;
// info!("{APP_NAME} service is now running...");
// // block thread
// loop {
// // Poll shutdown event.
// if (stop_signal.recv().await).is_some() {
// // Break the loop either upon stop or channel disconnect
// info!("Shutting down {APP_NAME} service");
// break;
// };
// }
// Ok(())

View File

@ -0,0 +1,145 @@
use axum::{debug_handler, extract::Query, Extension, Json};
use models::ApiKey;
use serde::Deserialize;
use utoipa::IntoParams;
use utoipa_axum::{router::OpenApiRouter, routes};
use crate::{
api::{
backend::ApiBackend,
description::API_KEY_TAG,
routes::{
users::permissions::{Permission, PermissionDetail},
User,
},
},
errors::ApiError,
permission_required,
};
use super::AuthBackendType;
pub mod models;
pub mod sql;
// expose the OpenAPI to parent module
pub fn router() -> OpenApiRouter {
let read = OpenApiRouter::new()
.routes(routes!(get_api_keys))
.route_layer(permission_required!(Permission::Read(
PermissionDetail::APIKeys
)));
let write = OpenApiRouter::new()
.routes(routes!(create_api_key, update_api_key, delete_api_key))
.route_layer(permission_required!(Permission::Write(
PermissionDetail::APIKeys
)));
OpenApiRouter::new().merge(read).merge(write)
}
#[debug_handler]
#[utoipa::path(
get,
path = "/apikeys",
summary = "Get all API Keys",
description = "Get a list of all configured API Keys.",
responses(
(status = OK, body = Vec<ApiKey>, description = "List of API Keys"),
),
security(
("user_auth" = ["read:apikeys",]),
),
tag = API_KEY_TAG)]
pub async fn get_api_keys(
Extension(backend): Extension<ApiBackend>,
) -> Result<Json<Vec<ApiKey>>, ApiError> {
Ok(Json(sql::get_api_keys(backend.pool()).await?))
}
#[debug_handler]
#[utoipa::path(
post,
path = "/apikeys",
summary = "Create new API Key",
description = "Create a new API Key.",
request_body(content = ApiKey, description = "API Key details", content_type = "application/json"),
responses(
(status = OK, description = "API Key successfully created (API Key Secret in Body)", body = String),
),
security(
("user_auth" = ["write:apikeys",]),
),
tag = API_KEY_TAG)]
pub async fn create_api_key(
auth_session: AuthBackendType,
Json(api_key): Json<ApiKey>,
) -> Result<String, ApiError> {
let backend = auth_session.backend();
// create new API key
let (key_secret, key) = ApiKey::create(
&api_key.name,
api_key.auth_required,
api_key.permissions.0,
backend.config(),
)?;
// insert API Key into database
sql::create_api_key(backend.pool(), &key).await?;
// add API Key to session
auth_session.add_api_key(key).await;
// Return key secret in response
Ok(key_secret)
}
#[debug_handler]
#[utoipa::path(
put,
path = "/apikeys",
summary = "Update API Key",
description = "Update an API Key.",
request_body(content = ApiKey, description = "API Key details", content_type = "application/json"),
responses(
(status = OK, description = "API Key successfully updated"),
),
security(
("user_auth" = ["write:apikeys",]),
),
tag = API_KEY_TAG)]
pub async fn update_api_key(
Extension(backend): Extension<ApiBackend>,
Json(api_key): Json<ApiKey>,
) -> Result<(), ApiError> {
sql::update_api_key(backend.pool(), &api_key).await?;
Ok(())
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct DeleteQueryParameters {
key_id: String,
}
#[debug_handler]
#[utoipa::path(
delete,
path = "/apikeys",
summary = "Delete API Key",
description = "Delete an API Key.",
params(DeleteQueryParameters),
responses(
(status = OK, description = "API Key successfully deleted"),
),
security(
("user_auth" = ["write:apikeys",]),
),
tag = API_KEY_TAG)]
pub async fn delete_api_key(
Extension(backend): Extension<ApiBackend>,
Query(params): Query<DeleteQueryParameters>,
) -> Result<(), ApiError> {
sql::delete_api_key(backend.pool(), &params.key_id).await?;
Ok(())
}

View File

@ -0,0 +1,143 @@
use std::collections::HashSet;
use axum_jwt_login::ApiKey as ApiKeyTrait;
use chrono::NaiveDateTime;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha512;
use sqlx::PgPool;
use ts_rs::TS;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{
api::routes::users::permissions::{Permission, PermissionContainer},
config::Configuration,
errors::ApiError,
utils::create_random,
};
const KEY_LENGTH: usize = 40;
#[derive(Debug, Clone, Serialize, Deserialize, TS, ToSchema)]
#[ts(export)]
pub struct ApiKey {
pub id: String,
#[serde(skip)] // Don't leak Hash
pub hash: Vec<u8>,
pub name: String,
pub auth_required: bool,
#[schema(inline)]
pub permissions: PermissionContainer,
#[serde(skip)] // Don't leak secret
pub api_config_secret: Option<String>,
#[serde(default)]
#[schema(value_type = String, read_only)]
pub creation_date: Option<NaiveDateTime>,
#[serde(default)]
#[schema(value_type = String, read_only)]
pub last_change: Option<NaiveDateTime>,
}
impl ApiKey {
pub async fn get_all_with_secret_attached(
pool: &PgPool,
config: &Configuration,
) -> Result<Vec<Self>, ApiError> {
let mut keys = super::sql::get_api_keys(pool).await?;
for key in keys.iter_mut() {
key.api_config_secret = Some(config.token_secret.clone());
}
Ok(keys)
}
pub fn create(
name: &str,
requires_auth: bool,
permissions: HashSet<Permission>,
config: &Configuration,
) -> Result<(String, Self), ApiError> {
// create uuid
let uuid = Uuid::new_v4().simple();
// create API Key secret part
let key: String = create_random(KEY_LENGTH);
// calculate API Key signature
let mut mac = Hmac::<Sha512>::new_from_slice(config.token_secret.as_bytes())?;
mac.update(key.as_bytes());
let signature = mac.finalize();
// Return Api Key Secret and Api Key
Ok((
key,
Self {
name: name.to_string(),
auth_required: requires_auth,
id: uuid.to_string(),
hash: signature.into_bytes().as_slice().to_vec(),
permissions: PermissionContainer(permissions),
api_config_secret: Some(config.token_secret.clone()),
creation_date: None,
last_change: None,
},
))
}
pub fn validate(&self, key_secret_part: &str) -> Result<(), ApiError> {
// calculate API Key signature
let mut mac = Hmac::<Sha512>::new_from_slice(
self.api_config_secret
.clone()
.ok_or(ApiError::InternalError(
"Missing API Config Secret".to_string(),
))?
.as_bytes(),
)?;
mac.update(key_secret_part.as_bytes());
match mac.verify_slice(&self.hash) {
Ok(_) => Ok(()),
Err(_e) => Err(ApiError::AccessDenied),
}
}
}
impl ApiKeyTrait for ApiKey {
type Permission = Permission;
fn id(&self) -> String {
self.id.clone()
}
fn validate(&self, key_secret_part: &str) -> bool {
self.validate(key_secret_part).is_ok()
}
fn requires_user_auth(&self) -> bool {
self.auth_required
}
fn get_permissions(&self) -> HashSet<Self::Permission> {
self.permissions.0.clone()
}
}
#[cfg(test)]
mod test {
use std::collections::HashSet;
use crate::config::Configuration;
use super::ApiKey;
#[tokio::test]
async fn test_api_key_creation_and_validation() {
let config = Configuration {
token_secret: "abcdefghijk".to_string(),
..Default::default()
};
let (key, api_key) =
ApiKey::create("name", true, HashSet::new(), &config).expect("Error creating API Key");
println!("{key}, {api_key:?}");
assert_eq!(Ok(()), api_key.validate(&key));
assert_ne!(Ok(()), api_key.validate("124"));
}
}

View File

@ -0,0 +1,135 @@
use sqlx::{PgPool, Postgres, Transaction};
use crate::errors::ApiError;
use super::models::ApiKey;
pub async fn get_api_keys(pool: &PgPool) -> Result<Vec<ApiKey>, ApiError> {
Ok(sqlx::query_as!(
ApiKey,
r#"SELECT
APIKEYS."KeyID" as id,
APIKEYS."Name" as name,
APIKEYS."Hash" as hash,
APIKEYS."UserAuthRequired" as auth_required,
APIKEYS."CreationDate" as "creation_date?",
APIKEYS."LastChanged" as "last_change?",
NULL as api_config_secret,
array_remove(ARRAY_AGG(APIKEY_PERMISSIONS."Permission"), NULL) AS permissions
FROM
apikeys
LEFT JOIN APIKEY_PERMISSIONS ON APIKEY_PERMISSIONS."KeyID" = APIKEYS."KeyID"
GROUP BY APIKEYS."KeyID""#
)
.fetch_all(pool)
.await?)
}
pub async fn create_api_key(pool: &PgPool, api_key: &ApiKey) -> Result<(), ApiError> {
// start transaction
let mut transaction = pool.begin().await?;
// create api_key
sqlx::query!(
r#"INSERT INTO apikeys
("KeyID", "Name", "UserAuthRequired", "Hash")
VALUES ($1, $2, $3, $4)"#,
api_key.id,
api_key.name,
api_key.auth_required,
api_key.hash,
)
.execute(&mut *transaction)
.await?;
// update permissions
update_permissions(&mut transaction, &api_key).await?;
// commit transaction
transaction.commit().await?;
Ok(())
}
pub async fn update_api_key(pool: &PgPool, api_key: &ApiKey) -> Result<(), ApiError> {
// start transaction
let mut transaction = pool.begin().await?;
// create api_key
sqlx::query!(
r#"UPDATE apikeys SET
"Name" = $2,
"UserAuthRequired" = $3
WHERE "KeyID" = $1"#,
api_key.id,
api_key.name,
api_key.auth_required,
)
.execute(&mut *transaction)
.await?;
// update permissions
update_permissions(&mut transaction, &api_key).await?;
// commit transaction
transaction.commit().await?;
Ok(())
}
pub async fn update_permissions(
transaction: &mut Transaction<'static, Postgres>,
api_key: &ApiKey,
) -> Result<(), ApiError> {
// delete all permissions for API Key
sqlx::query!(
r#"DELETE FROM apikey_permissions
WHERE "KeyID" = $1"#,
api_key.id,
)
.execute(&mut **transaction)
.await?;
// insert permissions
for permission in &api_key.permissions.0 {
sqlx::query!(
r#"INSERT INTO apikey_permissions
("KeyID", "Permission")
VALUES ($1, $2)"#,
api_key.id,
permission.to_string()
)
.execute(&mut **transaction)
.await?;
}
Ok(())
}
pub async fn delete_api_key(pool: &PgPool, key_id: &str) -> Result<(), ApiError> {
// start transaction
let mut transaction = pool.begin().await?;
// Delete permissions
sqlx::query!(
r#"DELETE FROM apikey_permissions
WHERE "KeyID" = $1"#,
key_id,
)
.execute(&mut *transaction)
.await?;
// Delete API Key
sqlx::query!(
r#"DELETE FROM apikeys
WHERE "KeyID" = $1"#,
key_id,
)
.execute(&mut *transaction)
.await?;
// commit transaction
transaction.commit().await?;
Ok(())
}

View File

@ -0,0 +1,62 @@
use axum::{debug_handler, Json};
use models::Credentials;
use utoipa_axum::{router::OpenApiRouter, routes};
use crate::{api::description::AUTH_TAG, errors::ApiError};
use super::AuthBackendType;
pub mod models;
// expose the OpenAPI to parent module
pub fn router() -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(authorize))
.routes(routes!(logout))
}
#[debug_handler]
#[utoipa::path(
post,
path = "/login",
summary = "Authenticate as user",
description = "Authenticate as user and receive a JWT auth token.",
request_body(content = Credentials, description = "User credentials", content_type = "application/json"),
responses(
(status = OK, body = String, description = "Successfully logged in (JWT Token in body)"),
(status = UNAUTHORIZED, description = "Invalid credentials or unauthorized user")
),
tag = AUTH_TAG)]
pub async fn authorize(
mut auth_session: AuthBackendType,
Json(credentials): Json<Credentials>,
) -> Result<String, ApiError> {
let token = match auth_session.authenticate(credentials).await {
Ok(Some(_user)) => {
if let Some(token) = auth_session.get_auth_token() {
token
} else {
return Err(ApiError::InvalidCredentials);
}
}
Ok(None) => return Err(ApiError::InvalidCredentials),
Err(_) => return Err(ApiError::InvalidCredentials),
};
Ok(token)
}
#[debug_handler]
#[utoipa::path(
post,
path = "/logout",
summary = "Logout",
description = "Log the currently logged in user out.",
responses(
(status = OK, description = "Logout successful")
),
tag = AUTH_TAG)]
pub async fn logout(mut auth_session: AuthBackendType) -> Result<(), ApiError> {
auth_session.logout().await?;
Ok(())
}

View File

@ -0,0 +1,8 @@
use serde::Deserialize;
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct Credentials {
pub id: String,
pub password: String,
}

64
src/api/routes/mod.rs Normal file
View File

@ -0,0 +1,64 @@
pub mod api_keys;
pub mod auth;
pub mod users;
use api_keys::models::ApiKey as APIKey;
use axum::{Extension, Router};
use axum_jwt_login::AuthSessionWithApiKey;
use users::models::User;
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_redoc::{Redoc, Servable};
use utoipa_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi;
use super::{backend::ApiBackend, description::ApiDocumentation};
const API_BASE: &str = "/api";
type AuthBackendType = AuthSessionWithApiKey<User, ApiBackend, APIKey>;
#[macro_export]
macro_rules! login_required {
() => {
axum_jwt_login::login_required!(User, ApiBackend, APIKey)
};
}
#[macro_export]
macro_rules! permission_required {
($($perm:expr),+ $(,)?) => {
axum_jwt_login::permission_required!(
User,
ApiBackend,
crate::api::routes::APIKey,
$($perm),+
)
};
}
pub fn create_routes(session: AuthBackendType) -> Router {
let backend = session.backend();
let (router, api) = OpenApiRouter::with_openapi(ApiDocumentation::openapi())
// .routes(routes!(health))
.nest(API_BASE, auth::router())
.nest(API_BASE, users::router())
.nest(API_BASE, api_keys::router())
// .nest(
// "/api/order",
// // order::router().route_layer(crate::login_required!(AuthenticationBackend<ApiKey>)),
// order::router().route_layer(login_required!(User, auth_backend::AuthenticationBackend)),
// )
// .routes(routes!(
// inner::secret_handlers::get_secret,
// inner::secret_handlers::post_secret
// ))
// .nest("/api", users::router())
// // .layer(auth_layer)
.layer(session.into_layer())
.layer(Extension(backend))
.split_for_parts();
router
.merge(SwaggerUi::new("/swagger-ui").url("/apidoc/openapi.json", api.clone()))
.merge(Redoc::with_url("/redoc", api.clone()))
.merge(Scalar::with_url("/scalar", api))
}

159
src/api/routes/users/mod.rs Normal file
View File

@ -0,0 +1,159 @@
use axum::{debug_handler, Extension, Json};
use axum_jwt_login::UserPermissions;
use models::User;
use permissions::{Permission, PermissionDetail};
use utoipa_axum::{router::OpenApiRouter, routes};
use crate::{
api::{
backend::{
ldap::{ActiveDirectoryUser, LDAPBackend},
ApiBackend,
},
description::USERS_TAG,
routes::auth::models::Credentials,
},
errors::ApiError,
permission_required,
utils::create_random,
};
pub mod models;
pub mod permissions;
pub mod sql;
// expose the OpenAPI to parent module
pub fn router() -> OpenApiRouter {
let read = OpenApiRouter::new()
.routes(routes!(get_users))
.route_layer(permission_required!(Permission::Read(
PermissionDetail::Users
)));
let write = OpenApiRouter::new()
.routes(routes!(create_user, update_user))
.routes(routes!(get_ad_users))
.route_layer(permission_required!(Permission::Write(
PermissionDetail::Users
)));
OpenApiRouter::new().merge(read).merge(write)
}
#[debug_handler]
#[utoipa::path(
get,
path = "/users",
summary = "Get all Users",
description = "Get a list of all users.",
responses(
(status = OK, body = Vec<User>, description = "List of users"),
),
security(
("user_auth" = ["read:users",]),
),
tag = USERS_TAG)]
pub async fn get_users(
Extension(backend): Extension<ApiBackend>,
) -> Result<Json<Vec<User>>, ApiError> {
Ok(Json(sql::get_users(backend.pool(), None, None).await?))
}
#[debug_handler]
#[utoipa::path(
put,
path = "/users",
summary = "Change User details",
description = "Update user information / permissions / groups ",
request_body(content = User, description = "User details", content_type = "application/json"),
responses(
(status = OK, description = "User successfully updated"),
),
security(
("user_auth" = ["write:users"]),
),
tag = USERS_TAG)]
pub async fn update_user(
Extension(backend): Extension<ApiBackend>,
Json(user): Json<User>,
) -> Result<(), ApiError> {
sql::update_user(backend.pool(), &user).await?;
Ok(())
}
#[debug_handler]
#[utoipa::path(
post,
path = "/users",
summary = "Create a new User",
description = "Creates a new user with the given information ",
request_body(content = User, description = "User details", content_type = "application/json"),
responses(
(status = OK, description = "User successfully created (Assigned Password in body)", body = String),
),
security(
("user_auth" = ["write:users",]),
),
tag = USERS_TAG)]
pub async fn create_user(
Extension(backend): Extension<ApiBackend>,
Json(user): Json<User>,
) -> Result<String, ApiError> {
// create password if not Active Directory user
let (password, hash) = match user.active_directory_auth {
true => (String::new(), None),
false => {
let salt = create_random(20);
let argon_config = argon2::Config::default();
let password = create_random(10);
let password_hash =
argon2::hash_encoded(password.as_bytes(), salt.as_bytes(), &argon_config)?;
(password, Some(password_hash))
}
};
// create user
sql::create_new_user(backend.pool(), &user, hash).await?;
// send created password back to frontend
Ok(password)
}
#[debug_handler]
#[utoipa::path(
get,
path = "/users/available_ad_users",
summary = "Get Active Directory Users",
description = "Get all Available Users from the Active Directory that are not already registered with this API.",
responses(
(status = OK, body = Vec<ActiveDirectoryUser>, description = "List of AD users"),
),
security(
("user_auth" = ["write:users",]),
),
tag = USERS_TAG)]
pub async fn get_ad_users(
Extension(backend): Extension<ApiBackend>,
Json(credentials): Json<Option<Credentials>>,
) -> Result<Json<Vec<ActiveDirectoryUser>>, ApiError> {
let api_user_ids: Vec<String> = sql::get_users(backend.pool(), None, None)
.await?
.iter()
.map(|user| user.id())
.collect();
let mut ldap = LDAPBackend::from_config(backend.config()).await?;
// bind to AD user if credentials are given
if let Some(credentials) = credentials {
ldap.ad_bind(&credentials.id, &credentials.password).await?;
}
let ad_users = ldap
.get_ad_user_list()
.await?
.into_iter()
.filter(|entry| !api_user_ids.contains(&entry.id))
.collect();
// disconnect from AD server
ldap.unbind().await;
Ok(Json(ad_users))
}

View File

@ -0,0 +1,118 @@
use std::collections::{HashMap, HashSet};
use axum_jwt_login::UserPermissions;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use ts_rs::TS;
use utoipa::ToSchema;
use crate::errors::ApiError;
use super::{
permissions::{Permission, PermissionContainer},
sql,
};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, TS, ToSchema)]
#[repr(i16)]
pub enum UserStatus {
Deleted = -1,
Deactivated = 0,
Active = 1,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, ToSchema)]
pub struct GroupContainer(pub HashSet<i32>);
impl From<Option<Vec<i32>>> for GroupContainer {
fn from(value: Option<Vec<i32>>) -> Self {
let set = match value {
Some(values) => HashSet::from_iter(values),
None => HashSet::new(),
};
Self(set)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, ToSchema)]
#[ts(export)]
pub struct User {
pub user_id: String,
pub active_directory_auth: bool,
#[serde(skip)] // do not leak password hashes
pub password: String,
pub name: String,
pub surname: String,
pub email: String,
#[schema(inline)]
pub groups: GroupContainer,
#[schema(inline)]
pub group_permissions: PermissionContainer,
#[schema(inline)]
pub permissions: PermissionContainer,
pub status_flag: UserStatus,
#[serde(default)]
#[schema(value_type = String, read_only)]
pub creation_date: NaiveDateTime,
#[serde(default)]
#[schema(value_type = String, read_only)]
pub last_change: NaiveDateTime,
}
impl UserPermissions for User {
type Error = ApiError;
type Permission = Permission;
type Id = String;
fn id(&self) -> Self::Id {
self.user_id.clone()
}
fn get_user_permissions(&self) -> Result<HashSet<Self::Permission>, Self::Error> {
Ok(self.permissions.0.iter().cloned().collect())
}
fn get_group_permissions(&self) -> Result<HashSet<Self::Permission>, Self::Error> {
Ok(self.group_permissions.0.iter().cloned().collect())
}
}
impl User {
pub async fn update_with_ad_details(
&self,
pool: &PgPool,
details: HashMap<String, Vec<String>>,
) -> Result<(), ApiError> {
let create_error = |key: &str| {
ApiError::InternalError(format!(
"Update User Information: Information '{key}' is not present"
))
};
// get field values
let name = details
.get("givenName")
.ok_or(create_error("Name"))?
.first()
.ok_or(create_error("Name"))?;
let surname = details
.get("sn")
.ok_or(create_error("Surname"))?
.first()
.ok_or(create_error("Surname"))?;
let email = details
.get("mail")
.ok_or(create_error("Email"))?
.first()
.ok_or(create_error("Email"))?;
if name != &self.name || surname != &self.surname || email != &self.email {
let mut transaction = pool.begin().await?;
sql::update_user_details(&mut transaction, &self.id(), name, surname, email).await?;
transaction.commit().await?;
}
Ok(())
}
}

View File

@ -0,0 +1,102 @@
use std::{collections::HashSet, convert::Infallible, fmt::Display, str::FromStr};
use serde::{Deserialize, Serialize};
use strum::EnumString;
use ts_rs::TS;
use utoipa::ToSchema;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, ToSchema)]
pub enum Permission {
Read(PermissionDetail),
Write(PermissionDetail),
None,
}
impl FromStr for Permission {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((permission, detail)) = s.split_once(':') {
match permission {
"Read" => Ok(Self::Read(
PermissionDetail::from_str(detail).unwrap_or_default(),
)),
"Write" => Ok(Self::Write(
PermissionDetail::from_str(detail).unwrap_or_default(),
)),
_ => Ok(Self::None),
}
} else {
Ok(Self::None)
}
}
}
impl Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let string = match self {
Permission::Read(detail) => format!("Read:{detail}"),
Permission::Write(detail) => format!("Write:{detail}"),
Permission::None => "None".to_string(),
};
f.write_str(&string)
}
}
#[derive(
Debug,
Default,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
Hash,
EnumString,
strum::Display,
TS,
ToSchema,
)]
pub enum PermissionDetail {
Users,
APIKeys,
#[default]
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, ToSchema)]
pub struct PermissionContainer(pub HashSet<Permission>);
impl From<Option<Vec<String>>> for PermissionContainer {
fn from(value: Option<Vec<String>>) -> Self {
let set = match value {
Some(values) => HashSet::from_iter(
values
.iter()
.map(|s| Permission::from_str(s).unwrap_or(Permission::None)),
),
None => HashSet::new(),
};
Self(set)
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use crate::api::routes::users::permissions::PermissionDetail;
use super::Permission;
#[tokio::test]
async fn test_permissions() {
let permission = Permission::Read(PermissionDetail::Users);
assert_eq!("Read:Users", permission.to_string());
let parsed = Permission::from_str("Write:Users").unwrap();
assert_eq!(parsed, Permission::Write(PermissionDetail::Users));
}
}

184
src/api/routes/users/sql.rs Normal file
View File

@ -0,0 +1,184 @@
use sqlx::{PgPool, Postgres, Transaction};
use crate::errors::ApiError;
use super::models::{User, UserStatus};
pub async fn get_users(
pool: &PgPool,
filter_status: Option<UserStatus>,
filter_id: Option<String>,
) -> Result<Vec<User>, ApiError> {
Ok(sqlx::query_as!(
User,
r#"SELECT
USERS."UserID" as user_id,
USERS."ActiveDirectoryAuth" as active_directory_auth,
USERS."Password" as password,
USERS."Name" as name,
USERS."Surname" as surname,
USERS."Email" as email,
USERS."StatusFlag" as "status_flag: UserStatus",
USERS."CreationDate" as "creation_date",
USERS."LastChanged" as "last_change",
array_remove(ARRAY_AGG(USERS_GROUPS."GroupID"), NULL) AS groups,
array_remove(ARRAY_AGG(USER_PERMISSIONS."Permission"), NULL) AS permissions,
array_remove(ARRAY_AGG(GROUP_PERMISSIONS."Permission"), NULL) AS group_permissions
FROM
users
LEFT JOIN PUBLIC.USER_PERMISSIONS ON USER_PERMISSIONS."UserID" = USERS."UserID"
LEFT JOIN USERS_GROUPS ON USERS."UserID" = USERS_GROUPS."UserID"
LEFT JOIN GROUP_PERMISSIONS ON GROUP_PERMISSIONS."GroupID" = USERS_GROUPS."GroupID"
WHERE
($1::smallint IS NULL OR USERS."StatusFlag" = $1)
AND ($2::varchar IS NULL OR USERS."UserID" = $2)
GROUP BY USERS."UserID""#,
filter_status.map(|s| s as i16),
filter_id
)
.fetch_all(pool)
.await?)
}
pub async fn update_user(pool: &PgPool, user: &User) -> Result<(), ApiError> {
let mut transaction = pool.begin().await?;
// update general stuff
sqlx::query!(
r#"UPDATE users SET
"ActiveDirectoryAuth" = $2,
"StatusFlag" = $3,
"LastChanged" = NOW()
WHERE "UserID" = $1"#,
user.user_id,
user.active_directory_auth,
user.status_flag as i16,
)
.execute(&mut *transaction)
.await?;
// update user information
update_user_details(
&mut transaction,
&user.user_id,
&user.name,
&user.surname,
&user.email,
)
.await?;
// update permissions
update_user_permissions(&mut transaction, &user).await?;
// update groups
update_user_groups(&mut transaction, user).await?;
// commit transaction
transaction.commit().await?;
Ok(())
}
pub async fn update_user_details<'a>(
pool: &'a mut Transaction<'static, Postgres>,
user_id: &String,
name: &String,
surname: &String,
email: &String,
) -> Result<(), ApiError> {
// let test = **pool;
sqlx::query!(
r#"UPDATE users SET
"Name" = $2,
"Surname" = $3,
"Email" = $4
WHERE "UserID" = $1"#,
user_id,
name,
surname,
email
)
.execute(&mut **pool)
.await?;
Ok(())
}
pub async fn update_user_permissions<'a>(
pool: &'a mut Transaction<'static, Postgres>,
user: &User,
) -> Result<(), ApiError> {
// delete all permissions for user
sqlx::query!(
r#"DELETE FROM user_permissions
WHERE "UserID" = $1"#,
user.user_id,
)
.execute(&mut **pool)
.await?;
// insert permissions
for permission in &user.permissions.0 {
sqlx::query!(
r#"INSERT INTO user_permissions
("UserID", "Permission")
VALUES ($1, $2)"#,
user.user_id,
permission.to_string()
)
.execute(&mut **pool)
.await?;
}
Ok(())
}
pub async fn update_user_groups<'a>(
pool: &'a mut Transaction<'static, Postgres>,
user: &User,
) -> Result<(), ApiError> {
// delete all permissions for user
sqlx::query!(
r#"DELETE FROM users_groups
WHERE "UserID" = $1"#,
user.user_id,
)
.execute(&mut **pool)
.await?;
// insert permissions
for group_id in &user.groups.0 {
sqlx::query!(
r#"INSERT INTO users_groups
("UserID", "GroupID")
VALUES ($1, $2)"#,
user.user_id,
group_id
)
.execute(&mut **pool)
.await?;
}
Ok(())
}
pub async fn create_new_user(
pool: &PgPool,
user: &User,
password: Option<String>,
) -> Result<(), ApiError> {
let mut transaction = pool.begin().await?;
sqlx::query!(
r#"INSERT INTO users
("UserID", "Password")
VALUES ($1, $2)"#,
user.user_id,
password
)
.execute(&mut *transaction)
.await?;
// commit transaction
transaction.commit().await?;
Ok(())
}

View File

@ -0,0 +1,69 @@
use rand::{distributions::Alphanumeric, Rng};
use sha2::{Digest, Sha512};
use uuid::Uuid;
pub(crate) const KEY_LENGTH: usize = 40;
pub trait ApiKeyInfo {
fn id(&self) -> String;
fn validate(&self, hash: &str) -> bool;
fn requires_auth(&self) -> bool;
}
impl ApiKeyInfo for ApiKey {
fn id(&self) -> String {
self.id.to_string()
}
fn validate(&self, hash: &str) -> bool {
let mut sha512 = Sha512::new();
sha512.update(hash);
format!("{:X}", sha512.finalize()) == self.hash
}
fn requires_auth(&self) -> bool {
self.auth_required
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub(crate) struct ApiKey {
pub(crate) id: String,
pub(crate) key: Option<String>,
pub(crate) hash: String,
pub(crate) name: String,
pub(crate) auth_required: bool,
}
impl ApiKey {
pub(crate) fn create(name: &str, requires_auth: bool) -> Self {
let uuid = Uuid::new_v4().simple();
let key: String = create_random(KEY_LENGTH);
let mut sha512 = Sha512::new();
sha512.update(key.clone());
ApiKey {
id: uuid.to_string(),
key: Some(key.to_owned()),
hash: format!("{:X}", sha512.finalize()),
auth_required: requires_auth,
name: name.to_string(),
}
}
pub(crate) fn validate(&self, password: &str) -> bool {
let mut sha512 = Sha512::new();
sha512.update(password);
format!("{:X}", sha512.finalize()) == self.hash
}
}
pub(crate) fn create_random(len: usize) -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(len)
.map(char::from)
.collect()
}

View File

@ -0,0 +1,27 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use super::AuthenticationBackend;
#[async_trait]
impl<S, T> FromRequestParts<S> for AuthenticationBackend<T>
where
S: Send + Sync,
T: Send + Sync + Clone + 'static,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticationBackend<T>>()
.cloned()
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Can't extract auth session. Is `AuthenticationLayer` enabled?",
))
}
}

112
src/authentication/jwt.rs Normal file
View File

@ -0,0 +1,112 @@
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use jsonwebtoken::{decode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use super::user::User;
// Session length in seconds -> 12 hours
pub(crate) const DEFAULT_SESSION_LENGTH: std::time::Duration = Duration::from_secs(12 * 3600);
#[derive(Clone)]
pub struct JwtAuthorizer {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
algorithm: Algorithm,
}
// Here we've implemented `Debug` manually to avoid accidentally logging the keys.
impl std::fmt::Debug for JwtAuthorizer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtAuthorizer")
.field("encoding_key", &"[redacted]")
.field("decoding_key", &"[redacted]")
.field("algorithm", &self.algorithm)
.finish()
}
}
impl JwtAuthorizer {
pub fn new(encoding_key: EncodingKey, decoding_key: DecodingKey, algorithm: Algorithm) -> Self {
Self {
encoding_key,
decoding_key,
algorithm,
}
}
pub fn encode(&self, user: User) -> Result<String, jsonwebtoken::errors::Error> {
let claims = RegisteredClaims::new(user);
jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)
}
pub fn decode(&self, token: &str) -> Result<User, jsonwebtoken::errors::Error> {
decode::<RegisteredClaims<User>>(
&token,
&self.decoding_key,
&Validation::new(self.algorithm),
)
.map(|data| data.claims.data)
}
}
/// Claims mentioned in the JWT specifications.
///
/// <https://www.rfc-editor.org/rfc/rfc7519#section-4.1>
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct RegisteredClaims<T> {
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<NumericDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<NumericDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<NumericDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
pub data: T,
}
impl<T> RegisteredClaims<T> {
pub fn new(data: T) -> Self {
let now = Utc::now();
let exp = now + DEFAULT_SESSION_LENGTH;
Self {
iss: Some(env!("CARGO_PKG_NAME").to_string()),
sub: Some("User token".to_string()),
exp: Some(exp.into()),
nbf: Some(now.into()),
iat: Some(now.into()),
jti: None,
data,
}
}
}
/// The number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time ignoring leap seconds.
/// (<https://www.rfc-editor.org/rfc/rfc7519#section-2>)
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)]
pub struct NumericDate(pub i64);
/// accesses the underlying value
impl From<NumericDate> for i64 {
fn from(t: NumericDate) -> Self {
t.0
}
}
impl From<DateTime<Utc>> for NumericDate {
fn from(t: DateTime<Utc>) -> Self {
Self(t.timestamp())
}
}
impl From<NumericDate> for DateTime<Utc> {
fn from(t: NumericDate) -> Self {
Utc.timestamp_opt(t.0, 0).unwrap()
}
}

191
src/authentication/layer.rs Normal file
View File

@ -0,0 +1,191 @@
use std::{
future::Future,
pin::Pin,
task::{Context, Poll},
};
use axum::{
extract::Request,
http::{self, header::AUTHORIZATION, HeaderMap, HeaderName, Response},
};
use tower_layer::Layer;
use tower_service::Service;
use tracing::Instrument;
use super::{api_key::ApiKeyInfo, AuthenticationBackend, AuthenticationData, UserAuthData};
#[derive(Debug, Clone)]
pub struct AuthenticationLayer<T> {
backend: AuthenticationBackend<T>,
}
impl<T> AuthenticationLayer<T> {
pub fn new(backend: AuthenticationBackend<T>) -> Self {
Self { backend }
}
}
impl<S, T> Layer<S> for AuthenticationLayer<T>
where
T: Clone,
{
type Service = AuthenticationService<S, T>;
fn layer(&self, service: S) -> Self::Service {
AuthenticationService {
backend: self.backend.clone(),
service,
}
}
}
// This service implements the Log behavior
#[derive(Debug, Clone)]
pub struct AuthenticationService<S, T> {
backend: AuthenticationBackend<T>,
service: S,
}
impl<ReqBody, ResBody, S, T> Service<Request<ReqBody>> for AuthenticationService<S, T>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
ResBody: Default + Send,
T: ApiKeyInfo + Send + Sync + Clone + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
#[inline]
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
let span = tracing::info_span!("call", user.id = tracing::field::Empty);
let mut backend = self.backend.clone();
// Because the inner service can panic until ready, we need to ensure we only
// use the ready service.
//
// See: https://docs.rs/tower/latest/tower/trait.Service.html#be-careful-when-cloning-inner-services
let clone = self.service.clone();
let mut inner = std::mem::replace(&mut self.service, clone);
Box::pin(
async move {
// check for api key if api key requirement is enabled
let bearer_required = if let Some(ref header) = backend.api_key_header {
match extract_api_key(req.headers(), header) {
Some((id, hash)) => {
// get api key information
if let Some(key) = backend.session_store.get_api_key(&id).await {
// validate api key
if !key.validate(&hash) {
tracing::error!("API Key invalid");
let mut res = Response::default();
*res.status_mut() = http::StatusCode::UNAUTHORIZED;
return Ok(res);
}
// check if further authentication is required
if key.requires_auth() {
true
} else {
// add API Key to authenticated information
backend.authenticated_user =
Some(AuthenticationData::ApiKey(key));
false
}
} else {
let mut res = Response::default();
*res.status_mut() = http::StatusCode::UNAUTHORIZED;
return Ok(res);
}
}
None => {
let mut res = Response::default();
*res.status_mut() = http::StatusCode::UNAUTHORIZED;
return Ok(res);
}
}
} else {
true
};
// Authenticate with bearer if required
if bearer_required {
// try to extract Authentication Bearer from headers
let bearer = extract_bearer(req.headers());
// check if bearer is present
if let Some(bearer) = bearer {
// check if bearer belongs to active session
if backend.session_store.includes(&bearer).await {
// try to decode bearer
match backend.jwt_authorizer.decode(&bearer) {
Ok(user) => {
backend.authenticated_user =
Some(AuthenticationData::User(UserAuthData {
user,
token: bearer,
}));
// tracing::Span::current().record("user.id", user.id().to_string());
}
Err(err) => {
// remove token from session storage
backend.session_store.remove(&bearer).await;
// log error
tracing::error!("Error decoding JWT token: {err}");
// return internal server error
let mut res = Response::default();
*res.status_mut() = http::StatusCode::INTERNAL_SERVER_ERROR;
return Ok(res);
}
}
}
}
}
req.extensions_mut().insert(backend);
inner.call(req).await
}
.instrument(span),
)
}
}
fn extract_bearer(headers: &HeaderMap) -> Option<String> {
// Check that its a well-formed bearer and return
headers
.get(AUTHORIZATION)
.map(|header| {
let split = header.to_str().unwrap_or("").split_once(' ');
let bearer = match split {
// Found proper bearer
Some((name, contents)) if name == "Bearer" => Some(contents.to_string()),
// Found nothing
_ => None,
};
bearer
})
.flatten()
}
fn extract_api_key(headers: &HeaderMap, name: &HeaderName) -> Option<(String, String)> {
// Check that its a well-formed bearer and return
headers
.get(name)
.map(|header| {
let split = header.to_str().unwrap_or("").split_once(".");
let api_key = match split {
Some((id, hash)) => Some((id.to_string(), hash.to_string())),
_ => None,
};
api_key
})
.flatten()
}

View File

@ -0,0 +1,76 @@
/// Login predicate middleware.
///
/// Requires that the user is authenticated.
#[macro_export]
macro_rules! login_required {
($backend_type:ty) => {{
async fn is_authenticated(
auth_session: $crate::axum_test::AuthenticationBackend<$backend_type>,
) -> bool {
auth_session.is_authenticated()
}
$crate::predicate_required!(is_authenticated, axum::http::StatusCode::UNAUTHORIZED)
}};
}
/// Permission predicate middleware.
///
/// Requires that the specified permissions, either user or group or both, are
/// all assigned to the user.
#[macro_export]
macro_rules! permission_required {
($backend_type:ty, login_url = $login_url:expr, redirect_field = $redirect_field:expr, $($perm:expr),+ $(,)?) => {{
use $crate::AuthzBackend;
async fn is_authorized(auth_session: $crate::axum_test::AuthSession<$backend_type>) -> bool {
if let Some(ref user) = auth_session.user {
let mut has_all_permissions = true;
$(
has_all_permissions = has_all_permissions &&
auth_session.backend.has_perm(user, $perm.into()).await.unwrap_or(false);
)+
has_all_permissions
} else {
false
}
}
$crate::predicate_required!(
is_authorized,
login_url = $login_url,
redirect_field = $redirect_field
)
}};
}
/// Predicate middleware.
///
/// Can be specified with a login URL and next redirect field or an alternative
/// which implements [`IntoResponse`](axum::response::IntoResponse).
///
/// When the predicate passes, the request processes normally. On failure,
/// either a redirect to the specified login URL is issued or the alternative is
/// used as the response.
#[macro_export]
macro_rules! predicate_required {
($predicate:expr, $alternative:expr) => {{
use axum::{
extract::Request,
middleware::{from_fn, Next},
response::IntoResponse,
};
from_fn(
|auth_session: $crate::axum_test::AuthenticationBackend<_>,
req: Request,
next: Next| async move {
if $predicate(auth_session).await {
next.run(req).await
} else {
$alternative.into_response()
}
},
)
}};
}

123
src/authentication/mod.rs Normal file
View File

@ -0,0 +1,123 @@
pub mod api_key;
mod extract;
mod jwt;
pub mod layer;
mod middleware;
mod sessions;
mod user;
// use api_key::ApiKey;
use axum::http::HeaderName;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
use jwt::JwtAuthorizer;
use sessions::SessionStore;
use user::User;
#[derive(Debug, Clone)]
pub struct AuthenticationBackend<T> {
sql_pool: (),
session_store: SessionStore<T>,
jwt_authorizer: JwtAuthorizer,
api_key_header: Option<HeaderName>,
authenticated_user: Option<AuthenticationData<T>>,
}
impl<T> AuthenticationBackend<T> {
pub fn is_authenticated(&self) -> bool {
self.authenticated_user.is_some()
}
}
#[derive(Debug, Clone)]
pub enum AuthenticationData<T> {
User(UserAuthData),
ApiKey(T),
}
#[derive(Debug, Clone)]
pub struct UserAuthData {
user: User,
token: String,
}
#[derive(Clone, Default)]
pub struct AuthenticationBackendBuilder<ApiKey> {
pool: Option<()>,
api_header_key: Option<HeaderName>,
loaded_api_keys: Option<Vec<ApiKey>>,
jwt_decode_key: Option<DecodingKey>,
jwt_encoding_key: Option<EncodingKey>,
jwt_algorithm: Option<Algorithm>,
}
impl<ApiKey> AuthenticationBackendBuilder<ApiKey> {
pub fn new() -> Self {
Self {
pool: None,
api_header_key: None,
loaded_api_keys: None,
jwt_decode_key: None,
jwt_encoding_key: None,
jwt_algorithm: None,
}
}
pub fn pool(self, pool: ()) -> Self {
let mut me = self;
me.pool = Some(pool);
me
}
pub fn use_api_key(self, key_header_name: HeaderName) -> Self {
let mut me = self;
me.api_header_key = Some(key_header_name);
me
}
pub fn api_keys(self, keys: Vec<ApiKey>) -> Self {
let mut me = self;
me.loaded_api_keys = Some(keys);
me
}
pub fn jwt_decoding_key(self, key: DecodingKey) -> Self {
let mut me = self;
me.jwt_decode_key = Some(key);
me
}
pub fn jwt_encoding_key(self, key: EncodingKey) -> Self {
let mut me = self;
me.jwt_encoding_key = Some(key);
me
}
pub fn jwt_algorithm(self, algorithm: Algorithm) -> Self {
let mut me = self;
me.jwt_algorithm = Some(algorithm);
me
}
pub fn build(self) -> Result<AuthenticationBackend<ApiKey>, String> {
if self.pool.is_none() {
return Err("Missing Pool".to_string());
}
if self.jwt_decode_key.is_none() {
return Err("Missing JWT Decoding key".to_string());
}
if self.jwt_encoding_key.is_none() {
return Err("Missing JWT Encoding key".to_string());
}
Ok(AuthenticationBackend {
sql_pool: self.pool.unwrap(),
session_store: SessionStore::new(self.loaded_api_keys),
jwt_authorizer: JwtAuthorizer::new(
self.jwt_encoding_key.unwrap(),
self.jwt_decode_key.unwrap(),
self.jwt_algorithm.unwrap_or(Algorithm::RS256),
),
api_key_header: self.api_header_key,
authenticated_user: None,
})
}
}

View File

@ -0,0 +1,54 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use tokio::sync::Mutex;
// use super::ApiKey;
#[derive(Debug, Clone)]
pub struct SessionStore<T> {
active_sessions: Arc<Mutex<HashSet<String>>>,
active_api_keys: Option<Arc<Mutex<HashMap<String, T>>>>,
}
impl<T> SessionStore<T> {
pub fn new(initial_api_keys: Option<Vec<T>>) -> Self {
Self {
active_sessions: Arc::new(Mutex::new(HashSet::new())),
active_api_keys: None, //initial_api_keys.map(|keys| {
// Arc::new(Mutex::new(HashMap::from_iter(
// keys.into_iter().map(|key| (key.id.clone(), key)),
// )))
// }),
}
}
pub async fn insert(&self, token: impl Into<String>) {
let mut sessions = self.active_sessions.lock().await;
sessions.insert(token.into());
}
pub async fn includes(&self, token: &str) -> bool {
let sessions = self.active_sessions.lock().await;
sessions.contains(token)
}
pub async fn remove(&self, token: &str) -> bool {
let mut sessions = self.active_sessions.lock().await;
sessions.remove(token)
}
pub async fn get_api_key(&self, key: &str) -> Option<T>
where
T: Clone,
{
if let Some(ref key_store) = self.active_api_keys {
let lock = key_store.lock().await;
lock.get(key).cloned()
} else {
None
}
}
}

View File

@ -0,0 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {}

310
src/cli.rs Normal file
View File

@ -0,0 +1,310 @@
use std::{collections::HashSet, path::PathBuf};
use clap::{command, Parser, Subcommand};
use error_stack::{Report, ResultExt};
use sqlx::PgPool;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio_util::sync::CancellationToken;
use crate::{
api::{
self,
routes::api_keys::{models::ApiKey, sql::create_api_key},
},
config::Configuration,
errors::AppError,
APP_NAME, ROOT_PATH,
};
#[derive(Parser, Debug)]
#[command(name = APP_NAME, version = "1.0", about = "A tool with subcommands")]
pub struct Cli {
#[arg(short, long, value_name = "FILE", help = "Configuration file location")]
config: Option<PathBuf>,
/// Subcommands for specific actions
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Debug, Subcommand)]
enum Commands {
Serve {
/// lists test values
#[arg(short, long)]
port: Option<u16>,
},
Migrate,
#[command(subcommand)]
Service(ServiceCommands),
#[command(subcommand)]
Create(CreateCommands),
}
#[derive(Debug, Subcommand)]
enum ServiceCommands {
Install,
Uninstall,
Run,
}
#[derive(Debug, Subcommand)]
enum CreateCommands {
ApiKey {
/// name of API Key
#[arg(short, long)]
name: String,
/// name of API Key
#[arg(short, long, default_value = "true")]
requires_auth: bool,
},
}
impl Cli {
pub fn config(&self) -> PathBuf {
match self.config.as_ref() {
Some(path) => path.clone(),
None => ROOT_PATH.with_file_name("config.toml"),
}
}
pub async fn handle(
&self,
config: &mut Configuration,
) -> Result<DaemonStatus, Report<AppError>> {
match &self.command {
Some(command) => match command {
Commands::Serve { port } => {
if let Some(port) = port {
config.port = *port;
}
start_service(config, None).await
}
Commands::Migrate => {
let pool = PgPool::connect(&config.database_url)
.await
.change_context(AppError)?;
sqlx::migrate!("src/migrations")
.run(&pool)
.await
.change_context(AppError)?;
Ok(DaemonStatus::NotRunning)
}
Commands::Service(service_commands) => match service_commands {
ServiceCommands::Install => {
// TODO do things
Ok(DaemonStatus::NotRunning)
}
ServiceCommands::Uninstall => {
// TODO do things
Ok(DaemonStatus::NotRunning)
}
ServiceCommands::Run => {
// TODO do things
// create shutdown signals
// let (shutdown_send, mut shutdown_recv) = mpsc::unbounded_channel();
// Register generated `ffi_service_main` with the system and start the service, blocking
// this thread until the service is stopped.
#[cfg(windows)]
let _ = service_dispatcher::start(env!("CARGO_PKG_NAME"), ffi_service_main);
Ok(DaemonStatus::NotRunning)
}
},
Commands::Create(create_commands) => match create_commands {
CreateCommands::ApiKey {
name,
requires_auth,
} => {
// create API Key
let (key_secret, key) =
ApiKey::create(name, *requires_auth, HashSet::new(), config)
.change_context(AppError)?;
// Add API Key to Database
let pool = PgPool::connect(&config.database_url)
.await
.change_context(AppError)?;
create_api_key(&pool, &key).await.change_context(AppError)?;
// print API key secret to console
println!("Created API Key: {}.{key_secret}", key.id);
Ok(DaemonStatus::NotRunning)
}
},
},
None => start_service(config, None).await,
}
}
}
pub enum DaemonStatus {
NotRunning,
Running((CancellationToken, Option<UnboundedReceiver<()>>)),
}
async fn start_service(
config: &Configuration,
shutdown_signal: Option<UnboundedReceiver<()>>,
) -> Result<DaemonStatus, Report<AppError>> {
// create cancellation token
let cancellation_token = CancellationToken::new();
api::start(config, cancellation_token.clone()).await?;
Ok(DaemonStatus::Running((cancellation_token, shutdown_signal)))
}
#[cfg(windows)]
mod windows {
use error_stack::{Report, ResultExt};
use std::{ffi::OsString, thread, time::Duration};
use windows_service::{
service::{
ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceState,
ServiceType,
},
service_manager::{ServiceManager, ServiceManagerAccess},
};
use crate::errors::AppError;
use crate::APP_NAME;
pub fn install() -> Result<(), Report<AppError>> {
let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)
.change_context(AppError)?;
// This example installs the service defined in `examples/ping_service.rs`.
// In the real world code you would set the executable path to point to your own binary
// that implements windows service.
let service_binary_path = ::std::env::current_exe()
.change_context(AppError)?
.with_file_name(format!("{}.exe", env!("CARGO_PKG_NAME")));
let service_info = ServiceInfo {
name: OsString::from(env!("CARGO_PKG_NAME")),
display_name: OsString::from(&format!("{APP_NAME}")),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: service_binary_path,
launch_arguments: vec!["service run".into()],
dependencies: vec![],
account_name: None, // run as System
account_password: None,
};
let service = service_manager
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)
.into_report()
.change_context(AppError)?;
service
.set_description(format!("{APP_NAME}"))
.into_report()
.change_context(AppError)?;
Ok(())
}
pub fn remove() -> Result<(), Report<AppError>> {
let manager_access = ServiceManagerAccess::CONNECT;
let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)
.change_context(AppError)?;
let service_access =
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE;
let service = service_manager
.open_service(env!("CARGO_PKG_NAME"), service_access)
.change_context(AppError)?;
let service_status = service.query_status().change_context(AppError)?;
if service_status.current_state != ServiceState::Stopped {
service.stop().change_context(AppError)?;
// Wait for service to stop
thread::sleep(Duration::from_secs(1));
}
service.delete().into_report().change_context(AppError)?;
Ok(())
}
// Service entry function which is called on background thread by the system with service
// parameters. There is no stdout or stderr at this point so make sure to configure the log
// output to file if needed.
pub fn service_main(_arguments: Vec<OsString>) {
if let Err(_e) = run_service() {
// Handle the error, by logging or something.
}
}
fn run_service() -> Result<(), Report<AppError>> {
// Create a channel to be able to poll a stop event from the service worker loop.
let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel(1);
// Define system service event handler that will be receiving service events.
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
// Notifies a service to report its current status information to the service
// control manager. Always return NoError even if not implemented.
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
// Handle stop
ServiceControl::Stop => {
shutdown_tx.try_send(()).unwrap();
ServiceControlHandlerResult::NoError
}
_ => ServiceControlHandlerResult::NotImplemented,
}
};
// Register system service event handler.
// The returned status handle should be used to report service status changes to the system.
let status_handle =
service_control_handler::register(env!("CARGO_PKG_NAME"), event_handler)
.change_context(AppError)?;
// Tell the system that service is running
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.change_context(AppError)?;
// run service - blocking thread
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
if let Err(e) = execute(shutdown_rx).await {
error!("{e:?}");
}
});
// Tell the system that service has stopped.
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.into_report()
.change_context(ServiceError::Starting)?;
Ok(())
}
}

88
src/config.rs Normal file
View File

@ -0,0 +1,88 @@
use std::path::PathBuf;
use config::{Config, Environment, File};
use error_stack::{Report, ResultExt};
use serde::Deserialize;
use crate::errors::AppError;
fn default_port() -> u16 {
8080
}
#[derive(Debug, Deserialize, Default, Clone)]
#[allow(unused)]
pub struct ConfigInfo {
pub location: Option<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct LDAP {
pub server: String,
#[serde(default)]
pub skip_tls_verify: bool,
#[serde(default)]
pub ad_domain: String,
#[serde(default)]
pub user_search_base: String,
#[serde(default)]
pub elevated_search: bool,
#[serde(default)]
pub elevated_user_id: String,
#[serde(default)]
pub elevated_user_pw: String,
}
#[derive(Debug, Deserialize, Default, Clone)]
#[allow(unused)]
pub struct Configuration {
#[serde(default)]
pub config: ConfigInfo,
#[serde(default)]
pub debug: bool,
#[serde(default)]
pub token_secret: String,
#[serde(default)]
pub session_length: u64,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
#[serde(alias = "DATABASE_URL")]
pub database_url: String,
#[serde(default)]
pub ldap: LDAP,
}
impl Configuration {
pub fn new(location: PathBuf) -> Result<Self, Report<AppError>> {
let s = Config::builder()
.add_source(File::from(location.clone()))
.add_source(Environment::default())
.set_override("config.location", location.to_str().unwrap_or("default"))
.change_context(AppError)?
.build()
.change_context(AppError)?;
let settings = s.try_deserialize().change_context(AppError)?;
Ok(settings)
}
pub fn check(&self) -> Result<(), Report<AppError>> {
// check if elevated LDAP user is set if activated
if self.ldap.elevated_search {
if self.ldap.elevated_user_id == "" {
return Err(Report::new(AppError).attach_printable(
"LDAP: Search with elevated user is activated but no user is configured",
));
}
if self.ldap.elevated_user_pw == "" {
return Err(Report::new(AppError).attach_printable(
"LDAP: Search with elevated user is activated but no password is configured",
));
}
}
Ok(())
}
}

124
src/errors.rs Normal file
View File

@ -0,0 +1,124 @@
use std::fmt::{self, Display};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use error_stack::Context;
use serde::Serialize;
use sha2::digest::InvalidLength;
use tracing::error;
use crate::api::{backend::ApiBackend, routes::users::models::User};
#[derive(Debug)]
pub struct AppError;
impl fmt::Display for AppError {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt.write_str("Error on executing Application")
}
}
impl Context for AppError {}
#[derive(Debug, Serialize)]
pub struct ErrorInfo<'a> {
error_message: &'a str,
debug_info: Option<&'a String>,
}
#[derive(Debug, PartialEq)]
pub enum ApiError {
SQLQueryError(String),
InvalidCredentials,
InternalError(String),
AccessDenied,
}
impl ApiError {
fn as_error_info(&self) -> (StatusCode, ErrorInfo) {
let (status, error_message, debug_info) = match self {
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials", None),
Self::SQLQueryError(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid credentials",
Some(error),
),
Self::InternalError(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
Some(error),
),
Self::AccessDenied => (StatusCode::FORBIDDEN, "Access Denied", None), // ApiError::WrongCredentials => (StatusCode::UNAUTHORIZED, "Wrong credentials"),
// ApiError::MissingCredentials => (StatusCode::BAD_REQUEST, "Missing credentials"),
// ApiError::TokenCreation => (StatusCode::INTERNAL_SERVER_ERROR, "Token creation error"),
// ApiError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid token"),
// ApiError::InvalidApiKey => (StatusCode::BAD_REQUEST, "Invalid api key"),
// ApiError::InternalServerError => {
// (StatusCode::INTERNAL_SERVER_ERROR, "Unspecified error")
// }
// ApiError::AccessDenied => (StatusCode::UNAUTHORIZED, "Access denied"),
// ApiError::InvalidPermissions => (StatusCode::BAD_REQUEST, "Invalid permissions"),
};
(
status,
ErrorInfo {
error_message,
debug_info,
},
)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_info) = self.as_error_info();
let body = Json(error_info);
(status, body).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(error: sqlx::Error) -> Self {
error!("{error}");
Self::SQLQueryError(error.to_string())
}
}
impl From<axum_jwt_login::Error<User, ApiBackend>> for ApiError {
fn from(value: axum_jwt_login::Error<User, ApiBackend>) -> Self {
match value {
axum_jwt_login::Error::Jwt(error) => ApiError::InternalError(error.to_string()),
axum_jwt_login::Error::Backend(error) => error,
_ => unreachable!(),
}
}
}
impl From<argon2::Error> for ApiError {
fn from(_: argon2::Error) -> Self {
Self::InvalidCredentials
}
}
impl From<InvalidLength> for ApiError {
fn from(value: InvalidLength) -> Self {
Self::InternalError(format!("Invalid HMac Key length: {value}"))
}
}
impl Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let error_info = self.as_error_info().1;
f.write_str(&format!(
"{}: {}",
error_info.error_message,
error_info.debug_info.unwrap_or(&"".to_string())
))
}
}
impl std::error::Error for ApiError {}

55
src/ldap_test.rs Normal file
View File

@ -0,0 +1,55 @@
use ldap3::exop::WhoAmI;
use ldap3::{LdapConnAsync, Scope, SearchEntry};
pub async fn try_authentication() -> ldap3::result::Result<()> {
println!("Hello, world!");
// let (conn, mut ldap) = LdapConnAsync::new("ldap://ldap.forumsys.com:389").await?;
let (conn, mut ldap) = LdapConnAsync::new("ldap://192.168.10.200:389").await?;
ldap3::drive!(conn);
let res = ldap
// .simple_bind("cn=read-only-admin,dc=example,dc=com", "password")
.simple_bind(
// "CN=Abel Austin,OU=Accounting,OU=Mylab Users,DC=mylab,DC=local",
"MYLAB\\A0H67123",
"Passwort123",
)
.await?;
println!("{res:?}");
// let (res, re) = ldap
// .search(
// "ou=mathematicians,dc=example,dc=com",
// Scope::Subtree,
// "(objectClass=*)",
// vec!["*"],
// )
// .await?
// .success()?;
// for entry in res {
// println!("{:?}", SearchEntry::construct(entry));
// }
let (res, re) = ldap
.search(
// "CN=Abel Austin,OU=Accounting,OU=Mylab Users,DC=mylab,DC=local",
"OU=Mylab Users,DC=mylab,DC=local",
Scope::Subtree,
// "(objectClass=*)",
"(&(ObjectCategory=Person)(sAMAccountName=A0H67123))",
vec!["givenName", "sn"],
)
.await?
.success()?;
for entry in res {
println!("{:?}", SearchEntry::construct(entry));
}
let test = ldap.extended(WhoAmI).await?.0;
let whoami: ldap3::exop::WhoAmIResp = test.parse();
println!("{whoami:?}");
Ok(ldap.unbind().await?)
}

96
src/main.rs Normal file
View File

@ -0,0 +1,96 @@
use std::{path::PathBuf, time::Duration};
use clap::Parser;
use error_stack::Report;
use errors::AppError;
use once_cell::sync::Lazy;
use tokio::signal;
use tokio_util::task::TaskTracker;
use tracing::{error, info};
pub mod api;
mod cli;
mod config;
mod errors;
mod utils;
pub static ROOT_PATH: Lazy<PathBuf> = Lazy::new(|| match std::env::current_exe() {
#[cfg(not(debug_assertions))]
Ok(path) => path,
#[cfg(debug_assertions)]
Ok(_) => PathBuf::from("./"),
Err(e) => {
error!("Exiting on error for getting root path: {e:?}");
panic!("Exiting on error for getting root path: {e:?}");
}
});
pub const APP_NAME: &str = "Generic API Service";
#[tokio::main]
async fn main() -> Result<(), Report<AppError>> {
dotenv::dotenv().ok();
// prepare CLI
let cli = cli::Cli::parse();
// load config file
let mut config = config::Configuration::new(cli.config())?;
config.check()?;
println!("{config:?}");
// handle CLI input
let daemon = cli.handle(&mut config).await?;
match daemon {
cli::DaemonStatus::NotRunning => {}
cli::DaemonStatus::Running((cancellation_token, shutdown_signal)) => {
// create task tracker
let tracker = TaskTracker::new();
// terminate signal
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to install termination signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
let shutdown_signal = async {
match shutdown_signal {
Some(mut signal) => signal.recv().await,
None => std::future::pending::<Option<()>>().await,
};
};
// wait for shutdown signals
tokio::select! {
_ = signal::ctrl_c() => {},
_ = terminate => {},
_ = shutdown_signal => {},
}
// send shutdown signal to application and wait
cancellation_token.cancel();
// close task tracker
tracker.close();
// Wait for everything to finish.
tracker.wait().await;
tokio::time::sleep(Duration::from_millis(200)).await;
info!("{APP_NAME} gracefully shut down.");
}
};
// let _ = axum_test::start_axum().await.unwrap();
// let _ = ldap_test::try_authentication().await.unwrap();
Ok(())
}

View File

@ -0,0 +1,37 @@
-- Table: public.apikeys
-- DROP TABLE IF EXISTS public.apikeys;
CREATE TABLE IF NOT EXISTS public.apikeys
(
"KeyID" character varying(32) COLLATE pg_catalog."default" NOT NULL,
"Name" character varying(255) COLLATE pg_catalog."default" NOT NULL,
"UserAuthRequired" boolean NOT NULL DEFAULT true,
"Hash" bytea NOT NULL,
"CreationDate" timestamp without time zone NOT NULL DEFAULT now(),
"LastChanged" timestamp without time zone NOT NULL DEFAULT now(),
CONSTRAINT apikeys_pkey PRIMARY KEY ("KeyID")
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.apikeys
OWNER to postgres;
COMMENT ON COLUMN public.apikeys."KeyID"
IS 'UUID of API Key';
COMMENT ON COLUMN public.apikeys."Name"
IS 'Name/Description of API Key';
COMMENT ON COLUMN public.apikeys."Hash"
IS 'Hashed value of API Key';
COMMENT ON COLUMN public.apikeys."UserAuthRequired"
IS 'Indication if this api key requires additional user authentication';
COMMENT ON COLUMN public.apikeys."CreationDate"
IS 'Time of creation';
COMMENT ON COLUMN public.apikeys."LastChanged"
IS 'Time of last modification';

View File

@ -0,0 +1,19 @@
CREATE TABLE public.users
(
"UserID" character varying(10)[] NOT NULL,
"ActiveDirectoryAuth" boolean NOT NULL DEFAULT false,
"Name" character varying(250) NOT NULL DEFAULT '',
"Surname" character varying(250) NOT NULL DEFAULT '',
"Email" character varying(500) NOT NULL DEFAULT '',
"Password" character varying(255) NOT NULL DEFAULT '',
"CreationDate" timestamp without time zone NOT NULL DEFAULT NOW(),
"LastChanged" timestamp without time zone NOT NULL DEFAULT NOW(),
"StatusFlag" smallint NOT NULL,
PRIMARY KEY ("UserID")
);
ALTER TABLE IF EXISTS public.users
OWNER to postgres;
COMMENT ON TABLE public.users
IS 'Table containing user information';

View File

@ -0,0 +1,12 @@
CREATE TABLE public.groups
(
"GroupID" serial NOT NULL,
"GroupName" character varying(255) NOT NULL DEFAULT '',
PRIMARY KEY ("GroupID")
);
ALTER TABLE IF EXISTS public.groups
OWNER to postgres;
COMMENT ON TABLE public.groups
IS 'Group information';

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS public.user_permissions
(
"UserID" character varying(10) COLLATE pg_catalog."default" NOT NULL,
"Permission" character varying(250) COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT "DistinctUserPermission" UNIQUE ("UserID", "Permission"),
CONSTRAINT "UserID" FOREIGN KEY ("UserID")
REFERENCES public.users ("UserID") MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.user_permissions
OWNER to postgres;
COMMENT ON TABLE public.user_permissions
IS 'Contains the permissions for every user';

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS public.group_permissions
(
"GroupID" integer NOT NULL,
"Permission" character varying(250) COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT "DistinctGroupPermission" UNIQUE ("GroupID", "Permission"),
CONSTRAINT "GroupID" FOREIGN KEY ("GroupID")
REFERENCES public.groups ("GroupID") MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.group_permissions
OWNER to postgres;
COMMENT ON TABLE public.group_permissions
IS 'Group -> Permission relation';

6
src/utils.rs Normal file
View File

@ -0,0 +1,6 @@
pub(crate) fn create_random(len: usize) -> String {
rand::Rng::sample_iter(rand::thread_rng(), &rand::distributions::Alphanumeric)
.take(len)
.map(char::from)
.collect()
}