From e54be145261ffe33fcd85d3897c3229d26cbf453 Mon Sep 17 00:00:00 2001 From: schn33fuchs Date: Fri, 1 May 2026 16:18:15 +0200 Subject: [PATCH] When not logged in redirection to login page Every page is locked behind a jwt, when it is not supplied neither other pages not api calls will work --- backend/src/cookie/jwt.rs | 21 ++++-- backend/src/cookie/validation.rs | 13 +++- backend/src/handlers/auth.rs | 9 ++- backend/src/handlers/ticket.rs | 121 ++++++++++++++++++++----------- backend/src/models.rs | 9 ++- backend/src/router.rs | 14 +++- frontend/src/auth.rs | 58 +++++++++++++++ frontend/src/lib.rs | 44 +++++++++-- 8 files changed, 221 insertions(+), 68 deletions(-) create mode 100644 frontend/src/auth.rs diff --git a/backend/src/cookie/jwt.rs b/backend/src/cookie/jwt.rs index c03acc7..22302e6 100644 --- a/backend/src/cookie/jwt.rs +++ b/backend/src/cookie/jwt.rs @@ -12,23 +12,28 @@ pub struct Error { pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String { let now = chrono::Utc::now(); - let issued = now.timestamp() as usize; - let expires = (now + chrono::Duration::minutes(60)).timestamp() as usize; + let expires = (now + chrono::Duration::minutes(60)).timestamp(); let claims: Claims = Claims { - subject: id, - issued: issued, - expires: expires, + sub: id, + issued: now.timestamp() as usize, + expires: expires as usize, }; let token = encode(header, &claims, key); return token.expect("token return failed"); } pub fn decode_token(token: String, key: &DecodingKey) -> Result)> { - let claims = decode::(&token, key, &Validation::default()) - .map_err(|_| { + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.leeway = 0; + + let claims = decode::(&token, key, &validation) + .map_err(|err| { + let message = format!("Invalid Token: {}", err); let error = Error { status: "error", - message: "Invalid Token".to_string(), + message, }; (StatusCode::UNAUTHORIZED, Json(error)) })? diff --git a/backend/src/cookie/validation.rs b/backend/src/cookie/validation.rs index df366bd..7fe057b 100644 --- a/backend/src/cookie/validation.rs +++ b/backend/src/cookie/validation.rs @@ -15,6 +15,7 @@ use serde_json::json; use crate::{ AppState, cookie::jwt::decode_token, + handlers::auth::filter_user, models::{LoginScheme, User}, }; @@ -53,9 +54,15 @@ pub async fn validate_token( token, &DecodingKey::from_secret(data.env.token_secret.as_ref()), ) - .unwrap(); + .map_err(|(status, json_err)| { + let error = json!({ + "status": json_err.status, + "message": json_err.message + }); + (status, Json(error)) + })?; - let uuid = (&claims.subject).parse::().map_err(|_| { + let uuid = (&claims.sub).parse::().map_err(|_| { let error = json!({ "status": "error", "message": "Invalid user id" @@ -83,6 +90,6 @@ pub async fn validate_token( (StatusCode::UNAUTHORIZED, Json(error)) })?; - request.extensions_mut().insert(user); + request.extensions_mut().insert(filter_user(&user)); Ok(next.run(request).await) } diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index b02b261..6b8d8d9 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -156,13 +156,14 @@ pub async fn logout() -> Result, + Extension(user): Extension, ) -> Result)> { let response = json!({ "status": "success", "data": json!({ - "first_name": filter_user(&state).first_name, - "last_name": filter_user(&state).last_name + "id": user.id, + "first_name": user.first_name, + "last_name": user.last_name }) }); @@ -302,7 +303,7 @@ pub async fn update_user( Ok(Json(response)) } -fn filter_user(user: &User) -> FilteredUser { +pub fn filter_user(user: &User) -> FilteredUser { FilteredUser { id: user.id, first_name: user.first_name.clone(), diff --git a/backend/src/handlers/ticket.rs b/backend/src/handlers/ticket.rs index e4ae579..61de3e0 100644 --- a/backend/src/handlers/ticket.rs +++ b/backend/src/handlers/ticket.rs @@ -1,30 +1,32 @@ use std::sync::Arc; use axum::{ - Json, + Extension, Json, extract::{Path, State}, http::StatusCode, response::IntoResponse, }; use serde_json::json; -use sqlx::query; +use sqlx::{query, Row}; use crate::{ AppState, - models::{Ticket, TicketCreateScheme, TicketResponse, TicketUpdateScheme}, + models::{FilteredUser, Ticket, TicketCreateScheme, TicketResponse, TicketUpdateScheme}, }; pub async fn create_ticket( + Extension(user): Extension, State(data): State>, Json(body): Json, ) -> Result)> { let query = query( - r#"INSERT INTO tickets (category, description, betreff, room) VALUES ($1, $2, $3, $4)"#, + r#"INSERT INTO tickets (category, description, betreff, room, user_id) VALUES ($1, $2, $3, $4, $5)"#, ) .bind(body.category.to_string()) .bind(body.description.to_string()) .bind(body.betreff.to_string()) .bind(body.room) + .bind(user.id) .execute(&data.db) .await; @@ -69,8 +71,11 @@ pub async fn get_tickets( State(data): State>, ) -> Result)> { println!("get_tickets called"); - let tickets = sqlx::query_as::<_, Ticket>( - r#"SELECT * FROM tickets WHERE status <> 'Archived' ORDER BY date DESC"#, + let tickets = sqlx::query( + r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name + FROM tickets t + LEFT JOIN users u ON t.user_id = u.id + WHERE t.status <> 'Archived' ORDER BY t.date DESC"#, ) .fetch_all(&data.db) .await @@ -83,10 +88,21 @@ pub async fn get_tickets( })?; println!("Tickets fetched"); - let ticket_response = tickets + let ticket_response: Vec = tickets .iter() - .map(|ticket| filter_record(&ticket)) - .collect::>(); + .map(|row| TicketResponse { + id: row.get("id"), + category: row.get("category"), + betreff: row.get("betreff"), + description: row.get("description"), + room: row.get("room"), + status: row.get("status"), + date: row.get("date"), + user_id: row.get("user_id"), + user_first_name: row.get("first_name"), + user_last_name: row.get("last_name"), + }) + .collect(); let json_response = serde_json::json!(ticket_response); println!("Json contructed"); @@ -97,15 +113,32 @@ pub async fn get_ticket_by_id( Path(id): Path, State(data): State>, ) -> Result)> { - let query = sqlx::query_as::<_, Ticket>(r#"SELECT * FROM tickets WHERE id = $1"#) - .bind(id) - .fetch_one(&data.db) - .await; + let query = sqlx::query( + r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name + FROM tickets t + LEFT JOIN users u ON t.user_id = u.id + WHERE t.id = $1"#, + ) + .bind(id) + .fetch_one(&data.db) + .await; match query { - Ok(ticket) => { - let ticket_response = serde_json::json!(filter_record(&ticket)); - return Ok(Json(ticket_response)); + Ok(row) => { + let ticket_response = TicketResponse { + id: row.get("id"), + category: row.get("category"), + betreff: row.get("betreff"), + description: row.get("description"), + room: row.get("room"), + status: row.get("status"), + date: row.get("date"), + user_id: row.get("user_id"), + user_first_name: row.get("first_name"), + user_last_name: row.get("last_name"), + }; + let response = serde_json::json!(ticket_response); + return Ok(Json(response)); } Err(sqlx::Error::RowNotFound) => { let error_response = serde_json::json!({ @@ -148,34 +181,40 @@ pub async fn edit_ticket( return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))); } - let updated_ticket = sqlx::query_as::<_, Ticket>(r#"SELECT * FROM tickets WHERE id = $1"#) - .bind(id) - .fetch_one(&data.db) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"status": "error", "message": format!("{:?}", e)})), - ) - })?; + let updated_ticket = sqlx::query( + r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name + FROM tickets t + LEFT JOIN users u ON t.user_id = u.id + WHERE t.id = $1"#, + ) + .bind(id) + .fetch_one(&data.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"status": "error", "message": format!("{:?}", e)})), + ) + })?; - let ticket_response = serde_json::json!({ - "ticket": filter_record(&updated_ticket), + let ticket_response = TicketResponse { + id: updated_ticket.get("id"), + category: updated_ticket.get("category"), + betreff: updated_ticket.get("betreff"), + description: updated_ticket.get("description"), + room: updated_ticket.get("room"), + status: updated_ticket.get("status"), + date: updated_ticket.get("date"), + user_id: updated_ticket.get("user_id"), + user_first_name: updated_ticket.get("first_name"), + user_last_name: updated_ticket.get("last_name"), + }; + + let response = serde_json::json!({ + "ticket": ticket_response, "status": "success" }); - Ok(Json(ticket_response)) + Ok(Json(response)) } -fn filter_record(ticket: &Ticket) -> TicketResponse { - TicketResponse { - id: ticket.id.to_owned(), - category: ticket.category.to_owned(), - betreff: ticket.betreff.to_owned(), - description: ticket.description.to_owned(), - room: ticket.room.to_owned(), - status: ticket.status.to_owned(), - date: ticket.date.to_owned(), - user_id: ticket.user_id.to_owned(), - } -} diff --git a/backend/src/models.rs b/backend/src/models.rs index 244c488..52c2e11 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -25,6 +25,8 @@ pub struct TicketResponse { pub status: String, pub date: chrono::NaiveDateTime, pub user_id: i16, + pub user_first_name: String, + pub user_last_name: String, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)] @@ -75,7 +77,7 @@ pub struct LoginScheme { pub pwd: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct FilteredUser { pub id: i16, pub first_name: String, @@ -86,7 +88,10 @@ pub struct FilteredUser { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { - pub subject: String, + #[serde(alias = "subject")] + pub sub: String, + #[serde(rename = "iat", alias = "issued", default)] pub issued: usize, + #[serde(rename = "exp", alias = "expires", default)] pub expires: usize, } diff --git a/backend/src/router.rs b/backend/src/router.rs index ba2bf3a..6f98460 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -1,12 +1,13 @@ use std::sync::Arc; use axum::{ - Router, + Router, middleware, routing::{get, post}, }; use crate::{ AppState, + cookie::validation::validate_token, handlers::{ auth::{ create_user, delete_user, get_current_user, get_user_by_id, get_users, login, logout, @@ -17,7 +18,7 @@ use crate::{ }; pub fn create_router(state: Arc) -> Router { - Router::new() + let protected_routes = Router::new() .route("/api/tickets", get(get_tickets)) .route("/api/tickets/create", post(create_ticket)) .route( @@ -27,7 +28,6 @@ pub fn create_router(state: Arc) -> Router { .patch(edit_ticket), ) .route("/api/register", post(create_user)) - .route("/api/login", post(login)) .route("/api/logout", get(logout)) .route("/api/users", get(get_users)) .route("/api/users/current", get(get_current_user)) @@ -35,5 +35,13 @@ pub fn create_router(state: Arc) -> Router { "/api/users/{id}", get(get_user_by_id).delete(delete_user).patch(update_user), ) + .layer(middleware::from_fn_with_state( + state.clone(), + validate_token, + )); + + Router::new() + .merge(protected_routes) + .route("/api/login", post(login)) .with_state(state) } diff --git a/frontend/src/auth.rs b/frontend/src/auth.rs new file mode 100644 index 0000000..fe06539 --- /dev/null +++ b/frontend/src/auth.rs @@ -0,0 +1,58 @@ +use gloo_net::http::Request; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; +use yew_router::prelude::*; + +#[derive(Clone, Debug, PartialEq)] +pub struct AuthState { + pub is_authenticated: bool, +} + +#[derive(Properties, PartialEq)] +pub struct ProtectedRouteProps { + pub children: Children, +} + +#[component(ProtectedRoute)] +pub fn protected_route(props: &ProtectedRouteProps) -> Html { + let is_authenticated = use_state(|| None::); + + { + let is_authenticated = is_authenticated.clone(); + use_effect_with((), move |_| { + let is_authenticated = is_authenticated.clone(); + spawn_local(async move { + match Request::get("/api/users/current") + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + web_sys::console::log_1(&format!("Auth check: status {}", status).into()); + if status == 200 { + is_authenticated.set(Some(true)); + } else { + is_authenticated.set(Some(false)); + } + } + Err(err) => { + web_sys::console::log_1(&format!("Auth check error: {:?}", err).into()); + is_authenticated.set(Some(false)); + } + } + }); + || () + }); + } + + match *is_authenticated { + None => html! {
{"Loading..."}
}, + Some(true) => props.children.clone().into(), + Some(false) => { + html! { + to={crate::Route::Login} /> + } + } + } +} diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index 076f4b5..a9cfd64 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -1,5 +1,7 @@ mod pages; +mod auth; use crate::pages::*; +use crate::auth::ProtectedRoute; use yew::prelude::*; use yew_router::prelude::*; @@ -28,15 +30,43 @@ enum Route { fn switch(route: Route) -> Html { match route { - Route::Home => html! { }, + Route::Home => html! { + + + + }, Route::NotFound => html! { }, - Route::Ticket => html! { }, - Route::TicketById { id } => html! { }, - Route::AllTickets => html! { }, - Route::Register => html! { }, + Route::Ticket => html! { + + + + }, + Route::TicketById { id } => html! { + + + + }, + Route::AllTickets => html! { + + + + }, + Route::Register => html! { + + + + }, Route::Login => html! { }, - Route::AllUsers => html! {}, - Route::UserByID { id } => html! { }, + Route::AllUsers => html! { + + + + }, + Route::UserByID { id } => html! { + + + + }, } }