daily commit
This commit is contained in:
16
src/api.rs
Normal file
16
src/api.rs
Normal 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
27
src/api/supplier.rs
Normal 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
29
src/api/tour.rs
Normal 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
425
src/auth.rs
Normal 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
97
src/config.rs
Normal 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
331
src/dto.rs
Normal 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
2
src/gsd.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub(crate) mod dto;
|
||||
pub(crate) mod service;
|
||||
27
src/gsd/dto.rs
Normal file
27
src/gsd/dto.rs
Normal 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
134
src/gsd/service.rs
Normal 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
84
src/main.rs
Normal 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
168
src/middleware.rs
Normal 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
13
src/model.rs
Normal 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
51
src/repository.rs
Normal 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
20
src/response.rs
Normal 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
47
src/util.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user