diff --git a/backend/main.py b/backend/main.py index 9987db39..d00f3dae 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,8 @@ import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi import Limiter +from slowapi.util import get_remote_address from . import models from .database import engine from .routers import items, operations, users, categories @@ -13,17 +15,26 @@ log.info("Database tables verified.") app = FastAPI(title="TFM aInventory API", version="1.1.0") log.info("TFM aInventory API process started.") +# [H-02] Rate limiting pe API +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler = limiter.add_exception_handler + # [SECURITY FIX M-01] CORS: allow_origins=["*"] + allow_credentials=True este invalid per spec. # Originile permise se configurează via variabila de mediu ALLOWED_ORIGINS (comma-separated). # Fallback sigur: doar localhost pentru development. -_raw_origins = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3002") +_raw_origins = os.environ.get( + "ALLOWED_ORIGINS", + "http://localhost:3000,http://localhost:3002" +) ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()] +log.info(f"CORS allowed origins: {ALLOWED_ORIGINS}") app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, - allow_methods=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], ) diff --git a/backend/routers/items.py b/backend/routers/items.py index 8f1a73d2..ce222fbf 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -1,10 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Request from sqlalchemy.orm import Session from sqlalchemy import func from typing import List +from slowapi import Limiter +from slowapi.util import get_remote_address from .. import models, schemas, auth from ..database import get_db +# [H-02] Rate limiter pentru extract-label endpoint +limiter = Limiter(key_func=get_remote_address) + router = APIRouter( prefix="/items", tags=["Items"] @@ -55,12 +60,13 @@ def read_item( _ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB +@limiter.limit("10/minute") @router.post("/extract-label") async def extract_label( file: UploadFile = File(...), current_user: auth.TokenData = Depends(auth.get_current_user) ): - """[C-01] Extragere etichetă din imagine — doar utilizatori autentificati. [H-02] Rate limit pe endpoint.""" + """[C-01] Extragere etichetă din imagine — doar utilizatori autentificati. [H-02] Rate limit: 10 req/min per IP.""" from ..ai_vision import extract_label_info # [SECURITY FIX H-03] Validare tip MIME și dimensiune maximă diff --git a/docker-compose.yml b/docker-compose.yml index 636c9105..ac860989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,10 @@ services: environment: - DATA_DIR=/app/data - LOGS_DIR=/app/logs + # [M-01] CORS allowed origins — personalizează pentru producție + - ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3002 + # [C-01] JWT secret key — GENEREAZĂ O VALOARE SIGURĂ PENTRU PRODUCȚIE! + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-me-in-production} restart: unless-stopped frontend: diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 703c14ee..f41cc85c 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, memo } from 'react'; import { User, Shield, X, Lock, ChevronRight } from 'lucide-react'; import { inventoryApi } from '@/lib/api'; +import { saveToken } from '@/lib/auth'; import { toast, Toaster } from 'react-hot-toast'; import { useRouter } from 'next/navigation'; @@ -20,9 +21,9 @@ export default function LoginPage() { useEffect(() => { setMounted(true); inventoryApi.getUsers().then(setUsers).catch(() => {}); - + // If already logged in, go home - if (localStorage.getItem('inventory_user')) { + if (localStorage.getItem('inventory_token')) { router.push('/'); } }, [router]); @@ -45,33 +46,29 @@ export default function LoginPage() { } try { - const user = await inventoryApi.login({ + // [C-01] Login vrânceanu token JWT + const tokenResponse = await inventoryApi.login({ username, password }); - - localStorage.setItem('inventory_user', JSON.stringify(user)); - toast.success(`Welcome back, ${user.username}`); - + + // Save JWT token și user info + saveToken(tokenResponse); + toast.success(`Welcome back, ${tokenResponse.username}`); + // Delay slightly to show toast then redirect setTimeout(() => { router.push('/'); }, 500); - + } catch (error) { toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password"); } }; const handleSelectUser = async (user: any) => { - if (user.username === 'Admin' || user.id > 1) { - setSelectedUserForLogin(user); - return; - } - - // Auto-login for passwordless users (if any exist beyond Admin) - localStorage.setItem('inventory_user', JSON.stringify(user)); - router.push('/'); + // [C-01] Toți userii necesită parolă pentru JWT + setSelectedUserForLogin(user); }; if (!mounted) return null; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e68dec34..6b932187 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,10 +1,11 @@ import axios from 'axios'; +import { getToken, clearAuth } from './auth'; export const getBackendUrl = () => { if (typeof window === 'undefined') return 'http://localhost:8000'; - + const host = window.location.hostname; - + // If we are on HTTPS (Proxy/Mobile mode), we use port 3002 for the backend if (window.location.protocol === 'https:') { if (host.includes('.loca.lt')) { @@ -12,126 +13,154 @@ export const getBackendUrl = () => { } return `https://${host}:3002`; } - + return `http://${host}:8000`; }; +/** + * [C-01] Axios instance cu JWT Bearer token în header + * și interceptor pentru 401 Unauthorized (token expired) + */ +const axiosInstance = axios.create({ + baseURL: getBackendUrl() +}); + +axiosInstance.interceptors.request.use((config) => { + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}, (error) => Promise.reject(error)); + +axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + // [L-01] Handle 401 Unauthorized — token expired + if (error.response?.status === 401) { + clearAuth(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + export const inventoryApi = { getItems: async () => { - const res = await axios.get(`${getBackendUrl()}/items/`); + const res = await axiosInstance.get('/items/'); return res.data; }, - + getStats: async () => { - const res = await axios.get(`${getBackendUrl()}/items/stats`); + const res = await axiosInstance.get('/items/stats'); return res.data; }, - + syncBulkOperations: async (userId: number, operations: any[]) => { - const url = `${getBackendUrl()}/operations/bulk-sync`; try { - const res = await axios.post(url, { + const res = await axiosInstance.post('/operations/bulk-sync', { user_id: userId, operations: operations }); return res.data; } catch (err: any) { - console.error("Sync API Error at:", url, err); + console.error("Sync API Error:", err); if (err.response?.status === 404) { - throw new Error(`404: Endpoint not found at ${url}`); + throw new Error(`404: Endpoint not found`); } throw err; } }, analyzeLabel: async (formData: FormData) => { - const res = await axios.post(`${getBackendUrl()}/items/extract-label`, formData, { + const res = await axiosInstance.post('/items/extract-label', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); return res.data; }, createItem: async (userId: number, itemData: any) => { - const res = await axios.post(`${getBackendUrl()}/items/`, itemData, { - params: { user_id: userId } - }); + const res = await axiosInstance.post('/items/', itemData); return res.data; }, updateItem: async (itemId: number, itemData: any) => { - const res = await axios.put(`${getBackendUrl()}/items/${itemId}`, itemData); + const res = await axiosInstance.put(`/items/${itemId}`, itemData); return res.data; }, adjustStock: async (endpoint: string, data: any) => { - const res = await axios.post(`${getBackendUrl()}/operations/${endpoint}`, data); + const res = await axiosInstance.post(`/operations/${endpoint}`, data); return res.data; }, deleteItem: async (itemId: number) => { - const res = await axios.delete(`${getBackendUrl()}/items/${itemId}`); + const res = await axiosInstance.delete(`/items/${itemId}`); return res.data; }, getAuditLogs: async (limit: number = 50) => { - const res = await axios.get(`${getBackendUrl()}/operations/logs`, { params: { limit } }); + const res = await axiosInstance.get('/operations/logs', { params: { limit } }); return res.data; }, - + // Users getUsers: async () => { - const res = await axios.get(`${getBackendUrl()}/users/`); + const res = await axiosInstance.get('/users/'); return res.data; }, - + createUser: async (userData: any) => { - const res = await axios.post(`${getBackendUrl()}/users/`, userData); + const res = await axiosInstance.post('/users/', userData); return res.data; }, - + login: async (credentials: any) => { + // [C-01] Login endpoint — NU adaug token header (login e public) const res = await axios.post(`${getBackendUrl()}/users/login`, credentials); return res.data; }, - + deleteUser: async (userId: number) => { - const res = await axios.delete(`${getBackendUrl()}/users/${userId}`); + const res = await axiosInstance.delete(`/users/${userId}`); return res.data; }, - + updateUser: async (userId: number, data: any) => { - const res = await axios.put(`${getBackendUrl()}/users/${userId}`, data); + const res = await axiosInstance.put(`/users/${userId}`, data); return res.data; }, getLdapConfig: async () => { - const res = await axios.get(`${getBackendUrl()}/users/ldap-config`); + const res = await axiosInstance.get('/users/ldap-config'); return res.data; }, updateLdapConfig: async (config: any) => { - const res = await axios.post(`${getBackendUrl()}/users/ldap-config`, config); + const res = await axiosInstance.post('/users/ldap-config', config); return res.data; }, testLdapConnection: async (config: any) => { - const res = await axios.post(`${getBackendUrl()}/users/test-ldap`, config); + const res = await axiosInstance.post('/users/test-ldap', config); return res.data; }, // Categories getCategories: async () => { - const res = await axios.get(`${getBackendUrl()}/categories/`); + const res = await axiosInstance.get('/categories/'); return res.data; }, createCategory: async (data: any) => { - const res = await axios.post(`${getBackendUrl()}/categories/`, data); + const res = await axiosInstance.post('/categories/', data); return res.data; }, updateCategory: async (id: number, data: any) => { - const res = await axios.put(`${getBackendUrl()}/categories/${id}`, data); + const res = await axiosInstance.put(`/categories/${id}`, data); return res.data; }, deleteCategory: async (id: number) => { - const res = await axios.delete(`${getBackendUrl()}/categories/${id}`); + const res = await axiosInstance.delete(`/categories/${id}`); return res.data; } }; diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts new file mode 100644 index 00000000..18cc4fe3 --- /dev/null +++ b/frontend/lib/auth.ts @@ -0,0 +1,79 @@ +/** + * [C-01] JWT Authentication Utilities + * Handle token storage, retrieval, and expiration + */ + +const TOKEN_KEY = 'inventory_token'; +const USER_KEY = 'inventory_user'; + +export interface AuthToken { + access_token: string; + token_type: string; + user_id: number; + username: string; + role: string; +} + +export interface AuthUser { + id: number; + username: string; + role: string; +} + +/** + * Save JWT token to localStorage + */ +export const saveToken = (token: AuthToken): void => { + localStorage.setItem(TOKEN_KEY, token.access_token); + localStorage.setItem(USER_KEY, JSON.stringify({ + id: token.user_id, + username: token.username, + role: token.role + })); +}; + +/** + * Get JWT token from localStorage + */ +export const getToken = (): string | null => { + if (typeof window === 'undefined') return null; + return localStorage.getItem(TOKEN_KEY); +}; + +/** + * Get current user info from localStorage + */ +export const getCurrentUser = (): AuthUser | null => { + if (typeof window === 'undefined') return null; + const userJson = localStorage.getItem(USER_KEY); + if (!userJson) return null; + try { + return JSON.parse(userJson); + } catch { + return null; + } +}; + +/** + * Check if user is authenticated (token exists) + */ +export const isAuthenticated = (): boolean => { + return !!getToken(); +}; + +/** + * Clear token and user data (logout) + */ +export const clearAuth = (): void => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); +}; + +/** + * Get Bearer token for API requests + */ +export const getAuthHeader = (): { Authorization: string } | {} => { + const token = getToken(); + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; +};