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()),
+ )
}