From 49dfb3ef24b3d7de1ce14968b43bd411d82bd2cb Mon Sep 17 00:00:00 2001 From: icamarillo Date: Tue, 3 Mar 2026 09:29:53 -0700 Subject: [PATCH] Mejora de seguridad --- backend/.coverage | Bin 53248 -> 53248 bytes backend/app/api/deps.py | 44 +- backend/app/api/v1/endpoints/auth.py | 32 +- backend/app/api/v1/endpoints/reports.py | 17 +- backend/app/api/v1/endpoints/tickets.py | 6 + backend/app/main.py | 10 +- backend/app/middleware/tenant.py | 2 + frontend-client/src/lib/stores/app.ts | 16 +- frontend-client/src/lib/stores/auth.ts | 53 +- frontend-client/src/lib/stores/tickets.ts | 42 +- frontend-client/src/lib/utils/api.ts | 126 +++++ frontend-client/src/routes/+layout.svelte | 11 +- .../src/routes/organization/+page.svelte | 24 +- .../src/routes/profile/+page.svelte | 55 +- frontend-internal/src/lib/stores/auth.ts | 81 ++- frontend-internal/src/lib/utils/api.ts | 32 +- frontend-internal/src/routes/+layout.svelte | 11 +- .../src/routes/test-report/+page.svelte | 492 ------------------ workers/app/core/database.py | 31 ++ 19 files changed, 428 insertions(+), 657 deletions(-) create mode 100644 frontend-client/src/lib/utils/api.ts diff --git a/backend/.coverage b/backend/.coverage index 9f4fb647fda038947c1c6d597a9f3287166e4d5d..530eac8d858227ddac4a02e9bcb6661f66e61dfd 100644 GIT binary patch delta 1808 zcmYk64NOy46vyxD>wE3%>wC9A={Id@OKJINX)&7AJ)%&e2FR+=oKqHZ^m~KqMNSNVC`i+TC}B=}FVyIsbd^ zx#!&1zGyEQ?Irh`2_|NFo?uR{J{l&2Oi)x*tzZV2BTPHfzy#x2nje)iXHaafaS;LW zSdEt`6NhT-l*3PgT5-+dT=nL)n>KEKXNzxBOQBD!tIbo)3h}!&db&8slml)9SE{RalS>T@ccs)CzjovPQ_bBtJAXU$p82yh?&5#P-J#h-#^{#B+t zzM}4;Qj9jbsGyf@AjHpW3Q#M9jrmlWldL7ACfFxU_{Al!S)cUKsCTamkR1TthS%Y5 z@Eh0*_rWIk5_Cfoyx7A3ZeMTshuk?Xi^` zhg9Fwum~;MmBN9sbnCW|EllKk#o-QJ#Dip$3KbabDkqGED4NH~`p$xL1Dyk--!@wm za3ZkuV3gq8Xvw0U%stf7J^rUrQGh~?d}@`&g`5Rt!u9ij0HEs7m9VuXQU|9F_l<^t zl69h0LqPD8O_TNc4mrbA3z6x26(op8st9!|mUU$&7SS320GA;-98r4mMWNICtQ~Fh zL1C5SR(SNq8~0{f@11#I2OpSiNT2T+Y53!~P`}}gpYK-=CYvj*T?zr1D1zAuyps`==7d5#zw2MZDE9B3 zS7pGu`_Xi`dV>OWgl+hrhN)S1_4>ri$d#In64D0%H2@?4SEYngx>)Q?nLR2aG$tIIme;>7L#xwlMZJxY_=MO?mQuRJw7)0Fq3(1Fe77Gq#2CDFFnaYR;7|M zbQWPeJ)q+J=_f&b?^@Kz*@Ki9)vycZMYTw1Zl{{ zoAy@o5p6)4g2*Yup!^sL9|m2D!IWU|#Td#W4AzUG@?da<7-~0$ya1!jg%NO4=s!<5 zaFCsk0qqzbTLhD|V#x9^bQTPa8N+MBa2he(1`LND!==M0&BgF(G4gXTin1{ZvM{nV z7{w5ygvT&(biFiuo*IWn7Q>>#urnBbC5BajVWu$*6oyTXQAlFwWzsKz$^E3a>_0e= Btakta delta 1529 zcmaLXe@s(H902gUzVx_SnwiC@OlKN17ZJv01?i6K{@$h6&wamlxx3fA zZ`E!_?PiW!p%Ao{LE&t}DIeqJ1DGqVGu-4)@qwO1qnX!bx^You7Ifj-%6xkP1N^Q{ zEiJS+zrJP5Ta9}*z1`^A)wpfbwp~4cR+`ydH?IP@S)MA~l8mw>os_1F1ClB|kv2$I z1(Q%B4~m(>W)Zup>=RD&Vf2ymiPEavQ_dr+vXWPO8mew31iPzoVQrSdozJX=xUMqW z;Yw%x@c)jkHRo)0>6-w0hJHuCqKoJ>>O{NIa#V(9qD(YV8CHIhugPD??@Rln3`r1Q z2*bi<{u2KUe~5pBd%#`czT#AFBUjB8a+5eK$FRffef9=>hCRuC%(k-ihS&5>XM<4B z9N4=Nw;vpV8*tm9m#_}2Z5Fs5f7)hETc?M-jeU4$PgkL%PZVoOXyG5ebMyN~owWx4 z+-6H!OH$4Rt@Cm8y?tJX4<@g{u{Kj+HA$xmWaXodLTFk=q5@ghdj(wW>wC1L&ffRi z^JyK|WLi5H7+UM^a!V{_;q!6D=gWI*rN?05cysAOl-yzxvZen)k0JOm*o80-v; zUnh4ixlYyOv5~Tx{_$NvG3g=LKR*1gwGdyxG!I$mnX*5ZMEcP9!x`y({5wG|A=9>0 ztuq$&#bRRO91>=wX+sZs9*1AWe359qIGdy_DcbcDLt#-U))SNvF=XGRP zIA)RsrS>aL`^Tc5oj0wZ0kA+SZraX}AM$DJJd|dT1*ujoaw0YwfJsFp%CTtC=uU4- z*N|sG7lHOd{B4ILFrCay%pDw!>G)~mL$ly@)*v?-I+MpP{S{y!7`QG9%0=K(&H5OQIu5WWz>W2p{wXS^b7g} zg;6`&g|?!1Nu4&MLF7SlJvvUx_G_e}RFqEsewHW!mK+X~$@@T_3D98TM#_W@6hj?_ zTTfxvQTSSlQbS2tOA*&lgw+&z6-8P}(XXHsR8w4422v$(ISB$hOj<^hXelLc358iq z(Ji8wDk;W=lzcxWa{(o%f|5~A$tt5voli-hN10Sg$@Wnay_Csw$Eku-JTy!#p`^{B hq|Bz+iYd-n6#Gnybq2*!L~#^Sa;H str: + """Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto. + + Usa el header X-App para seleccionar la cookie: + - X-App: internal → solo 'internal_access_token' + - X-App: client → solo 'client_access_token' + - sin header → prueba ambas (compatibilidad con Swagger/CLI) + """ + if bearer_token: + return bearer_token + + app_hint = request.headers.get("X-App", "").lower() + if app_hint == "internal": + token = request.cookies.get("internal_access_token") + elif app_hint == "client": + token = request.cookies.get("client_access_token") + else: + # Fallback para Swagger, tests y clientes sin header + token = ( + request.cookies.get("internal_access_token") + or request.cookies.get("client_access_token") + ) + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token + async def get_current_user( request: Request, diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 6ac425c..aae1fc9 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb Endpoints para autenticación y autorización """ -from fastapi import APIRouter, HTTPException, status, Depends, Request +from fastapi import APIRouter, HTTPException, status, Depends, Request, Response from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -21,6 +21,14 @@ from app.services.audit_service import AuditService from app.services.token_service import TokenService from app.api.deps import oauth2_scheme, get_current_user from app.core.cache import cache, cache_key + +# Nombres de cookie por tipo de usuario +CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"} + + +def _cookie_name_for_role(role: str) -> str: + """Devuelve el nombre de cookie según el rol del usuario.""" + return "client_access_token" if role in CLIENT_ROLES else "internal_access_token" from app.api.schemas.auth import ( LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse, TwoFactorStatusResponse, TwoFactorSetupResponse, @@ -41,6 +49,7 @@ settings = get_settings() async def login( login_data: LoginRequest, request: Request, + response: Response, db: AsyncSession = Depends(get_db) ): """ @@ -247,7 +256,20 @@ async def login( # Best-effort: clear per-identity limiter on success. if ident_key: await cache.delete(ident_key) - + + # Cookie diferenciada por rol para aislar sesiones entre frontends + cookie_name = _cookie_name_for_role( + user.role.value if hasattr(user.role, "value") else user.role + ) + response.set_cookie( + key=cookie_name, + value=access_token, + httponly=True, + secure=settings.is_production(), + samesite="strict" if settings.is_production() else "lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + return LoginResponse( access_token=access_token, refresh_token=refresh_token, @@ -330,6 +352,7 @@ async def refresh_token( @router.post("/logout") async def logout( + response: Response, token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db) ): @@ -387,7 +410,10 @@ async def logout( logger.warning("Failed to log audit entry", error=str(e)) logger.info("Logout successful", user_id=payload["sub"]) - + + # Borrar la cookie correcta según el rol del usuario + cookie_name = _cookie_name_for_role(payload.get("role", "")) + response.delete_cookie(key=cookie_name) return {"message": "Successfully logged out"} diff --git a/backend/app/api/v1/endpoints/reports.py b/backend/app/api/v1/endpoints/reports.py index aa1b704..0595d44 100644 --- a/backend/app/api/v1/endpoints/reports.py +++ b/backend/app/api/v1/endpoints/reports.py @@ -7,7 +7,7 @@ Accesible por ADMIN y SUPPORT_MANAGER. from fastapi import APIRouter, Depends, Query, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, case, text +from sqlalchemy import select, func, and_, case, text, literal_column from typing import Optional, List from datetime import datetime, timedelta, timezone import uuid @@ -505,20 +505,23 @@ async def get_report_trends( tenant_filter = Ticket.tenant_id == current_user.tenant_id # Tickets creados por día + # literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros + # ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY. + _day_lit = literal_column("'day'") created_rows = (await db.execute( select( - func.date_trunc("day", Ticket.created_at).label("day"), + func.date_trunc(_day_lit, Ticket.created_at).label("day"), func.count(Ticket.id).label("cnt"), ) .where(and_(tenant_filter, Ticket.created_at >= period_start)) - .group_by(func.date_trunc("day", Ticket.created_at)) - .order_by(func.date_trunc("day", Ticket.created_at)) + .group_by(func.date_trunc(_day_lit, Ticket.created_at)) + .order_by(func.date_trunc(_day_lit, Ticket.created_at)) )).all() # Tickets resueltos por día (según resolved_at) resolved_rows = (await db.execute( select( - func.date_trunc("day", Ticket.resolved_at).label("day"), + func.date_trunc(_day_lit, Ticket.resolved_at).label("day"), func.count(Ticket.id).label("cnt"), ) .where(and_( @@ -526,8 +529,8 @@ async def get_report_trends( Ticket.resolved_at >= period_start, Ticket.resolved_at.isnot(None), )) - .group_by(func.date_trunc("day", Ticket.resolved_at)) - .order_by(func.date_trunc("day", Ticket.resolved_at)) + .group_by(func.date_trunc(_day_lit, Ticket.resolved_at)) + .order_by(func.date_trunc(_day_lit, Ticket.resolved_at)) )).all() created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows} diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index a06fdfe..1539785 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -48,11 +48,17 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db) category = await db.get(Category, category_uuid) if not category: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.") + # ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection) + if category.tenant_id != current_user.tenant_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.") if system_uuid: system = await db.get(System, system_uuid) if not system: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.") + # ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection) + if system.tenant_id != current_user.tenant_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.") sla_response_due, sla_resolution_due = calculate_sla_deadlines(category) assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None diff --git a/backend/app/main.py b/backend/app/main.py index f84d798..efd96ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -87,8 +87,14 @@ if settings.is_production(): "X-Correlation-ID", ] else: - cors_allow_methods = ["*"] - cors_allow_headers = ["*"] + cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + cors_allow_headers = [ + "Authorization", + "Content-Type", + "X-Tenant-ID", + "X-Tenant-Slug", + "X-Correlation-ID", + ] app.add_middleware( CORSMiddleware, diff --git a/backend/app/middleware/tenant.py b/backend/app/middleware/tenant.py index 78de68e..2b38636 100644 --- a/backend/app/middleware/tenant.py +++ b/backend/app/middleware/tenant.py @@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware): "/v1/auth/refresh", "/api/v1/auth/logout", "/v1/auth/logout", + "/api/v1/auth/me", + "/v1/auth/me", "/api/v1/auth/forgot-password", "/v1/auth/forgot-password", "/api/v1/auth/reset-password", diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts index 98c3a24..7e70a94 100644 --- a/frontend-client/src/lib/stores/app.ts +++ b/frontend-client/src/lib/stores/app.ts @@ -29,15 +29,17 @@ const initialState: AppState = { // API helper function async function apiCall(endpoint: string, options: RequestInit = {}) { const authState = get(auth); - + const headers: Record = { + 'Content-Type': 'application/json', + 'X-App': 'client', + ...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}), + ...(options.headers as Record ?? {}) + }; + if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`; const response = await fetch(`/api/v1${endpoint}`, { ...options, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authState.token}`, - ...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}), - ...options.headers - } + credentials: 'include', + headers }); if (!response.ok) { diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts index 2a6263a..3edfd2e 100644 --- a/frontend-client/src/lib/stores/auth.ts +++ b/frontend-client/src/lib/stores/auth.ts @@ -50,26 +50,26 @@ function createAuthStore() { return { subscribe, - // Initialize auth from localStorage - init: () => { + // Rehidrata sesión desde cookie HttpOnly (no toca localStorage) + init: async () => { if (typeof window !== 'undefined') { - const token = localStorage.getItem('auth_token'); - const user = localStorage.getItem('auth_user'); - - if (token && user) { - try { - const parsedUser = JSON.parse(user); + try { + const response = await fetch('/api/v1/auth/me', { + credentials: 'include', + headers: { 'X-App': 'client' } + }); + if (response.ok) { + const user = await response.json(); set({ - user: parsedUser, - token, + user, + token: null, isAuthenticated: true, isLoading: false }); - } catch (error) { - console.error('Error parsing stored auth data:', error); - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_user'); } + // 401/400 es esperado cuando no hay sesión activa — no es un error + } catch (error) { + // Ignorar errores de red en init } } }, @@ -81,6 +81,7 @@ function createAuthStore() { try { const response = await fetch('/api/v1/auth/login', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', }, @@ -94,12 +95,6 @@ function createAuthStore() { const data: LoginResponse = await response.json(); - // Store auth data - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', data.access_token); - localStorage.setItem('auth_user', JSON.stringify(data.user)); - } - set({ user: data.user, token: data.access_token, @@ -113,22 +108,24 @@ function createAuthStore() { }, // Logout - logout: () => { + logout: async () => { + // Llamar al backend para que borre la cookie HttpOnly + try { + await fetch('/api/v1/auth/logout', { + method: 'POST', + credentials: 'include', + headers: { 'X-App': 'client' } + }); + } catch { /* ignorar errores de red */ } + set(initialState); if (typeof window !== 'undefined') { - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_user'); - // Immediate redirect after cleanup window.location.href = '/login'; } - set(initialState); }, // Update user data updateUser: (user: User) => { update(state => ({ ...state, user })); - if (typeof window !== 'undefined') { - localStorage.setItem('auth_user', JSON.stringify(user)); - } }, // Set loading state diff --git a/frontend-client/src/lib/stores/tickets.ts b/frontend-client/src/lib/stores/tickets.ts index 8c153ca..4efb07e 100644 --- a/frontend-client/src/lib/stores/tickets.ts +++ b/frontend-client/src/lib/stores/tickets.ts @@ -80,18 +80,22 @@ const initialState: TicketsState = { async function apiCall(endpoint: string, options: RequestInit = {}) { const authState = get(auth); - if (!authState.token || !authState.user) { + if (!authState.user) { throw new Error('Not authenticated'); } + const headers: Record = { + 'Content-Type': 'application/json', + 'X-App': 'client', + ...(options.headers as Record) + }; + if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`; + if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id; + const response = await fetch(`/api/v1${endpoint}`, { ...options, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authState.token}`, - 'X-Tenant-ID': authState.user.tenant_id, - ...options.headers - } + credentials: 'include', + headers }); if (!response.ok) { @@ -259,16 +263,18 @@ function createTicketsStore() { const authState = get(auth); - if (!authState.token || !authState.user) { + if (!authState.user) { throw new Error('Not authenticated'); } + const uploadHeaders: Record = { 'X-App': 'client' }; + if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`; + if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id; + const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { method: 'POST', - headers: { - 'Authorization': `Bearer ${authState.token}`, - 'X-Tenant-ID': authState.user.tenant_id - }, + credentials: 'include', + headers: uploadHeaders, body: formData }); @@ -338,16 +344,18 @@ function createTicketsStore() { downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => { const authState = get(auth); - if (!authState.token || !authState.user) { + if (!authState.user) { throw new Error('Not authenticated'); } + const dlHeaders: Record = { 'X-App': 'client' }; + if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`; + if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id; + const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, { method: 'GET', - headers: { - 'Authorization': `Bearer ${authState.token}`, - 'X-Tenant-ID': authState.user.tenant_id - } + credentials: 'include', + headers: dlHeaders }); if (!response.ok) { diff --git a/frontend-client/src/lib/utils/api.ts b/frontend-client/src/lib/utils/api.ts new file mode 100644 index 0000000..a1ae2c5 --- /dev/null +++ b/frontend-client/src/lib/utils/api.ts @@ -0,0 +1,126 @@ +/** + * Cliente HTTP centralizado para frontend-client. + * Usa cookies HttpOnly (client_access_token) como fuente primaria de auth, + * con Bearer token como complemento cuando está disponible en memoria. + */ +import { auth } from '$lib/stores/auth'; +import { get } from 'svelte/store'; + +const API_BASE = '/api/v1'; + +interface RequestOptions extends RequestInit { + params?: Record; +} + +async function request(endpoint: string, options: RequestOptions = {}): Promise { + const { params, ...init } = options; + + let url = `${API_BASE}${endpoint}`; + if (params) { + const filteredParams = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + + if (Object.keys(filteredParams).length > 0) { + url += `?${new URLSearchParams(filteredParams).toString()}`; + } + } + + const authState = get(auth); + const headers = new Headers(init.headers); + + // Bearer header cuando el token está en memoria (sesión activa sin reload) + if (authState.token) { + headers.set('Authorization', `Bearer ${authState.token}`); + } + if (authState.user?.tenant_id && !headers.has('X-Tenant-ID')) { + headers.set('X-Tenant-ID', authState.user.tenant_id); + } + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + // Identifica este frontend para que el backend use client_access_token + headers.set('X-App', 'client'); + + const response = await fetch(url, { + ...init, + credentials: 'include', + headers + }); + + if (response.status === 401) { + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw new Error('Unauthorized'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `API error: ${response.statusText}`); + } + + if (response.status === 204) { + return {} as T; + } + + return response.json(); +} + +async function downloadFile(endpoint: string, filename: string): Promise { + const authState = get(auth); + const headers = new Headers(); + + if (authState.token) { + headers.set('Authorization', `Bearer ${authState.token}`); + } + if (authState.user?.tenant_id) { + headers.set('X-Tenant-ID', authState.user.tenant_id); + } + headers.set('X-App', 'client'); + + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'GET', + credentials: 'include', + headers + }); + + if (response.status === 401) { + if (typeof window !== 'undefined') window.location.href = '/login'; + throw new Error('Unauthorized'); + } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `Download error: ${response.statusText}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); +} + +export const api = { + get: (endpoint: string, params?: Record) => + request(endpoint, { method: 'GET', params }), + + post: (endpoint: string, body?: any) => + request(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }), + + put: (endpoint: string, body?: any) => + request(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }), + + patch: (endpoint: string, body?: any) => + request(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }), + + delete: (endpoint: string) => + request(endpoint, { method: 'DELETE' }), + + downloadFile: (endpoint: string, filename: string) => + downloadFile(endpoint, filename) +}; diff --git a/frontend-client/src/routes/+layout.svelte b/frontend-client/src/routes/+layout.svelte index d083aaa..e4a99c8 100644 --- a/frontend-client/src/routes/+layout.svelte +++ b/frontend-client/src/routes/+layout.svelte @@ -11,8 +11,8 @@ let mounted = false; - onMount(() => { - auth.init(); + onMount(async () => { + await auth.init(); mounted = true; }); @@ -27,6 +27,12 @@
+ {#if !mounted} + +
+
+
+ {:else} {#if showHeader}
{/if} @@ -39,6 +45,7 @@

ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft

+ {/if} {#each $toast.toasts as toastMessage (toastMessage.id)} diff --git a/frontend-client/src/routes/organization/+page.svelte b/frontend-client/src/routes/organization/+page.svelte index d7c0d48..a49c749 100644 --- a/frontend-client/src/routes/organization/+page.svelte +++ b/frontend-client/src/routes/organization/+page.svelte @@ -38,11 +38,14 @@ async function loadProfile() { isLoading = true; try { + const headers: Record = { + 'X-App': 'client', + 'X-Tenant-ID': $auth.user?.tenant_id ?? '' + }; + if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`; const response = await fetch('/api/v1/client-profile/', { - headers: { - Authorization: `Bearer ${$auth.token}`, - 'X-Tenant-ID': $auth.user?.tenant_id ?? '' - } + credentials: 'include', + headers }); if (!response.ok) throw new Error((await response.json()).detail); profile = await response.json(); @@ -62,13 +65,16 @@ async function saveProfile() { isSaving = true; try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-App': 'client', + 'X-Tenant-ID': $auth.user?.tenant_id ?? '' + }; + if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`; const response = await fetch('/api/v1/client-profile/', { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${$auth.token}`, - 'X-Tenant-ID': $auth.user?.tenant_id ?? '' - }, + credentials: 'include', + headers, body: JSON.stringify(form) }); if (!response.ok) throw new Error((await response.json()).detail); diff --git a/frontend-client/src/routes/profile/+page.svelte b/frontend-client/src/routes/profile/+page.svelte index 63352ae..4153dc1 100644 --- a/frontend-client/src/routes/profile/+page.svelte +++ b/frontend-client/src/routes/profile/+page.svelte @@ -34,7 +34,11 @@ try { const response = await fetch('/api/v1/auth/2fa/setup', { method: 'POST', - headers: { Authorization: `Bearer ${$auth.token}` } + credentials: 'include', + headers: { + 'X-App': 'client', + ...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {}) + } }); if (!response.ok) throw new Error((await response.json()).detail); const data = await response.json(); @@ -57,7 +61,12 @@ try { const response = await fetch('/api/v1/auth/2fa/enable', { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` }, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-App': 'client', + ...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {}) + }, body: JSON.stringify({ totp_code: totpSetupCode }) }); if (!response.ok) throw new Error((await response.json()).detail); @@ -84,7 +93,12 @@ try { const response = await fetch('/api/v1/auth/2fa/disable', { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` }, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-App': 'client', + ...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {}) + }, body: JSON.stringify({ totp_code: disableTotpCode }) }); if (!response.ok) throw new Error((await response.json()).detail); @@ -157,16 +171,20 @@ async function loadBusinessProfile() { try { - if (!$auth.token || !$auth.user) { + if (!$auth.user) { console.warn('Usuario no autenticado'); return; } + const _lpHeaders: Record = { + 'X-App': 'client', + 'X-Tenant-ID': $auth.user.tenant_id + }; + if ($auth.token) _lpHeaders['Authorization'] = `Bearer ${$auth.token}`; + const response = await fetch('/api/v1/client-profile/', { - headers: { - Authorization: `Bearer ${$auth.token}`, - 'X-Tenant-ID': $auth.user.tenant_id - } + credentials: 'include', + headers: _lpHeaders }); if (response.ok) { @@ -267,9 +285,11 @@ try { const response = await fetch('/api/v1/auth/profile', { method: 'PATCH', + credentials: 'include', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${$auth.token}` + 'X-App': 'client', + ...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {}) }, body: JSON.stringify({ first_name: firstName.trim(), @@ -300,9 +320,11 @@ try { const response = await fetch('/api/v1/auth/change-password', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${$auth.token}` + 'X-App': 'client', + ...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {}) }, body: JSON.stringify({ current_password: currentPassword, @@ -349,13 +371,16 @@ profileData.credit_limit = parseFloat(profileData.credit_limit); } + const _bpHeaders: Record = { + 'Content-Type': 'application/json', + 'X-App': 'client', + 'X-Tenant-ID': $auth.user?.tenant_id ?? '' + }; + if ($auth.token) _bpHeaders['Authorization'] = `Bearer ${$auth.token}`; const response = await fetch('/api/v1/client-profile/', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${$auth.token}`, - 'X-Tenant-ID': $auth.user.tenant_id - }, + credentials: 'include', + headers: _bpHeaders, body: JSON.stringify(profileData) }); diff --git a/frontend-internal/src/lib/stores/auth.ts b/frontend-internal/src/lib/stores/auth.ts index 4d3a0ee..9a5c74c 100644 --- a/frontend-internal/src/lib/stores/auth.ts +++ b/frontend-internal/src/lib/stores/auth.ts @@ -60,32 +60,34 @@ const initialState: AuthState = { function createAuthStore() { const { subscribe, set, update } = writable(initialState); + // Track current state for uso interno (evita dependencias circulares) + let _state = initialState; + subscribe(s => { _state = s; }); + return { subscribe, - // Initialize auth from localStorage - init: () => { + // Rehidrata sesión desde cookie HttpOnly (no toca localStorage) + init: async () => { if (typeof window !== 'undefined') { - const token = localStorage.getItem('internal_auth_token'); - const refreshToken = localStorage.getItem('internal_auth_refresh_token'); - const user = localStorage.getItem('internal_auth_user'); - - if (token && user) { - try { - const parsedUser = JSON.parse(user); + try { + const response = await fetch('/api/v1/auth/me', { + credentials: 'include', + headers: { 'X-App': 'internal' } + }); + if (response.ok) { + const user = await response.json(); set({ - user: parsedUser, - token, - refreshToken: refreshToken || null, + user, + token: null, + refreshToken: null, isAuthenticated: true, isLoading: false }); - } catch (error) { - console.error('Error parsing stored auth data:', error); - localStorage.removeItem('internal_auth_token'); - localStorage.removeItem('internal_auth_refresh_token'); - localStorage.removeItem('internal_auth_user'); } + // 401/400 es esperado cuando no hay sesión activa — no es un error + } catch (error) { + // Ignorar errores de red en init } } }, @@ -97,6 +99,7 @@ function createAuthStore() { try { const response = await fetch('/api/v1/auth/login', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', }, @@ -109,15 +112,6 @@ function createAuthStore() { } const data: LoginResponse = await response.json(); - - // Store auth data - if (typeof window !== 'undefined') { - localStorage.setItem('internal_auth_token', data.access_token); - if (data.refresh_token) { - localStorage.setItem('internal_auth_refresh_token', data.refresh_token); - } - localStorage.setItem('internal_auth_user', JSON.stringify(data.user)); - } set({ user: data.user, @@ -134,21 +128,18 @@ function createAuthStore() { // Refresh Session refreshSession: async (): Promise => { - // Need to get current state to access refresh token, logic simplified - let currentRefreshToken: string | null = null; - if (typeof window !== 'undefined') { - currentRefreshToken = localStorage.getItem('internal_auth_refresh_token'); - } + const currentRefreshToken = _state.refreshToken; if (!currentRefreshToken) { throw new Error("No refresh token available"); } - update (state => ({ ...state, isLoading: true })); + update(state => ({ ...state, isLoading: true })); try { const response = await fetch('/api/v1/auth/refresh', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', }, @@ -166,11 +157,6 @@ function createAuthStore() { const data: TokenResponse = await response.json(); - // Update token in storage and state - if (typeof window !== 'undefined') { - localStorage.setItem('internal_auth_token', data.access_token); - } - update(state => ({ ...state, token: data.access_token, @@ -184,25 +170,24 @@ function createAuthStore() { }, // Logout - logout: () => { - if (typeof window !== 'undefined') { - localStorage.removeItem('internal_auth_token'); - localStorage.removeItem('internal_auth_refresh_token'); - localStorage.removeItem('internal_auth_user'); - } + logout: async () => { + // Llamar al backend para que borre la cookie HttpOnly + try { + await fetch('/api/v1/auth/logout', { + method: 'POST', + credentials: 'include', + headers: { 'X-App': 'internal' } + }); + } catch { /* ignorar errores de red */ } set(initialState); - // Optional: Redirect to login if (typeof window !== 'undefined') { - window.location.href = '/login'; + window.location.href = '/login'; } }, // Update user data updateUser: (user: InternalUser) => { update(state => ({ ...state, user })); - if (typeof window !== 'undefined') { - localStorage.setItem('internal_auth_user', JSON.stringify(user)); - } }, // Set loading state diff --git a/frontend-internal/src/lib/utils/api.ts b/frontend-internal/src/lib/utils/api.ts index b2639e6..78a4415 100644 --- a/frontend-internal/src/lib/utils/api.ts +++ b/frontend-internal/src/lib/utils/api.ts @@ -24,16 +24,8 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi } const authState = get(auth); - const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); - - // Resolve tenant_id from store or from the persisted user object in localStorage - let tenantId = authState.user?.tenant_id ?? null; - if (!tenantId && typeof window !== 'undefined') { - try { - const stored = localStorage.getItem('internal_auth_user'); - if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null; - } catch { /* ignore */ } - } + const token = authState.token; + const tenantId = authState.user?.tenant_id ?? null; const headers = new Headers(init.headers); if (token) { @@ -45,17 +37,18 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi if (!headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } + // Identifica este frontend para que el backend use la cookie correcta + headers.set('X-App', 'internal'); const response = await fetch(url, { ...init, + credentials: 'include', headers }); if (response.status === 401) { // Token expired or invalid if (typeof window !== 'undefined') { - localStorage.removeItem('internal_auth_token'); - localStorage.removeItem('internal_auth_user'); window.location.href = '/login'; } throw new Error('Unauthorized'); @@ -76,15 +69,8 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi async function downloadFile(endpoint: string, filename: string): Promise { const authState = get(auth); - const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); - - let tenantId = authState.user?.tenant_id ?? null; - if (!tenantId && typeof window !== 'undefined') { - try { - const stored = localStorage.getItem('internal_auth_user'); - if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null; - } catch { /* ignore */ } - } + const token = authState.token; + const tenantId = authState.user?.tenant_id ?? null; const headers = new Headers(); if (token) { @@ -93,16 +79,16 @@ async function downloadFile(endpoint: string, filename: string): Promise { if (tenantId) { headers.set('X-Tenant-ID', tenantId); } + headers.set('X-App', 'internal'); const response = await fetch(`${API_BASE}${endpoint}`, { method: 'GET', + credentials: 'include', headers }); if (response.status === 401) { if (typeof window !== 'undefined') { - localStorage.removeItem('internal_auth_token'); - localStorage.removeItem('internal_auth_user'); window.location.href = '/login'; } throw new Error('Unauthorized'); diff --git a/frontend-internal/src/routes/+layout.svelte b/frontend-internal/src/routes/+layout.svelte index b73fdda..195dd8e 100644 --- a/frontend-internal/src/routes/+layout.svelte +++ b/frontend-internal/src/routes/+layout.svelte @@ -13,8 +13,8 @@ let sidebarOpen = false; let mounted = false; - onMount(() => { - auth.init(); + onMount(async () => { + await auth.init(); mounted = true; }); @@ -29,7 +29,12 @@
- {#if $auth.isAuthenticated} + {#if !mounted} + +
+
+
+ {:else if $auth.isAuthenticated}
diff --git a/frontend-internal/src/routes/test-report/+page.svelte b/frontend-internal/src/routes/test-report/+page.svelte index fd04e9c..e69de29 100644 --- a/frontend-internal/src/routes/test-report/+page.svelte +++ b/frontend-internal/src/routes/test-report/+page.svelte @@ -1,492 +0,0 @@ - - - - Reporte de Endpoints - ServiceManager - - -
- -
-
-

Reporte de Endpoints & Páginas

-

- Diagnóstico de conectividad de todos los endpoints del backend y páginas del frontend. -

-
-
- -
-
- - - {#if testedCount() > 0} -
-
-
{testedCount()}/{totalEndpoints}
-
Endpoints probados
-
-
-
{totalOk}
-
OK / Exitosos
-
-
-
{totalFail}
-
Errores / Fallidos
-
-
- {/if} - - -
- - -
- - - {#if activeTab === 'endpoints'} -
- {#each GROUPS as group} -
- -
-
- - {group.name} - - {group.endpoints.length} endpoint{group.endpoints.length !== 1 ? 's' : ''} -
-
- {#each group.endpoints as ep} - {#if results[ep.id].tested} - - {/if} - {/each} -
-
- - -
- {#each group.endpoints as ep} - {@const r = results[ep.id]} - {@const badge = statusBadge(r)} -
-
- - - {ep.method} - - - - /api/v1{ep.path} - - - {ep.label} - - -
- {#if r.ms !== null && r.tested} - {r.ms}ms - {/if} - {badge.text} - - - - {#if r.tested && r.preview} - - {/if} -
-
- - -

{ep.description}

- - - {#if r.tested && r.error} -
{r.error}
- {/if} - - - {#if expandedIds.has(ep.id) && r.preview} -
{r.preview}
- {/if} -
- {/each} -
-
- {/each} -
- {/if} - - - {#if activeTab === 'pages'} -
- - - - - - - - - - - - {#each FRONTEND_PAGES as pg} - - - - - - - - {/each} - -
PáginaRutaDescripciónRolesAcción
- {pg.label} - - {pg.href} - - {pg.description} - -
- {#each pg.roles as role} - {role} - {/each} -
-
- - Abrir ↗ - -
- - -
-

- Nota: Las páginas se abren en una nueva pestaña para verificar su renderizado. - Asegúrate de estar autenticado antes de acceder a rutas protegidas. -

-
- {#each FRONTEND_PAGES as pg} - - - - - {pg.label} - {pg.href} - - {/each} -
-
-
- {/if} -
diff --git a/workers/app/core/database.py b/workers/app/core/database.py index 62603e7..ac13a3f 100644 --- a/workers/app/core/database.py +++ b/workers/app/core/database.py @@ -7,11 +7,42 @@ Async database session management para Celery workers from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy import DateTime, func +from sqlalchemy import types as sa_types +from sqlalchemy.types import TypeDecorator, CHAR +from sqlalchemy.dialects.postgresql import UUID as PG_UUID from contextlib import asynccontextmanager from typing import AsyncGenerator import uuid from datetime import datetime + +class GUID(TypeDecorator): + """UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests).""" + + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return None + if dialect.name == "postgresql": + return value + if isinstance(value, uuid.UUID): + return str(value) + return str(uuid.UUID(str(value))) + + def process_result_value(self, value, dialect): + if value is None: + return None + if not isinstance(value, uuid.UUID): + return uuid.UUID(str(value)) + return value + from app.core.config import get_settings settings = get_settings()