From 4f0cbe9145ec3f39fee1575442482c7bc1edb78a Mon Sep 17 00:00:00 2001 From: Nell Date: Sat, 23 May 2026 02:17:37 +0200 Subject: [PATCH] init --- frontend/src/pages/login.vue | 11 ++++ frontend/src/plugins/api.ts | 28 +++++++++ frontend/src/router/index.ts | 35 +++++++++++- frontend/src/stores/auth.ts | 52 +++++++++++++++++ src/auth/token.rs | 3 + src/http/middleware.rs | 108 ++++++++++++++++++++++++++--------- src/http/mod.rs | 2 +- src/http/server.rs | 47 +++++++-------- src/routes/mod.rs | 24 ++++---- 9 files changed, 247 insertions(+), 63 deletions(-) create mode 100644 frontend/src/pages/login.vue create mode 100644 frontend/src/plugins/api.ts create mode 100644 frontend/src/stores/auth.ts diff --git a/frontend/src/pages/login.vue b/frontend/src/pages/login.vue new file mode 100644 index 0000000..27324a8 --- /dev/null +++ b/frontend/src/pages/login.vue @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/plugins/api.ts b/frontend/src/plugins/api.ts new file mode 100644 index 0000000..4b0a26b --- /dev/null +++ b/frontend/src/plugins/api.ts @@ -0,0 +1,28 @@ +import {useAuthStore} from '@/stores/auth' + +export async function apiFetch(endpoint: string, options: RequestInit = {}) { + const authStore = useAuthStore() + const baseUrl = '/api' // Votre préfixe configuré dans mod.rs + + const headers = new Headers(options.headers) + headers.set('Content-Type', 'application/json') + + // On injecte le token s'il existe + if (authStore.token) { + headers.set('Authorization', `Bearer ${authStore.token}`) + } + + const response = await fetch(`${baseUrl}${endpoint}`, { + ...options, + headers, + }) + + // Gestion automatique de l'expiration du token (401 Unauthorized) + if (response.status === 401) { + authStore.logout() + // Optionnel : rediriger vers /login + window.location.href = '/login' + } + + return response +} \ No newline at end of file diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b66b893..acf8314 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,17 +5,50 @@ */ // Composables -import { createRouter, createWebHistory } from 'vue-router' +import {createRouter, createWebHistory} from 'vue-router' import Index from '@/pages/index.vue' +import {useAuthStore} from '@/stores/auth' + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/pages/login.vue'), + }, { path: '/', component: Index, }, + { + path: '/admin', + name: 'admin-dashboard', + component: () => import('@/pages/admin/Dashboard.vue'), + meta: {requiresAdmin: true} + }, ], }) +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + const publicPages = ['login', 'register'] + const authRequired = !publicPages.includes(to.name as string) + const adminRequired = to.matched.some(record => record.meta.requiresAdmin) + + if (authRequired && !authStore.isAuthenticated) { + // Non connecté -> Login + next('/login') + } else if (to.name === 'login' && authStore.isAuthenticated) { + // Déjà connecté -> Accueil + next('/') + } else if (adminRequired && !authStore.isAdmin) { + // Connecté mais pas admin -> Accueil (ou page 403) + next('/') + } else { + next() + } +}) + export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..dc461cd --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,52 @@ +import {defineStore} from 'pinia' + +interface UserClaims { + user_id: string + username: string + is_admin: boolean // On s'assure que le backend l'envoie ou on le déduit + exp: number +} + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('token') || null as string | null, + user: JSON.parse(localStorage.getItem('user') || 'null') as UserClaims | null, + }), + getters: { + isAuthenticated: (state) => !!state.token, + isAdmin: (state) => state.user?.is_admin || false, + }, + actions: { + setToken(token: string) { + this.token = token + localStorage.setItem('token', token) + + try { + // Décoder le payload du JWT (2ème partie du string) + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }).join('')) + + const decoded = JSON.parse(jsonPayload) + this.user = { + user_id: decoded.user_id, + username: decoded.username, + is_admin: decoded.is_superuser || false, // Vérifiez le nom du champ dans votre Claims Rust + exp: decoded.expire_at + } + localStorage.setItem('user', JSON.stringify(this.user)) + } catch (e) { + console.error("Failed to decode token", e) + this.logout() + } + }, + logout() { + this.token = null + this.user = null + localStorage.removeItem('token') + localStorage.removeItem('user') + } + } +}) \ No newline at end of file diff --git a/src/auth/token.rs b/src/auth/token.rs index a9b71f1..422608c 100644 --- a/src/auth/token.rs +++ b/src/auth/token.rs @@ -9,11 +9,13 @@ pub struct Claims { pub expire_at: usize, // Expiration time pub created_at: usize, // Issued at pub username: String, + pub is_superuser: bool, // Ajoutez ce champ } pub fn create_jwt( user_id: Uuid, username: &str, + is_superuser: bool, // Ajoutez l'argument ici secret: &str, expiration_seconds: u64, ) -> Result { @@ -27,6 +29,7 @@ pub fn create_jwt( expire_at: (now + expiration_seconds) as usize, created_at: now as usize, username: username.to_string(), + is_superuser, // Et ici }; encode( diff --git a/src/http/middleware.rs b/src/http/middleware.rs index d83fb44..7d3688c 100644 --- a/src/http/middleware.rs +++ b/src/http/middleware.rs @@ -1,28 +1,78 @@ -use axum::{extract::State, http::Request, middleware::Next, response::Response}; +use axum::{ + body::Body, + extract::State, + http::{header, Request}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use std::sync::Arc; use std::time::Instant; -use tracing::info; +use tracing::{info, Instrument}; use uuid::Uuid; use super::context::{CurrentUser, RequestContext}; +use super::error::HTTPError; +use super::metrics::HttpMetrics; use crate::auth::token::verify_jwt; use crate::core::AppState; -pub async fn context_middleware( - State(app_state): State, - mut req: Request, +/// Middleware pour collecter les métriques HTTP. +pub async fn metrics_middleware( + State(metrics): State>, + req: Request, next: Next, ) -> Response { + metrics.inc_request(); + let started = Instant::now(); + let response = next.run(req).await; + let latency = started.elapsed(); + metrics.record_response(response.status().as_u16(), latency); + response +} + +/// Middleware d'initialisation du contexte de requête et de traçage de base. +pub async fn request_context_middleware(mut req: Request, next: Next) -> Response { let request_id = Uuid::new_v4(); let started_at = Instant::now(); - // Infos "type Django request" let method = req.method().clone(); let uri = req.uri().clone(); - // Authentification par JWT + // On injecte un contexte initial sans utilisateur + req.extensions_mut().insert(RequestContext { + request_id, + started_at, + method: method.clone(), + uri: uri.clone(), + user: None, + }); + + let span = tracing::info_span!( + "request", + request_id = %request_id, + method = %method, + uri = %uri, + ); + + async move { + info!("Incoming request"); + next.run(req).await + } + .instrument(span) + .await +} + +/// Middleware d'authentification par JWT. +/// Tente d'identifier l'utilisateur et met à jour le RequestContext. +pub async fn auth_middleware( + State(app_state): State, + mut req: Request, + next: Next, +) -> Response { + // Extraction du JWT depuis le header Authorization let user: Option = match req .headers() - .get(axum::http::header::AUTHORIZATION) + .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|auth_header| { if auth_header.starts_with("Bearer ") { @@ -44,26 +94,28 @@ pub async fn context_middleware( None => None, }; - let user_id = user.as_ref().map(|u| u.id); + // Mise à jour du RequestContext existant + if let Some(user) = &user { + info!(user_id = %user.id, username = %user.username, "User identified"); + if let Some(ctx) = req.extensions_mut().get_mut::() { + ctx.user = Some(user.clone()); + } + } - // Injecte le contexte dans la requête (espace de stockage partagé) - // C'est ce qui permettra aux extracteurs comme 'CurrentUser' de retrouver ces données plus tard. - req.extensions_mut().insert(RequestContext { - request_id, - started_at, - method: method.clone(), - uri: uri.clone(), - user, - }); - - info!( - request_id = %request_id, - user_id = ?user_id, - method = %method, - uri = %uri, - "Incoming request" - ); - - // Passe la requête au reste de la stack next.run(req).await } + +/// Middleware de sécurité qui impose une authentification. +pub async fn require_auth(req: Request, next: Next) -> Response { + let authenticated = req + .extensions() + .get::() + .and_then(|ctx| ctx.user.as_ref()) + .is_some(); + + if authenticated { + next.run(req).await + } else { + HTTPError::Unauthorized.into_response() + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 33acea8..5d6b3d1 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -4,7 +4,7 @@ use axum::Router; pub mod context; pub mod error; pub mod metrics; -mod middleware; +pub mod middleware; pub mod server; pub mod validation; diff --git a/src/http/server.rs b/src/http/server.rs index 1ca14fc..18f91a8 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -20,7 +20,7 @@ use crate::core::AppState; use crate::routes; use super::metrics::HttpMetrics; -use super::middleware::context_middleware; +use super::middleware; // ── Erreurs ─────────────────────────────────────────────────────────────────── @@ -44,8 +44,9 @@ pub enum HttpServerError { /// - Injecte [`AppState`] via le middleware (les handlers l'obtiendront via /// `State` quand ils seront implémentés ; `.with_state()` sera /// ajouté en conséquence). -/// - Applique [`context_middleware`] (authentification JWT + contexte de -/// requête) sur l'ensemble du router. +/// - Applique [`middleware::request_context_middleware`] (contexte de requête) +/// et [`middleware::auth_middleware`] (authentification JWT) sur l'ensemble +/// du router. /// - Empile les layers Tower : [`CatchPanicLayer`], [`CorsLayer`] (permissif), /// [`TraceLayer`]. /// - Collecte des métriques via [`HttpMetrics`] et les reporte périodiquement. @@ -104,35 +105,35 @@ impl HttpServer { /// La future se résout lorsqu'un signal de shutdown est reçu ou qu'une /// erreur I/O fatale survient. pub async fn run(mut self) -> Result<(), HttpServerError> { - let metrics = self.metrics.clone(); let app_state = self.app_state.clone(); // Construit le router avec state + middleware + layers Tower. // - // Ordre d'application en axum : chaque `.layer()` enveloppe le service - // courant — le dernier appel devient la couche la plus externe. + // Ordre d'application en axum (LIFO) : le dernier .layer() ajouté est le premier exécuté. // - // CatchPanicLayer (outermost — intercepte tout panic en dessous) - // └─ CorsLayer (répond aux preflight avant auth) - // └─ TraceLayer (span tracing sur la durée totale) - // └─ context_middleware (JWT + RequestContext + métriques) - // └─ routes (handlers) + // Courant de la requête : + // 1. CatchPanicLayer (le plus externe) + // 2. CorsLayer + // 3. TraceLayer + // 4. metrics_middleware (commence le chrono) + // 5. request_context_middleware (init ID) + // 6. auth_middleware (identifie l'utilisateur) + // 7. Routes (handlers) let app: Router = routes::router() .with_state(app_state.clone()) - // Innermost : auth JWT, injection contexte, métriques + // Identification de l'utilisateur (optionnelle ici) .layer(axum_middleware::from_fn_with_state( app_state.clone(), - move |state, req, next| { - let metrics = metrics.clone(); - async move { - metrics.inc_request(); - let started = std::time::Instant::now(); - let response = context_middleware(state, req, next).await; - let latency = started.elapsed(); - metrics.record_response(response.status().as_u16(), latency); - response - } - }, + middleware::auth_middleware, + )) + // Initialisation du contexte (ID de requête, etc.) + .layer(axum_middleware::from_fn( + middleware::request_context_middleware, + )) + // Métriques (mesure de la durée totale de la requête) + .layer(axum_middleware::from_fn_with_state( + app_state.metrics.http.clone(), + middleware::metrics_middleware, )) // Spans tracing par requête (method, uri, status, latency) .layer(TraceLayer::new_for_http()) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index ad9dabb..dfd4745 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,6 @@ +use crate::http::middleware; use crate::http::OxRouter; -use axum::Router; +use axum::{middleware as axum_middleware, Router}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -15,19 +16,22 @@ pub mod server; pub mod user; pub fn router() -> OxRouter { - let api_routes = Router::new() - .merge(auth::routes::router()) + // Routes nécessitant une authentification + let secure_routes = Router::new() .merge(server::routes::router()) .merge(category::routes::router()) .merge(channel::routes::router()) .merge(group::routes::router()) .merge(message::routes::router()) - .merge(user::routes::router()); + .merge(user::routes::router()) + .layer(axum_middleware::from_fn(middleware::require_auth)); - Router::new() - .nest("/api", api_routes) - // .merge(attachment::routes::router()) - .merge( - SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()), - ) + // Routes publiques (ou gérant leur propre auth) + let api_routes = Router::new() + .merge(secure_routes) + .merge(auth::routes::router()); + + Router::new().nest("/api", api_routes).merge( + SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()), + ) }