chore: build
This commit is contained in:
@@ -61,13 +61,16 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.startSession(c, user)
|
// Le JWT n'est plus jamais renvoyé dans le corps JSON (pentest F-003) :
|
||||||
if err != nil {
|
// uniquement posé en cookie HttpOnly par startSession. Le renvoyer ici
|
||||||
|
// permettait au frontend de le dupliquer en localStorage, annulant la
|
||||||
|
// protection HttpOnly contre un vol de session via XSS.
|
||||||
|
if _, err := h.startSession(c, user); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"token": token, "token_type": "Bearer", "role": user.Role})
|
c.JSON(http.StatusOK, gin.H{"role": user.Role})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Me renvoie l'identité de la session courante (dont le rôle, pour le front).
|
// Me renvoie l'identité de la session courante (dont le rôle, pour le front).
|
||||||
@@ -111,7 +114,18 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, exists := h.users.ByUsername(req.Username); exists {
|
if existing, exists := h.users.ByUsername(req.Username); exists {
|
||||||
|
// Un statut 409 distinct de la création (201) permet d'énumérer les
|
||||||
|
// comptes existants (pentest F-002) — inévitable pour un flux
|
||||||
|
// d'inscription instantané sans vérification email (l'utilisateur a
|
||||||
|
// besoin de savoir qu'il doit choisir un autre nom). On protège au
|
||||||
|
// moins la cible à plus fort enjeu : un compte admin ne confirme
|
||||||
|
// jamais son existence, la réponse est indiscernable d'un nom
|
||||||
|
// d'utilisateur simplement invalide.
|
||||||
|
if existing.Role == RoleAdmin {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"})
|
c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -127,12 +141,11 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.startSession(c, user)
|
if _, err := h.startSession(c, user); err != nil {
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, gin.H{"token": token, "token_type": "Bearer", "role": user.Role})
|
c.JSON(http.StatusCreated, gin.H{"role": user.Role})
|
||||||
}
|
}
|
||||||
|
|
||||||
// startSession ouvre une session Redis, signe le JWT et pose le cookie.
|
// startSession ouvre une session Redis, signe le JWT et pose le cookie.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type Config struct {
|
|||||||
RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0
|
RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0
|
||||||
DemoDomain string // domaine des démos
|
DemoDomain string // domaine des démos
|
||||||
DemoHTTPSPort string // port HTTPS public à afficher dans l'URL des démos (443 = omis, sinon ex. NodePort)
|
DemoHTTPSPort string // port HTTPS public à afficher dans l'URL des démos (443 = omis, sinon ex. NodePort)
|
||||||
RegistrationCode string // OMNEX_REGISTRATION_CODE (vide = inscription ouverte)
|
|
||||||
Kubeconfig string
|
Kubeconfig string
|
||||||
FrontendImage string
|
FrontendImage string
|
||||||
BackendImage string
|
BackendImage string
|
||||||
@@ -47,7 +46,6 @@ func Load() (Config, error) {
|
|||||||
RedisURL: os.Getenv("OMNEX_REDIS_URL"),
|
RedisURL: os.Getenv("OMNEX_REDIS_URL"),
|
||||||
DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"),
|
DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"),
|
||||||
DemoHTTPSPort: getenv("OMNEX_DEMO_HTTPS_PORT", "443"),
|
DemoHTTPSPort: getenv("OMNEX_DEMO_HTTPS_PORT", "443"),
|
||||||
RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"),
|
|
||||||
Kubeconfig: os.Getenv("KUBECONFIG"),
|
Kubeconfig: os.Getenv("KUBECONFIG"),
|
||||||
FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"),
|
FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"),
|
||||||
BackendImage: os.Getenv("BACKEND_IMAGE_APP"),
|
BackendImage: os.Getenv("BACKEND_IMAGE_APP"),
|
||||||
|
|||||||
@@ -34,6 +34,29 @@ func New(d Deps) *gin.Engine {
|
|||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
// Sans ça, Gin fait confiance par défaut au X-Forwarded-For fourni par
|
||||||
|
// N'IMPORTE QUEL client pour déterminer c.ClientIP() (utilisé par
|
||||||
|
// RateLimit) — un attaquant peut alors faire croire que chaque requête
|
||||||
|
// vient d'une IP différente en changeant juste cet en-tête, contournant
|
||||||
|
// intégralement la limitation de débit sur /auth/login et
|
||||||
|
// /auth/register (trouvé en pentest, voir F-005).
|
||||||
|
//
|
||||||
|
// L'API n'est jamais exposée directement (pas de "ports:" dans
|
||||||
|
// docker-compose.yml, voir docker/docker-compose.yml) — seul nginx/waf,
|
||||||
|
// sur le réseau Docker interne, peut l'atteindre (proxy_pass vers
|
||||||
|
// http://api:8080, voir docker/waf/nginx.conf qui construit le
|
||||||
|
// X-Forwarded-For via $proxy_add_x_forwarded_for : ajoute toujours la
|
||||||
|
// vraie IP vue par nginx en dernière position, sans jamais écraser une
|
||||||
|
// valeur fournie par le client). En ne faisant confiance qu'aux plages
|
||||||
|
// privées RFC1918 (réseau Docker interne), Gin ignore la partie du
|
||||||
|
// X-Forwarded-For contrôlée par le client et ne retient que la partie
|
||||||
|
// ajoutée par nginx.
|
||||||
|
if err := r.SetTrustedProxies([]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}); err != nil {
|
||||||
|
// CIDR statiques et valides : ne peut arriver qu'en cas d'erreur de
|
||||||
|
// programmation (typo) — fatal au démarrage plutôt que de tourner
|
||||||
|
// avec la protection anti-spoofing désactivée sans s'en rendre compte.
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
r.Use(httpx.SecurityHeaders())
|
r.Use(httpx.SecurityHeaders())
|
||||||
r.Use(httpx.CORS(d.Cfg.AllowedOrigins))
|
r.Use(httpx.CORS(d.Cfg.AllowedOrigins))
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
|
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
|
||||||
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
|
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
|
||||||
<script type="module" crossorigin src="/assets/index-yR0d-eKM.js"></script>
|
<script type="module" crossorigin src="/assets/index-DkbJvUQ3.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+14
-27
@@ -1,19 +1,8 @@
|
|||||||
// Client HTTP vers l'API Omnex.
|
// Client HTTP vers l'API Omnex. "" (chemin relatif) par défaut : same-origin
|
||||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
// en prod (nginx proxy /api/ vers l'API, voir docker/waf/nginx.conf) et en
|
||||||
|
// dev (proxy Vite, voir vite.config.ts) — nécessaire pour que le cookie de
|
||||||
const TOKEN_KEY = 'omnex.token'
|
// session SameSite=Lax soit envoyé (jamais cross-origin, voir lib/auth.tsx).
|
||||||
|
const BASE = import.meta.env.VITE_API_URL ?? ''
|
||||||
|
|
||||||
export function getToken(): string | null {
|
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
export function setToken(t: string) {
|
|
||||||
localStorage.setItem(TOKEN_KEY, t)
|
|
||||||
}
|
|
||||||
export function clearToken() {
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
@@ -23,14 +12,19 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentification exclusivement via le cookie de session HttpOnly (posé par
|
||||||
|
// le backend sur /auth/login et /auth/register) — jamais de JWT lu/stocké en
|
||||||
|
// JS. Le dupliquer en localStorage (comme avant) annulait la protection
|
||||||
|
// HttpOnly contre le vol de session par XSS (pentest F-003) : le SPA et
|
||||||
|
// l'API étant same-origin, le cookie authentifie déjà chaque fetch() sans
|
||||||
|
// qu'aucun en-tête Authorization manuel ne soit nécessaire.
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
const token = getToken()
|
|
||||||
if (token) headers.Authorization = `Bearer ${token}`
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE}/api/v1${path}`, {
|
const res = await fetch(`${BASE}/api/v1${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -177,16 +171,9 @@ export interface AppDownloadsResponse {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
login: (username: string, password: string, role: Role) =>
|
login: (username: string, password: string, role: Role) =>
|
||||||
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/login', {
|
request<{ role: Role }>('POST', '/auth/login', { username, password, role }),
|
||||||
username,
|
|
||||||
password,
|
|
||||||
role,
|
|
||||||
}),
|
|
||||||
register: (username: string, password: string) =>
|
register: (username: string, password: string) =>
|
||||||
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/register', {
|
request<{ role: Role }>('POST', '/auth/register', { username, password }),
|
||||||
username,
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
me: () =>
|
me: () =>
|
||||||
request<{
|
request<{
|
||||||
user_id: string
|
user_id: string
|
||||||
|
|||||||
+13
-26
@@ -7,7 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { api, clearToken, getToken, setToken, type Role } from './api'
|
import { api, type Role } from './api'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
@@ -26,16 +26,15 @@ interface AuthState {
|
|||||||
const AuthContext = createContext<AuthState | null>(null)
|
const AuthContext = createContext<AuthState | null>(null)
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [token, setTok] = useState<string | null>(getToken())
|
|
||||||
const [role, setRole] = useState<Role | null>(null)
|
const [role, setRole] = useState<Role | null>(null)
|
||||||
const [typeAbo, setTypeAbo] = useState<string | null>(null)
|
const [typeAbo, setTypeAbo] = useState<string | null>(null)
|
||||||
const [initializing, setInitializing] = useState<boolean>(!!getToken())
|
const [initializing, setInitializing] = useState(true)
|
||||||
|
|
||||||
|
// Le cookie de session est HttpOnly : impossible à lire en JS pour savoir
|
||||||
|
// à l'avance si l'utilisateur est connecté. On tente donc systématiquement
|
||||||
|
// /auth/me au montage — le cookie (s'il existe et est valide) l'authentifie
|
||||||
|
// automatiquement ; un 401 signifie simplement "pas de session".
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
|
||||||
setInitializing(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let active = true
|
let active = true
|
||||||
api
|
api
|
||||||
.me()
|
.me()
|
||||||
@@ -46,12 +45,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (active) {
|
/* pas de session valide — état par défaut (déconnecté) */
|
||||||
clearToken()
|
|
||||||
setTok(null)
|
|
||||||
setRole(null)
|
|
||||||
setTypeAbo(null)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (active) setInitializing(false)
|
if (active) setInitializing(false)
|
||||||
@@ -59,24 +53,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string, role: Role) => {
|
const login = useCallback(async (username: string, password: string, role: Role) => {
|
||||||
const res = await api.login(username, password, role)
|
await api.login(username, password, role)
|
||||||
setToken(res.token)
|
|
||||||
setTok(res.token)
|
|
||||||
setRole(res.role)
|
|
||||||
const me = await api.me()
|
const me = await api.me()
|
||||||
|
setRole(me.role)
|
||||||
setTypeAbo(me.type_abonnement)
|
setTypeAbo(me.type_abonnement)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const register = useCallback(async (username: string, password: string) => {
|
const register = useCallback(async (username: string, password: string) => {
|
||||||
const res = await api.register(username, password)
|
await api.register(username, password)
|
||||||
setToken(res.token)
|
|
||||||
setTok(res.token)
|
|
||||||
setRole(res.role)
|
|
||||||
const me = await api.me()
|
const me = await api.me()
|
||||||
|
setRole(me.role)
|
||||||
setTypeAbo(me.type_abonnement)
|
setTypeAbo(me.type_abonnement)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -84,8 +73,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
} finally {
|
} finally {
|
||||||
clearToken()
|
|
||||||
setTok(null)
|
|
||||||
setRole(null)
|
setRole(null)
|
||||||
setTypeAbo(null)
|
setTypeAbo(null)
|
||||||
}
|
}
|
||||||
@@ -99,7 +86,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const value = useMemo<AuthState>(
|
const value = useMemo<AuthState>(
|
||||||
() => ({
|
() => ({
|
||||||
isAuthenticated: !!token,
|
isAuthenticated: !!role,
|
||||||
isAdmin: role === 'admin',
|
isAdmin: role === 'admin',
|
||||||
isClient: role === 'client',
|
isClient: role === 'client',
|
||||||
isPremium: typeAbo === 'premium',
|
isPremium: typeAbo === 'premium',
|
||||||
@@ -111,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
logout,
|
logout,
|
||||||
refreshAbo,
|
refreshAbo,
|
||||||
}),
|
}),
|
||||||
[token, role, typeAbo, initializing, login, register, logout, refreshAbo],
|
[role, typeAbo, initializing, login, register, logout, refreshAbo],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
// Fait passer l'API par la même origine que le SPA en dev (localhost:5173)
|
||||||
|
// au lieu d'un appel cross-origin direct vers localhost:8080 — nécessaire
|
||||||
|
// depuis le passage à l'authentification 100% cookie HttpOnly (plus de
|
||||||
|
// fallback Authorization: Bearer, voir lib/api.ts) : un cookie
|
||||||
|
// SameSite=Lax n'est pas envoyé sur un fetch() cross-site.
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/healthz': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
Reference in New Issue
Block a user