feat: frontend JWT handling, rate limiting [H-02], CORS config
Frontend: - Creiez frontend/lib/auth.ts cu saveToken, getToken, getAuthHeader, clearAuth - Modific api.ts: axiosInstance cu interceptor Bearer token + 401 → /login redirect - Modific login page: salveaza JWT token din response Backend: - [H-02] Integrez slowapi rate limiting: 10 req/minute pe /items/extract-label - [M-01] CORS: ALLOWED_ORIGINS din env (dev fallback: localhost:3000, localhost:3002) - [C-01] JWT_SECRET_KEY din env (dev fallback: ephemeral key) docker-compose.yml: - Adaug ALLOWED_ORIGINS env var (dev: localhost) - Adaug JWT_SECRET_KEY env var cu fallback warning Status: - ✅ JWT backend: complet - ✅ JWT frontend: token save + attach + 401 handling - ✅ Rate limiting: 10/min pe extract-label - ✅ CORS: configurable via env Gata pentru dev + testing local. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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=["*"],
|
||||
)
|
||||
|
||||
|
||||
@@ -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ă
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
79
frontend/lib/auth.ts
Normal file
79
frontend/lib/auth.ts
Normal file
@@ -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}` };
|
||||
};
|
||||
Reference in New Issue
Block a user