daily commit

This commit is contained in:
Dennis Nemec
2026-02-05 10:46:30 +01:00
parent 00ff9a7474
commit 8419c77263
46 changed files with 5494 additions and 3 deletions

16
src/api.rs Normal file
View File

@ -0,0 +1,16 @@
pub(crate) mod supplier;
pub(crate) mod tour;
use crate::util::{decode_payload_unchecked};
use axum::body::Body;
use axum::extract::Request;
use axum::response::IntoResponse;
use crate::model::{User};
pub async fn userinfo(request: Request<Body>) -> impl IntoResponse {
let access_token_string = &request.headers().get("authorization").unwrap().to_str().unwrap().to_string()[7..];
println!("access_token_string is {}", access_token_string);
let user = decode_payload_unchecked::<User>(access_token_string).unwrap();
serde_json::to_string(&user.employee).unwrap().into_response()
}

27
src/api/supplier.rs Normal file
View File

@ -0,0 +1,27 @@
use crate::dto::{GetCarInfosDTO, get_example_deliveries};
use crate::model::User;
use crate::response::{FailResponse, ResponseFactory};
use crate::util::decode_payload_unchecked;
use axum::Json;
use axum::http::StatusCode;
use axum_extra::TypedHeader;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
pub async fn load_supplier_cars(
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<Json<GetCarInfosDTO>, (StatusCode, Json<FailResponse>)> {
let user_res = decode_payload_unchecked::<User>(auth.token());
if let Err(e) = user_res {
return Err((
StatusCode::UNAUTHORIZED,
Json(ResponseFactory::error(
format!("An error occured: {}", e.to_string()),
Some(StatusCode::UNAUTHORIZED.as_u16() as u32),
)),
));
}
Ok(Json(get_example_deliveries()))
}

29
src/api/tour.rs Normal file
View File

@ -0,0 +1,29 @@
use axum::extract::Path;
use axum::http::StatusCode;
use axum::Json;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
use axum_extra::TypedHeader;
use crate::dto::{get_example_deliveries, get_test_tour, GetCarInfosDTO, TourDTO};
use crate::model::User;
use crate::response::{FailResponse, ResponseFactory};
use crate::util::decode_payload_unchecked;
pub async fn load_tour(
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
Path(car_id): Path<u64>
) -> Result<Json<TourDTO>, (StatusCode, Json<FailResponse>)> {
let user_res = decode_payload_unchecked::<User>(auth.token());
if let Err(e) = user_res {
return Err((
StatusCode::UNAUTHORIZED,
Json(ResponseFactory::error(
format!("An error occured: {}", e.to_string()),
Some(StatusCode::UNAUTHORIZED.as_u16() as u32),
)),
));
}
Ok(Json(get_test_tour(car_id)))
}

425
src/auth.rs Normal file
View File

@ -0,0 +1,425 @@
use std::collections::HashMap;
use crate::config::Config;
use crate::middleware::AppState;
use crate::repository::RedisRepository;
use axum::http::{StatusCode, header};
use axum::response::{Html, Response};
use axum::{
Router,
extract::{Query, State},
response::{IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::CookieJar;
use oauth2::basic::{
BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse,
BasicTokenResponse,
};
use oauth2::{
AuthUrl, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, EndpointNotSet,
EndpointSet, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, StandardRevocableToken,
TokenResponse, TokenUrl, basic::BasicClient,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use axum::routing::post;
use log::info;
pub type OAuthClient = Client<
BasicErrorResponse,
BasicTokenResponse,
BasicTokenIntrospectionResponse,
StandardRevocableToken,
BasicRevocationErrorResponse,
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointSet,
>;
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/login", get(login))
.route("/callback", get(callback))
.route("/logout", post(logout))
.with_state(state)
}
async fn login(State(client): State<Arc<AppState>>) -> impl IntoResponse {
let cloned_client = client.clone();
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let csrf_token = CsrfToken::new_random();
// Store the PKCE verifier in Redis with CSRF token as key
let redis_key = format!("pkce_verifier:{}", csrf_token.secret());
let verifier_secret = pkce_verifier.secret().to_string();
match cloned_client
.repository
.set_with_expiry(&redis_key, &verifier_secret, 600) // 10 minutes expiry
.await
{
Ok(_) => {
let (auth_url, _) = cloned_client
.oauth_client
.authorize_url(|| csrf_token)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
Redirect::to(auth_url.as_str()).into_response()
}
Err(e) => {
log::error!("Failed to store PKCE verifier in Redis: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to initiate login",
)
.into_response()
}
}
}
#[derive(Deserialize)]
pub struct Callback {
code: String,
state: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UserSession {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_at: i64,
}
async fn callback(
State(client): State<Arc<AppState>>,
Query(query): Query<Callback>,
) -> impl IntoResponse {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Client should build");
let cloned_state = client.clone();
log::info!("Callback called");
// Retrieve the PKCE verifier from Redis using CSRF token
let redis_key = format!("pkce_verifier:{}", query.state);
let verifier_secret = match cloned_state.repository.get(&redis_key).await {
Ok(Some(secret)) => secret,
Ok(None) => {
log::error!("PKCE verifier not found for state: {}", query.state);
return (
StatusCode::BAD_REQUEST,
"Invalid or expired login session. Please try again.",
)
.into_response();
}
Err(e) => {
log::error!("Failed to retrieve PKCE verifier from Redis: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Login failed").into_response();
}
};
// Delete the verifier from Redis (one-time use)
let _ = cloned_state.repository.delete(&redis_key).await;
let pkce_verifier = PkceCodeVerifier::new(verifier_secret);
let token_result = cloned_state
.oauth_client
.exchange_code(AuthorizationCode::new(query.code))
.set_pkce_verifier(pkce_verifier)
.request_async(&http_client)
.await;
match token_result {
Ok(token) => {
let access_token = token.access_token().secret();
let refresh_token = token
.refresh_token()
.map(|rt| rt.secret().to_string())
.unwrap_or_else(|| "No refresh token".to_string());
let expires_at = chrono::Utc::now().timestamp()
+ token
.expires_in()
.map(|d| d.as_secs() as i64)
.unwrap_or(3600);
// ============================================
// 1. GENERATE A UNIQUE SESSION ID
// ============================================
let session_id = uuid::Uuid::now_v7().to_string();
// ============================================
// 2. CREATE THE USER SESSION STRUCT
// ============================================
let user_session = UserSession {
access_token: access_token.clone(),
refresh_token: refresh_token.clone(),
expires_at,
};
// ============================================
// 3. SERIALIZE THE SESSION TO JSON
// ============================================
let session_key = format!("user_session:{}", session_id);
let session_json = match serde_json::to_string(&user_session) {
Ok(json) => json,
Err(e) => {
log::error!("Failed to serialize user session: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Login failed").into_response();
}
};
// ============================================
// 4. STORE IN REDIS WITH 24 HOUR EXPIRATION
// This is where the tokens are actually stored!
// ============================================
if let Err(e) = cloned_state
.repository
.set_with_expiry(&session_key, &session_json, 86400) // 86400 = 24 hours
.await
{
log::error!("Failed to store user session in Redis: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Login failed").into_response();
}
info!("Successfully created session {} for user", session_id);
info!("Token scopes: {:?}", token.extra_fields());
// 4. Redirect to frontend
let redirect_url = format!("{}/?session_id={}", cloned_state.frontend_url.clone(), session_id);
Redirect::to(redirect_url.as_str()).into_response()
}
Err(e) => {
log::error!("Token exchange failed: {:?}", e);
(StatusCode::UNAUTHORIZED, format!("Login failed: {:?}", e)).into_response()
}
}
}
pub fn create_oauth_client(config: &Config) -> OAuthClient {
BasicClient::new(ClientId::new(config.keycloak.client_id.clone()))
.set_client_secret(ClientSecret::new(config.keycloak.client_secret.clone()))
.set_redirect_uri(RedirectUrl::new(config.keycloak.redirect_url.clone()).unwrap())
.set_token_uri(TokenUrl::new(config.keycloak.token_url.clone()).unwrap())
.set_auth_uri(AuthUrl::new(config.keycloak.auth_url.clone()).unwrap())
}
/// Internal helper to refresh access token
pub async fn refresh_access_token_internal(
client: &OAuthClient,
repository: &RedisRepository,
session_id: &str,
user_session: &mut UserSession,
) -> Result<String, String> {
use oauth2::{RefreshToken, TokenResponse};
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Client should build");
let refresh_token = &user_session.refresh_token;
// Exchange refresh token for new access token
let token_result = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.clone()))
.request_async(&http_client)
.await
.map_err(|e| format!("Token refresh request failed: {:?}", e))?;
// Update session with new tokens
let new_access_token = token_result.access_token().secret().to_string();
user_session.access_token = new_access_token.clone();
// Update refresh token if a new one was provided
if let Some(new_refresh_token) = token_result.refresh_token() {
user_session.refresh_token = new_refresh_token.secret().to_string();
}
// Update expiration time
user_session.expires_at = chrono::Utc::now().timestamp()
+ token_result
.expires_in()
.map(|d| d.as_secs() as i64)
.unwrap_or(3600);
// Save updated session back to Redis
let session_key = format!("user_session:{}", session_id);
let updated_json = serde_json::to_string(&user_session)
.map_err(|e| format!("Failed to serialize session: {:?}", e))?;
repository
.set_with_expiry(&session_key, &updated_json, 86400)
.await
.map_err(|e| format!("Failed to update session in Redis: {:?}", e))?;
Ok(new_access_token)
}
async fn logout(jar: CookieJar, State(oauth_state): State<Arc<AppState>>) -> impl IntoResponse {
// 1. Extract session ID from cookie
let session_id = match jar.get("session_id") {
Some(cookie) => cookie.value().to_string(),
None => {
log::warn!("Logout attempted without session cookie");
return (StatusCode::BAD_REQUEST, "No active session").into_response();
}
};
// 2. Get session from Redis to retrieve tokens
let session_key = format!("user_session:{}", session_id);
let session_json = match oauth_state.repository.get(&session_key).await {
Ok(Some(json)) => json,
Ok(None) => {
log::warn!("Session not found in Redis: {}", session_id);
// Session already gone, just clear cookie
return clear_session_cookie().into_response();
}
Err(e) => {
log::error!("Redis error while fetching session for logout: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Logout failed").into_response();
}
};
let user_session: UserSession = match serde_json::from_str(&session_json) {
Ok(session) => session,
Err(e) => {
log::error!("Failed to parse session JSON during logout: {:?}", e);
// Clean up anyway
let _ = oauth_state.repository.delete(&session_key).await;
return clear_session_cookie().into_response();
}
};
// 3. Revoke tokens at Keycloak
let revoke_result = revoke_tokens_at_keycloak(
&oauth_state,
&user_session.access_token,
&user_session.refresh_token,
)
.await;
if let Err(e) = revoke_result {
log::error!("Failed to revoke tokens at Keycloak: {}", e);
// Continue anyway - we'll still delete local session
} else {
log::info!(
"Successfully revoked tokens at Keycloak for session {}",
session_id
);
}
// 4. Delete session from Redis
match oauth_state.repository.delete(&session_key).await {
Ok(_) => {
log::info!("Successfully deleted session {} from Redis", session_id);
}
Err(e) => {
log::error!("Failed to delete session from Redis: {:?}", e);
}
}
// 5. Clear session cookie and respond
clear_session_cookie().into_response()
}
/// Helper function to revoke tokens at Keycloak's revocation endpoint
async fn revoke_tokens_at_keycloak(
oauth_state: &Arc<AppState>,
access_token: &str,
refresh_token: &str,
) -> Result<(), String> {
// Get client credentials from OAuth client
let client_id = oauth_state.oauth_client.client_id().as_str();
let client_secret = oauth_state.config.keycloak.client_secret.as_str();
// Build revocation endpoint URL
// Keycloak's revocation endpoint is typically at:
// {realm_url}/protocol/openid-connect/revoke
let token_url = oauth_state.config.keycloak.token_url.as_str();
// Replace /token with /revoke
let revoke_url = token_url.replace("/token", "/revoke");
log::info!("Revoking tokens at: {}", revoke_url);
let client = reqwest::Client::new();
// Revoke refresh token (this also invalidates the access token)
let revoke_refresh_result = client
.post(&revoke_url)
.form(&[
("token", refresh_token),
("token_type_hint", "refresh_token"),
("client_id", client_id),
("client_secret", client_secret),
])
.send()
.await
.map_err(|e| format!("Failed to send revoke request: {:?}", e))?;
if !revoke_refresh_result.status().is_success() {
let status = revoke_refresh_result.status();
let body = revoke_refresh_result
.text()
.await
.unwrap_or_else(|_| "Unable to read response".to_string());
log::warn!(
"Token revocation returned non-success status {}: {}",
status,
body
);
// Note: Keycloak returns 200 even if token is already invalid, so this is unusual
}
// Optionally also revoke access token explicitly
let revoke_access_result = client
.post(&revoke_url)
.form(&[
("token", access_token),
("token_type_hint", "access_token"),
("client_id", client_id),
("client_secret", client_secret),
])
.send()
.await
.map_err(|e| format!("Failed to send revoke request for access token: {:?}", e))?;
if !revoke_access_result.status().is_success() {
let status = revoke_access_result.status();
let body = revoke_access_result
.text()
.await
.unwrap_or_else(|_| "Unable to read response".to_string());
log::warn!(
"Access token revocation returned non-success status {}: {}",
status,
body
);
}
Ok(())
}
/// Helper function to create a response that clears the session cookie
fn clear_session_cookie() -> Response {
// Set cookie with Max-Age=0 to delete it
let clear_cookie = "session_id=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
Response::builder()
.status(StatusCode::OK)
.header(header::SET_COOKIE, clear_cookie)
.body("Logged out successfully".into())
.unwrap()
}

97
src/config.rs Normal file
View File

@ -0,0 +1,97 @@
use std::fs;
use std::path::PathBuf;
const CONFIG_FILE: &str = "config.toml";
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct Config {
// Backend server configuration
pub log_file_prefix: String,
pub host_ip: String,
pub host_port: u16,
pub redis_url: String,
pub frontend_url: String,
// GSD RestAPI configuration
pub gsd_app_key: String,
pub gsd_rest_url: String,
pub gsd_user: String,
pub gsd_password: String,
pub gsd_app_names: Vec<String>,
pub keycloak: Keycloak,
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct Keycloak {
pub realm_url: String,
pub client_id: String,
pub client_secret: String,
pub auth_url: String,
pub token_url: String,
pub redirect_url: String,
pub realm: String,
pub base_url: String,
}
impl Config {
pub fn get_host_url(&self) -> String {
format!("{}:{}", self.host_ip, self.host_port)
}
}
fn get_config_absolute_path() -> PathBuf {
PathBuf::from(CONFIG_FILE)
}
pub fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
if fs::exists(get_config_absolute_path())? {
Ok(toml::from_str(&fs::read_to_string(CONFIG_FILE)?)?)
} else {
let config = create_standard_config();
save_config(&config)?;
Ok(config)
}
}
pub fn generate_log_file_name(prefix: String) -> String {
format!("{}_{}.log", prefix, chrono::Local::now().format("%Y-%m-%dT%H-%M-%S%.6f%z"))
}
pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
fs::write(get_config_absolute_path(), toml::to_string(config)?)?;
Ok(())
}
pub fn create_standard_config() -> Config {
Config {
log_file_prefix: String::from("delivery_backend"),
host_ip: String::from("127.0.0.1"),
host_port: 3000,
redis_url: String::from("redis://127.0.0.1:6379"),
gsd_rest_url: String::from("http://127.0.0.1:8334"),
gsd_app_key: String::from("GSD-RestApi"),
frontend_url: String::from("http://127.0.0.1:3000"),
gsd_app_names: vec![String::from("GSD-RestApi")],
gsd_user: String::from("<GSD-USER>"),
gsd_password: String::from("<GSD-Password>"),
keycloak: Keycloak {
realm_url: String::from("http://127.0.0.1:8080/auth/realms/master"),
client_id: String::from("delivery-backend"),
client_secret: String::from(""),
realm: String::from("master"),
base_url: String::from("http://127.0.0.1:8080"),
auth_url: String::from(
"http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/auth",
),
token_url: String::from(
"http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/token",
),
redirect_url: String::from("http://127.0.0.1:3000/callback"),
},
}
}

331
src/dto.rs Normal file
View File

@ -0,0 +1,331 @@
use chrono::{DateTime, TimeZone, Utc};
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct CarInfoDTO {
pub amount_deliveries: u32,
pub car: CarDTO,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct CarDTO {
pub id: u32,
pub car_name: String,
pub driver_name: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct GetCarInfosDTO {
pub deliveries: Vec<CarInfoDTO>,
pub date: String
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ArticleDTO {
pub id: u64,
pub title: String,
pub quantity: u64,
pub price_per_quantity: f32,
pub deposit_price_per_quantity: f32,
pub quantity_delivered: u64,
pub quantity_returned: u64,
pub quantity_to_deposit: u64,
pub reference_to: Option<u64>
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct AddressDTO {
pub street: String,
pub housing_number: String,
pub postal_code: String,
pub city: String,
pub country: Option<String>
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct CustomerDTO {
pub name: String,
pub id: u64,
pub address: AddressDTO
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ReceiptDTO {
pub articles: Vec<ArticleDTO>,
pub customer: CustomerDTO,
pub total_gross_price: f32,
pub total_net_price: f32
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct DeliveryDTO {
pub receipt: ReceiptDTO,
pub information_for_driver: String,
pub desired_delivery_time: DateTime<Utc>,
pub time_delivered: Option<DateTime<Utc>>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct TourDTO {
pub car: CarDTO,
pub date: String,
pub deliveries: Vec<DeliveryDTO>,
}
pub fn get_example_deliveries() -> GetCarInfosDTO {
GetCarInfosDTO {
date: format!("{}", Utc::now().format("%d.%m.%Y")),
deliveries: vec![
CarInfoDTO {
car: CarDTO {
id: 1,
car_name: "OG DN 1998".to_string(),
driver_name: Some("Dennis Nemec".to_string()),
},
amount_deliveries: 12,
},
CarInfoDTO {
car: CarDTO {
id: 2,
car_name: "S SM 2547".to_string(),
driver_name: None // "Sarah Müller".to_string(),
},
amount_deliveries: 8,
},
CarInfoDTO {
car: CarDTO {
id: 3,
car_name: "M MS 3891".to_string(),
driver_name: Some("Michael Schmidt".to_string()),
},
amount_deliveries: 15,
},
CarInfoDTO {
car: CarDTO {
id: 4,
car_name: "KA AW 1024".to_string(),
driver_name: Some("Anna Weber".to_string()),
},
amount_deliveries: 6,
},
CarInfoDTO {
car: CarDTO {
id: 5,
car_name: "FR TB 7652".to_string(),
driver_name: None // "Thomas Becker".to_string(),
},
amount_deliveries: 11,
},
CarInfoDTO {
car: CarDTO {
id: 6,
car_name: "HD JH 4389".to_string(),
driver_name: Some("Julia Hoffmann".to_string()),
},
amount_deliveries: 9,
},
CarInfoDTO {
car: CarDTO {
id: 7,
car_name: "KN MF 5123".to_string(),
driver_name: Some("Markus Fischer".to_string()),
},
amount_deliveries: 13,
},
],
}
}
pub fn get_test_tour(car_id: u64) -> TourDTO {
TourDTO {
car: CarDTO {
id: car_id as u32,
car_name: String::from("Mercedes Sprinter"),
driver_name: Some(String::from("Hans Müller")),
},
date: String::from("2026-02-05"),
deliveries: vec![
// Lieferung 1
DeliveryDTO {
receipt: ReceiptDTO {
articles: vec![
ArticleDTO {
id: 1001,
title: String::from("Propangas 5kg"),
quantity: 4,
price_per_quantity: 18.99,
deposit_price_per_quantity: 0.0,
quantity_delivered: 4,
quantity_returned: 0,
quantity_to_deposit: 0,
reference_to: None,
},
ArticleDTO {
id: 2001,
title: String::from("Pfandflasche 5kg"),
quantity: 4,
price_per_quantity: 0.0,
deposit_price_per_quantity: 35.00,
quantity_delivered: 4,
quantity_returned: 2,
quantity_to_deposit: 2,
reference_to: Some(1001),
},
ArticleDTO {
id: 1002,
title: String::from("Propangas 11kg"),
quantity: 2,
price_per_quantity: 32.99,
deposit_price_per_quantity: 0.0,
quantity_delivered: 2,
quantity_returned: 0,
quantity_to_deposit: 0,
reference_to: None,
},
ArticleDTO {
id: 2002,
title: String::from("Pfandflasche 11kg"),
quantity: 2,
price_per_quantity: 0.0,
deposit_price_per_quantity: 45.00,
quantity_delivered: 2,
quantity_returned: 1,
quantity_to_deposit: 1,
reference_to: Some(1002),
},
],
customer: CustomerDTO {
name: String::from("Grillhütte Waldheim"),
id: 5001,
address: AddressDTO {
street: String::from("Waldstraße"),
housing_number: String::from("15"),
postal_code: String::from("70597"),
city: String::from("Stuttgart"),
country: Some(String::from("Deutschland")),
},
},
total_gross_price: 211.94,
total_net_price: 178.10,
},
information_for_driver: String::from("Flaschen beim Lagerraum hinter dem Gebäude abstellen. Alte Flaschen abholen."),
desired_delivery_time: Utc.with_ymd_and_hms(2026, 2, 5, 8, 0, 0).unwrap(),
time_delivered: None // Utc.with_ymd_and_hms(2026, 2, 5, 8, 15, 0).unwrap(),
},
// Lieferung 2
DeliveryDTO {
receipt: ReceiptDTO {
articles: vec![
ArticleDTO {
id: 1001,
title: String::from("Propangas 5kg"),
quantity: 8,
price_per_quantity: 18.99,
deposit_price_per_quantity: 0.0,
quantity_delivered: 8,
quantity_returned: 0,
quantity_to_deposit: 0,
reference_to: None,
},
ArticleDTO {
id: 2001,
title: String::from("Pfandflasche 5kg"),
quantity: 8,
price_per_quantity: 0.0,
deposit_price_per_quantity: 35.00,
quantity_delivered: 8,
quantity_returned: 7,
quantity_to_deposit: 1,
reference_to: Some(1001),
},
ArticleDTO {
id: 1003,
title: String::from("Propangas 33kg"),
quantity: 1,
price_per_quantity: 89.99,
deposit_price_per_quantity: 0.0,
quantity_delivered: 1,
quantity_returned: 0,
quantity_to_deposit: 0,
reference_to: None,
},
ArticleDTO {
id: 2003,
title: String::from("Pfandflasche 33kg"),
quantity: 1,
price_per_quantity: 0.0,
deposit_price_per_quantity: 75.00,
quantity_delivered: 1,
quantity_returned: 1,
quantity_to_deposit: 0,
reference_to: Some(1003),
},
],
customer: CustomerDTO {
name: String::from("Campingplatz Sonnenhof"),
id: 5002,
address: AddressDTO {
street: String::from("Am See"),
housing_number: String::from("23"),
postal_code: String::from("70376"),
city: String::from("Stuttgart"),
country: Some(String::from("Deutschland")),
},
},
total_gross_price: 241.91,
total_net_price: 203.28,
},
information_for_driver: String::from("Rezeption informieren. Flaschen zur Gasflaschenhütte bei Stellplatz A12 bringen."),
desired_delivery_time: Utc.with_ymd_and_hms(2026, 2, 5, 10, 0, 0).unwrap(),
time_delivered: Some(Utc.with_ymd_and_hms(2026, 2, 5, 10, 20, 0).unwrap()),
},
// Lieferung 3
DeliveryDTO {
receipt: ReceiptDTO {
articles: vec![
ArticleDTO {
id: 1002,
title: String::from("Propangas 11kg"),
quantity: 6,
price_per_quantity: 32.99,
deposit_price_per_quantity: 0.0,
quantity_delivered: 6,
quantity_returned: 0,
quantity_to_deposit: 0,
reference_to: None,
},
ArticleDTO {
id: 2002,
title: String::from("Pfandflasche 11kg"),
quantity: 6,
price_per_quantity: 0.0,
deposit_price_per_quantity: 45.00,
quantity_delivered: 6,
quantity_returned: 5,
quantity_to_deposit: 1,
reference_to: Some(1002),
},
],
customer: CustomerDTO {
name: String::from("Baumarkt Schmidt GmbH"),
id: 5003,
address: AddressDTO {
street: String::from("Industriestraße"),
housing_number: String::from("88"),
postal_code: String::from("70565"),
city: String::from("Stuttgart"),
country: Some(String::from("Deutschland")),
},
},
total_gross_price: 242.94,
total_net_price: 204.15,
},
information_for_driver: String::from("Wareneingang nutzen, Anlieferung nur bis 12 Uhr möglich."),
desired_delivery_time: Utc.with_ymd_and_hms(2026, 2, 5, 11, 30, 0).unwrap(),
time_delivered: Some(Utc.with_ymd_and_hms(2026, 2, 5, 11, 45, 0).unwrap()),
},
],
}
}

2
src/gsd.rs Normal file
View File

@ -0,0 +1,2 @@
pub(crate) mod dto;
pub(crate) mod service;

27
src/gsd/dto.rs Normal file
View File

@ -0,0 +1,27 @@
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GSDLoginRequestDTO {
pub user: String,
pub pass: String,
pub app_names: Vec<String>,
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GSDResponseDTO {
pub status: Option<GSDResponseStatusDTO>,
pub data: Option<GSDLoginResponseDataDTO>,
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GSDLoginResponseDataDTO {
pub session_id: Option<String>,
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GSDResponseStatusDTO {
pub internal_status: String,
pub status_message: String,
}

134
src/gsd/service.rs Normal file
View File

@ -0,0 +1,134 @@
use crate::config::Config;
use crate::gsd::dto::*;
use axum::body::Body;
use axum::extract::Request;
use log::{error, info};
use reqwest::Response;
#[derive(Clone)]
pub struct GSDService {
host_url: String,
app_names: Vec<String>,
app_key: String,
username: String,
password: String,
}
#[derive(Debug)]
pub enum GSDServiceError {
LoginFailed,
LoginResponseParsingFailed,
RequestError(String),
}
impl GSDService {
pub async fn get_session(&self) -> Result<String, GSDServiceError> {
info!(
"Session: No session found. Generate session from GSD server {}",
self.host_url
);
let dto = GSDLoginRequestDTO {
user: self.username.clone(),
pass: self.password.clone(),
app_names: self.app_names.clone(),
};
let response = reqwest::Client::new()
.post(format!("{}/v1/login", self.host_url.clone()))
.header("appKey", self.app_key.as_str())
.header("Content-Type", "application/json")
.json(&dto)
.send()
.await
.map_err(|e| {
error!("Session: error request to GSD: {}", e);
GSDServiceError::LoginFailed
})?;
let response_dto: GSDResponseDTO = response.json().await.map_err(|e| {
error!("Session: error request to GSD: {}", e);
GSDServiceError::LoginResponseParsingFailed
})?;
let response_dto_unwrapped = response_dto.status.unwrap();
if response_dto_unwrapped.internal_status != "0" {
error!(
"Session: error message from GSD: {}",
response_dto_unwrapped.status_message
);
Err(GSDServiceError::LoginFailed)
} else {
match response_dto.data {
Some(data) => {
info!(
"Session: successfully obtained session with session id {:?}",
&data.session_id.as_ref()
);
Ok(data.session_id.unwrap())
}
None => {
error!("Session: failed to obtain session id. No session id in request found.");
Err(GSDServiceError::LoginResponseParsingFailed)
}
}
}
}
pub async fn forward_post_request(
&self,
request: Request<Body>,
) -> Result<Response, GSDServiceError> {
let (parts, body) = request.into_parts();
reqwest::Client::new()
.post(format!("{}{}", self.host_url.clone(), parts.uri))
.headers(parts.headers)
.body(axum::body::to_bytes(body, usize::MAX).await.unwrap())
.send()
.await
.map_err(|e| GSDServiceError::RequestError(e.to_string()))
}
pub async fn forward_patch_request(
&self,
request: Request<Body>,
) -> Result<Response, GSDServiceError> {
let (parts, body) = request.into_parts();
reqwest::Client::new()
.patch(format!("{}{}", self.host_url.clone(), parts.uri))
.headers(parts.headers)
.body(axum::body::to_bytes(body, usize::MAX).await.unwrap())
.send()
.await
.map_err(|e| GSDServiceError::RequestError(e.to_string()))
}
pub async fn forward_get_request(
&self,
request: Request<Body>,
) -> Result<Response, GSDServiceError> {
let (parts, body) = request.into_parts();
reqwest::Client::new()
.get(format!("{}{}", self.host_url.clone(), parts.uri))
.headers(parts.headers)
.body(axum::body::to_bytes(body, usize::MAX).await.unwrap())
.send()
.await
.map_err(|e| GSDServiceError::RequestError(e.to_string()))
}
}
impl From<&Config> for GSDService {
fn from(config: &Config) -> Self {
Self {
host_url: config.gsd_rest_url.clone(),
app_names: config.gsd_app_names.clone(),
app_key: config.gsd_app_key.clone(),
username: config.gsd_user.clone(),
password: config.gsd_password.clone(),
}
}
}

84
src/main.rs Normal file
View File

@ -0,0 +1,84 @@
use crate::api::userinfo;
use crate::config::load_config;
use crate::middleware::AppState;
use crate::repository::RedisRepository;
use crate::util::initialize_logging;
use axum::routing::get;
use axum::{Extension, Router};
use axum_keycloak_auth::PassthroughMode;
use axum_keycloak_auth::instance::{KeycloakAuthInstance, KeycloakConfig};
use axum_keycloak_auth::layer::KeycloakAuthLayer;
use log::info;
use oauth2::url::Url;
use std::sync::Arc;
mod api;
mod auth;
mod config;
mod gsd;
mod middleware;
mod model;
mod repository;
mod util;
mod response;
mod dto;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config()?;
initialize_logging(&config);
info!("Redirect URI: {}", config.keycloak.redirect_url);
info!("Logging initialized");
info!("Starting Gas Delivery Backend");
let redis_url = config.redis_url.clone();
let host_url = config.get_host_url().clone();
info!("Initializing redis server");
let state = Arc::new(AppState {
config: config.clone(),
repository: RedisRepository::try_new(redis_url).await?,
gsd_service: (&config).into(),
oauth_client: auth::create_oauth_client(&config),
frontend_url: config.frontend_url.clone(),
});
//
info!("Starting axum server");
let keycloak_instance: Arc<KeycloakAuthInstance> = Arc::new(KeycloakAuthInstance::new(
KeycloakConfig::builder()
.server(Url::parse(config.keycloak.base_url.as_str())?)
.realm(config.keycloak.realm)
.build(),
));
let auth_router = auth::router(state.clone());
let api_router = Router::new()
.route("/cars", get(api::supplier::load_supplier_cars))
.route("/tour/{car_id}", get(api::tour::load_tour))
.route("/userinfo", get(userinfo))
.route_layer(Extension(state.clone()))
.route_layer(
KeycloakAuthLayer::<String>::builder()
.instance(keycloak_instance.clone())
.passthrough_mode(PassthroughMode::Block)
.persist_raw_claims(false)
.expected_audiences(vec![String::from("account")])
.build(),
)
.route_layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::session_auth_middleware,
))
.with_state(state);
let app = Router::new().merge(api_router).merge(auth_router);
info!("Listening on {}", host_url);
let listener = tokio::net::TcpListener::bind(host_url.clone()).await?;
axum::serve(listener, app).await?;
Ok(())
}

168
src/middleware.rs Normal file
View File

@ -0,0 +1,168 @@
use crate::auth::{OAuthClient, UserSession, refresh_access_token_internal};
use crate::config::Config;
use crate::gsd::service::GSDService;
use crate::repository::RedisRepository;
use crate::util::set_and_log_session;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum_extra::extract::CookieJar;
use log::{error, info, warn};
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub repository: RedisRepository,
pub gsd_service: GSDService,
pub oauth_client: OAuthClient,
pub frontend_url: String,
}
/// Middleware to validate session and refresh tokens if needed
pub async fn session_auth_middleware(
jar: CookieJar,
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Response {
// 1. Extract session ID from cookie
let session_id = match jar.get("session_id") {
Some(cookie) => cookie.value().to_string(),
None => {
warn!("No session cookie found");
return (StatusCode::UNAUTHORIZED, "No session cookie").into_response();
}
};
// 2. Find session in Redis
let session_key = format!("user_session:{}", session_id);
let session_json = match state.repository.get(&session_key).await {
Ok(Some(json)) => json,
Ok(None) => {
warn!("Session not found in Redis: {}", session_id);
return (StatusCode::UNAUTHORIZED, "Session expired or invalid").into_response();
}
Err(e) => {
error!("Redis error while fetching session: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response();
}
};
// 3. Parse session data
let mut user_session: UserSession = match serde_json::from_str(&session_json) {
Ok(session) => session,
Err(e) => {
error!("Failed to parse session JSON: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid session data").into_response();
}
};
// 4. Check if access token is expired
let now = chrono::Utc::now().timestamp();
if user_session.expires_at <= now {
info!(
"Access token expired for session {}, attempting refresh",
session_id
);
// 5. Refresh the access token using refresh token
match refresh_access_token_internal(
&state.oauth_client,
&state.repository,
&session_id,
&mut user_session,
)
.await
{
Ok(new_access_token) => {
info!(
"Successfully refreshed access token for session {}",
session_id
);
user_session.access_token = new_access_token;
}
Err(e) => {
error!("Failed to refresh access token: {}", e);
// Clean up invalid session
let _ = state.repository.delete(&session_key).await;
return (
StatusCode::UNAUTHORIZED,
"Session expired, please login again",
)
.into_response();
}
}
} else {
info!(
"Access token still valid for session {} (expires in {} seconds)",
session_id,
user_session.expires_at - now
);
}
// 6. Attach validated access token to request for downstream handlers
match HeaderValue::from_str(format!("Bearer {}", &user_session.access_token).as_str()) {
Ok(header_value) => {
request.headers_mut().insert("authorization", header_value);
}
Err(e) => {
error!("Failed to create authorization header: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response();
}
}
// 7. Pass the request to the next handler
next.run(request).await
}
pub async fn gsd_decorate_header(
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Response {
let state_cloned = state.clone();
let session = state_cloned.repository.get_session().await;
match session {
Ok(session) => {
let session_value;
if session.is_none() {
match state_cloned.gsd_service.get_session().await {
Ok(session) => {
session_value = session.clone();
set_and_log_session(&state_cloned, session.clone()).await;
}
Err(error) => {
error!("Error getting session: {:?}", error);
return StatusCode::UNAUTHORIZED.into_response();
}
}
} else {
session_value = session.unwrap();
}
request.headers_mut().insert(
"sessionId",
HeaderValue::from_str(session_value.as_str()).unwrap(),
);
}
Err(error) => {
error!(
"Redis error occured during fetching current session id. Error: {}",
error
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
request.headers_mut().insert(
"appkey",
HeaderValue::from_str(state_cloned.config.gsd_app_key.as_str()).unwrap(),
);
next.run(request).await
}

13
src/model.rs Normal file
View File

@ -0,0 +1,13 @@
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct User {
pub employee: Employee,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Employee {
pub last_name: String,
pub first_name: String,
pub mail: String,
pub supplier_id: u64
}

51
src/repository.rs Normal file
View File

@ -0,0 +1,51 @@
use redis::aio::ConnectionManager;
use redis::{AsyncTypedCommands, Connection, RedisError, RedisResult};
#[derive(Clone)]
pub struct RedisRepository {
connection_manager: ConnectionManager,
}
impl RedisRepository {
pub async fn try_new(redis_url: String) -> Result<RedisRepository, RedisError> {
Ok(RedisRepository {
connection_manager: redis::Client::open(redis_url)?
.get_connection_manager()
.await?,
})
}
pub async fn get_session(&self) -> RedisResult<Option<String>> {
self.connection_manager
.clone()
.get::<String>("current_session".to_string())
.await
}
pub async fn set_session(&self, session: String) -> RedisResult<()> {
self.connection_manager
.clone()
.set("current_session", session)
.await?;
Ok(())
}
pub async fn set_with_expiry(&self, key: &str, value: &str, expiry: u64) -> RedisResult<()> {
self.connection_manager
.clone()
.set_ex(key, value, expiry)
.await
}
pub async fn get(&self, key: &str) -> RedisResult<Option<String>> {
self.connection_manager
.clone()
.get::<String>(key.to_string())
.await
}
pub async fn delete(&self, key: &str) -> RedisResult<usize> {
self.connection_manager.clone().del(key.to_string()).await
}
}

20
src/response.rs Normal file
View File

@ -0,0 +1,20 @@
use axum::Json;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
pub struct ResponseFactory {}
impl ResponseFactory {
pub fn error(message: String, code: Option<u32>) -> FailResponse {
FailResponse {
code,
message,
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct FailResponse {
pub code: Option<u32>,
pub message: String,
}

47
src/util.rs Normal file
View File

@ -0,0 +1,47 @@
use crate::config::{Config, generate_log_file_name};
use crate::middleware::AppState;
use log::{LevelFilter, error, info};
use simplelog::{ColorChoice, CombinedLogger, TermLogger, TerminalMode, WriteLogger};
use std::fs::File;
use std::sync::Arc;
use serde::de::DeserializeOwned;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
pub fn decode_payload_unchecked<T: DeserializeOwned>(token: &str) -> Result<T, Box<dyn std::error::Error>> {
let mut parts = token.split('.');
let _header = parts.next().ok_or("missing header")?;
let payload_b64 = parts.next().ok_or("missing payload")?;
// signature is parts.next() but we ignore it here
let payload = URL_SAFE_NO_PAD.decode(payload_b64.as_bytes())?;
let claims = serde_json::from_slice::<T>(&payload)?;
Ok(claims)
}
pub fn initialize_logging(config: &Config) {
CombinedLogger::init(vec![
TermLogger::new(
LevelFilter::Info,
simplelog::Config::default(),
TerminalMode::Mixed,
ColorChoice::Auto,
),
WriteLogger::new(
LevelFilter::Info,
simplelog::Config::default(),
File::create(generate_log_file_name(config.log_file_prefix.clone())).unwrap(),
),
])
.unwrap();
}
pub async fn set_and_log_session(state: &Arc<AppState>, session: String) {
match state.repository.set_session(session.clone()).await {
Ok(_) => {
info!("Redis: saved session {}", &session);
}
Err(err) => {
error!("Redis: failed to save session: {}", err);
}
}
}