Sesiones correctas

This commit is contained in:
2026-03-04 13:31:38 -07:00
parent 3b46f48655
commit 16b3dc5d47
10 changed files with 127 additions and 164 deletions

View File

@@ -13,9 +13,9 @@ services:
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro #- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
ports: ports:
- "5432:5432" - "5433:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
interval: 10s interval: 10s
@@ -215,7 +215,7 @@ services:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- uploads_data:/var/www/uploads:ro - uploads_data:/var/www/uploads:ro
ports: ports:
- "80:80" - "8088:80"
depends_on: depends_on:
- backend - backend
- frontend-client - frontend-client

View File

@@ -1,19 +1,37 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? ''; // No restaurar sesión en la página de login
if (cookie) { if (event.url.pathname === '/login') {
try { event.locals.user = null;
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000'; return resolve(event);
const response = await fetch(`${apiUrl}/v1/auth/me`, { }
headers: { cookie, 'X-App': 'client' }
}); const cookieHeader = event.request.headers.get('cookie') ?? '';
event.locals.user = response.ok ? await response.json() : null; const cookieMatch = cookieHeader.match(/(?:client_access_token|internal_access_token)=([^;]+)/);
} catch { const token = cookieMatch?.[1];
event.locals.user = null;
} if (token) {
} else { try {
event.locals.user = null; const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
} const response = await fetch(`${apiUrl}/v1/auth/me`, {
return resolve(event); headers: {
'Authorization': `Bearer ${token}`,
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft'
}
});
if (response.ok) {
event.locals.user = await response.json();
} else {
event.locals.user = null;
event.cookies.delete('client_access_token', { path: '/' });
event.cookies.delete('internal_access_token', { path: '/' });
}
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
}; };

View File

@@ -1,7 +1,5 @@
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
// Types
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -13,131 +11,94 @@ export interface User {
is_two_factor_enabled: boolean; is_two_factor_enabled: boolean;
created_at: string; created_at: string;
} }
export interface AuthState { export interface AuthState {
user: User | null; user: User | null;
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
} }
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
password: string; password: string;
tenant_slug: string; tenant_slug: string;
totp_code?: string; totp_code?: string;
} }
export interface LoginResponse { export interface LoginResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
expires_in: number; expires_in: number;
user: User; user: User;
} }
// Initial state
const initialState: AuthState = { const initialState: AuthState = {
user: null, user: null,
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false isLoading: false
}; };
// Create auth store
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState); const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return { return {
subscribe, subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => { init: async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
const response = await fetch('/api/v1/auth/me', { const response = await fetch('/api/v1/auth/me', {
credentials: 'include', credentials: 'include',
headers: { 'X-App': 'client' } headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
}); });
if (response.ok) { if (response.ok) {
const user = await response.json(); const user = await response.json();
set({ set({ user, token: null, isAuthenticated: true, isLoading: false });
user,
token: null,
isAuthenticated: true,
isLoading: false
});
} }
// 401/400 es esperado cuando no hay sesión activa — no es un error } catch (error) {}
} catch (error) {
// Ignorar errores de red en init
}
} }
}, },
// Login
login: async (credentials: LoginRequest): Promise<void> => { login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true })); update(state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
}, },
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'Login failed'); throw new Error(error.detail || 'Login failed');
} }
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
set({
user: data.user,
token: data.access_token,
isAuthenticated: true,
isLoading: false
});
} catch (error) { } catch (error) {
update(state => ({ ...state, isLoading: false })); update(state => ({ ...state, isLoading: false }));
throw error; throw error;
} }
}, },
// Logout
logout: async () => { logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try { try {
const token = _state.token;
await fetch('/api/v1/auth/logout', { await fetch('/api/v1/auth/logout', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'X-App': 'client' } headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
}); });
} catch { /* ignorar errores de red */ } } catch {}
set(initialState); set(initialState);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
} }
}, },
updateUser: (user: User) => { update(state => ({ ...state, user })); },
// Update user data setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
updateUser: (user: User) => { setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
update(state => ({ ...state, user }));
},
// Set user from SSR pre-load (no fetch required)
setUser: (user: User) => {
set({ user, token: null, isAuthenticated: true, isLoading: false });
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
}; };
} }
export const auth = createAuthStore(); export const auth = createAuthStore();

View File

@@ -1,12 +1,8 @@
/**
* 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 { auth } from '$lib/stores/auth';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
const TENANT_SLUG = 'aduanasoft';
interface RequestOptions extends RequestInit { interface RequestOptions extends RequestInit {
params?: Record<string, string>; params?: Record<string, string>;
@@ -20,7 +16,6 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const filteredParams = Object.entries(params) const filteredParams = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '') .filter(([, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) { if (Object.keys(filteredParams).length > 0) {
url += `?${new URLSearchParams(filteredParams).toString()}`; url += `?${new URLSearchParams(filteredParams).toString()}`;
} }
@@ -29,7 +24,6 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth); const authState = get(auth);
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
// Bearer header cuando el token está en memoria (sesión activa sin reload)
if (authState.token) { if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`); headers.set('Authorization', `Bearer ${authState.token}`);
} }
@@ -39,8 +33,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
if (!headers.has('Content-Type')) { if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
// Identifica este frontend para que el backend use client_access_token
headers.set('X-App', 'client'); headers.set('X-App', 'client');
headers.set('X-Tenant-Slug', TENANT_SLUG);
const response = await fetch(url, { const response = await fetch(url, {
...init, ...init,
@@ -78,6 +72,7 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
headers.set('X-Tenant-ID', authState.user.tenant_id); headers.set('X-Tenant-ID', authState.user.tenant_id);
} }
headers.set('X-App', 'client'); headers.set('X-App', 'client');
headers.set('X-Tenant-Slug', TENANT_SLUG);
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET', method: 'GET',
@@ -108,19 +103,14 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
export const api = { export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) => get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }), request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body?: any) => post: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
put: <T>(endpoint: string, body?: any) => put: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(endpoint: string, body?: any) => patch: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(endpoint: string) => delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }), request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) => downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename) downloadFile(endpoint, filename)
}; };

View File

@@ -7,7 +7,7 @@
let email = ''; let email = '';
let password = ''; let password = '';
let tenantSlug = 'aduanasoft-demo'; let tenantSlug = 'aduanasoft';
let totpCode = ''; let totpCode = '';
let isLoading = false; let isLoading = false;
let showTwoFactor = false; let showTwoFactor = false;
@@ -34,7 +34,7 @@
await auth.login({ await auth.login({
email, email,
password, password,
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo', tenant_slug: tenantSlug.trim() || 'aduanasoft',
totp_code: totpCode || undefined totp_code: totpCode || undefined
}); });

View File

@@ -1,38 +1,35 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
port: 3000, port: 3000,
host: '0.0.0.0', host: '0.0.0.0',
watch: { watch: {
usePolling: true, usePolling: true,
interval: 500 interval: 500
}, },
// HMR: el browser llega al contenedor en el mismo puerto 3000 hmr: {
hmr: { host: 'localhost',
host: 'localhost', clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000') },
}, fs: {
// Permitir que Vite sirva archivos del filesystem del contenedor allow: ['/app', '.'],
fs: { strict: false
allow: ['/app', '.'], },
strict: false proxy: {
}, '/api': {
proxy: { target: process.env.PUBLIC_API_URL || 'http://backend:8000',
'/api': { changeOrigin: true,
target: process.env.PUBLIC_API_URL || 'http://localhost:8000', rewrite: (path) => path.replace(/^\/api/, '')
changeOrigin: true, }
rewrite: (path) => path.replace(/^\/api/, '') }
} },
} preview: {
}, port: 3000,
preview: { host: '0.0.0.0'
port: 3000, },
host: '0.0.0.0' build: {
}, target: 'esnext'
build: { }
target: 'esnext'
}
}); });

View File

@@ -102,6 +102,7 @@ function createAuthStore() {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
}, },
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
}); });

View File

@@ -32,7 +32,7 @@
await auth.login({ await auth.login({
email, email,
password, password,
tenant_slug: 'aduanasoft-demo', tenant_slug: 'aduanasoft',
totp_code: totpCode || undefined totp_code: totpCode || undefined
}); });

View File

@@ -1,39 +1,35 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host port: parseInt(process.env.PORT || '3001'),
port: parseInt(process.env.PORT || '3001'), host: '0.0.0.0',
host: '0.0.0.0', watch: {
watch: { usePolling: true,
usePolling: true, interval: 500
interval: 500 },
}, hmr: {
// HMR: el browser llega al contenedor a través del puerto 3001 del host host: 'localhost',
hmr: { clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
host: 'localhost', },
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001') fs: {
}, allow: ['/app', '.'],
// Permitir que Vite sirva archivos del filesystem del contenedor strict: false
fs: { },
allow: ['/app', '.'], proxy: {
strict: false '/api': {
}, target: process.env.PUBLIC_API_URL || 'http://backend:8000',
proxy: { changeOrigin: true,
'/api': { rewrite: (path) => path.replace(/^\/api/, '')
target: process.env.PUBLIC_API_URL || 'http://localhost:8000', }
changeOrigin: true, }
rewrite: (path) => path.replace(/^\/api/, '') },
} preview: {
} port: parseInt(process.env.PORT || '3001'),
}, host: '0.0.0.0'
preview: { },
port: parseInt(process.env.PORT || '3001'), build: {
host: '0.0.0.0' target: 'esnext'
}, }
build: {
target: 'esnext'
}
}); });

BIN
migrations.sql Normal file

Binary file not shown.