Skip to content

Autenticacion Frontend - Portal de Clientes

DOCUMENTACION RETROSPECTIVA - Generada a partir de codigo implementado el 2026-04-27.

Modulo: Portal de Clientes Tipo: Process Estado: Implementado Fecha: 2026-04-27 Repo frontend: portal-usuarios/Repo backend: bautista-backend/Modules/Portal/


Estrategia de tokens

El portal usa una estrategia de dos tokens con superficies de exposicion distintas:

TokenStorage frontendComo viajaVencimientoQuien lo gestiona
Access token (JWT)Memoria de React (state en AuthContext)Header Authorization: Bearer {token}Corto (controlado por backend)El frontend lo recibe en el body y lo guarda en state
Refresh tokenCookie httpOnly portal_refresh_tokenCookie automatica del browser30 dias (Max-Age=2592000)El backend lo emite y lo borra; el JS no lo lee

Por que esta estrategia

  • El access token vive en memoria -> al recargar la pagina se pierde, pero un atacante con XSS no lo puede leer de localStorage ni sessionStorage porque ahi NO esta. La superficie XSS sigue siendo no nula (un payload XSS puede leer el state de React), pero queda mucho mas chica que persistirlo.
  • El refresh token vive en cookie httpOnly -> es invisible para JavaScript. Solo el browser lo manda automaticamente al backend en endpoints especificos. Un atacante con XSS no puede exfiltrarlo.
  • El refresh token se rota en cada uso (ver AuthService::refreshToken) -> robar la cookie da una ventana acotada porque el siguiente uso legitimo invalida la sesion robada.

Trade-off explicito: al recargar, hay un periodo de hidratacion (isHydrating) en el que el portal hace una llamada a /portal/auth/refresh-token para reobtener el access token usando la cookie.


Flujo de login

Implementado en src/features/auth/components/LoginForm.tsx -> useLogin (useAuth.hooks.ts) -> AuthContext.login.

PasoActorAccion
1UsuarioCompleta formulario con DNI y contrasena
2LoginFormValida con Zod (loginSchema) y dispara useLogin().mutateAsync
3AuthContext.loginapiClient.post('/backend/portal/auth/login', { sucursalId, dni, plainPassword })
4Backend (PortalAuthController::login)Valida credenciales, emite access + refresh token, setea cookie portal_refresh_token
5FrontendRecibe access_token en el body, lo guarda en state via setAccessTokenState
6FrontendDecodifica claims con tokenStorage.decodeAccessToken (sin verificar firma) y arma PortalUser
7LoginViewLlama onSuccess() que navega a /dashboard
8authedRoute.beforeLoadPermite acceso porque auth.isAuthenticated === true

Notas:

  • El sucursalId se inyecta como hidden input desde la variable de entorno VITE_SUCURSAL_ID.
  • Si las credenciales son invalidas (401), el form muestra toast.error('Credenciales invalidas...'). La excepcion concreta del backend (InvalidCredentialsException, LockedException) no se diferencia en la UI actual.

Flujo de refresh

Implementado en src/lib/api-client.ts (interceptor) + src/lib/auth-refresh.ts (cola singleton).

Trigger

Cualquier request del portal recibe 401. El interceptor de respuesta de apiClient lo intercepta.

Pasos

PasoCodigoAccion
1apiClient.interceptors.response.use(error)Detecta error.response.status === 401 y !original._retry
2InterceptorMarca original._retry = true para evitar loops
3InterceptorLlama handleUnauthorized() (de auth-refresh.ts)
4handleUnauthorizedSi ya hay un refreshPromise en curso, devuelve el mismo promise (de-duplicacion)
5doRefreshrefreshAxios.post('/backend/portal/auth/refresh-token', {}, { withCredentials: true })
6BrowserManda automaticamente la cookie portal_refresh_token
7BackendVerifica el refresh token, rota uno nuevo, devuelve nuevo access_token y reemplaza la cookie
8doRefreshLlama setAccessToken(token) -> propaga a AuthContext via _setAccessToken
9InterceptorReintenta la request original con el nuevo token (return apiClient(original))
10Si refresh fallaEl interceptor llama _onLogout?.(), que limpia el estado en AuthContext

Detalles clave

  • De-duplicacion de refresh: la cola en auth-refresh.ts garantiza que N requests paralelos que reciben 401 disparen UNA sola llamada al endpoint de refresh.
  • Instancia axios separada para refresh (refreshAxios): NO tiene interceptores de respuesta, evita loop infinito 401 -> refresh -> 401 -> refresh.
  • Hidratacion al montar la app (AuthProvider useEffect): al iniciar, se intenta POST /backend/portal/auth/refresh-token para reobtener access token desde la cookie. Si la cookie esta o expiro, se queda en estado no autenticado y isHydrating pasa a false.

Flujo de logout

Implementado en AuthContext.logout.

PasoCodigoAccion
1Componente UILlama useAuth().logout()
2AuthContext.logoutapiClient.post('/backend/portal/auth/logout')
3Backend (PortalAuthController::logout)Lee la cookie portal_refresh_token, llama AuthService::invalidateRefreshTokenByUuid, borra la cookie con Max-Age=0
4FrontendAunque el endpoint falle, el bloque finally limpia state local: setAccessTokenState(null) y setUser(null)
5RouterLas rutas autenticadas detectan isAuthenticated === false y redirigen a /login

Que se limpia y donde:

RecursoLimpieza
Cookie portal_refresh_tokenBackend: Set-Cookie: portal_refresh_token=; ... Max-Age=0
Refresh token persistido en DBBackend: AuthService::invalidateRefreshTokenByUuid
Access token en memoriaFrontend: setAccessTokenState(null)
Objeto PortalUser en contextFrontend: setUser(null)
Cache de TanStack QueryNo se invalida explicitamente. Pendiente de revision (ver Notas y pendientes)

CORS

El portal frontend y la API viven en dominios distintos (ejemplo: portal.tenant.com y api.tenant.com), por lo cual todo el trafico es cross-origin.

Configuracion del cliente

En src/lib/api-client.ts:

SettingValorPor que
baseURLimport.meta.env.VITE_BACKEND_URLCada tenant apunta a su API
withCredentials: truesiempreNecesario para que el browser MANDE y RECIBA la cookie de refresh
Headers X-Tenant-Id / X-Sucursal-Iddesde import.meta.envEl backend resuelve schema desde estos headers

Configuracion del servidor

Implementada en Modules/Portal/Infrastructure/Http/Middleware/PortalCorsMiddleware.php. Se aplica al grupo /portal/*.

Header de respuestaValorPor que
Access-Control-Allow-OriginPORTAL_ALLOWED_ORIGIN (origen especifico, NUNCA *)El wildcard * es incompatible con Allow-Credentials: true
Access-Control-Allow-CredentialstrueSin esto el browser ignora la cookie en respuestas cross-origin
Access-Control-Allow-HeadersContent-Type, Authorization, X-Requested-With, X-Tenant-Id, X-Sucursal-IdHeaders que el portal envia
Access-Control-Allow-MethodsGET, POST, PUT, DELETE, OPTIONSOPTIONS responde 200 directo (preflight short-circuit)

Definida en PortalAuthController::setRefreshCookie:

portal_refresh_token={value}; HttpOnly; Secure; SameSite=None; Path=/portal/auth; Max-Age=2592000
AtributoValorRazon
HttpOnlysiJS no la puede leer (defensa contra XSS)
SecuresiSolo viaja por HTTPS. Implica que en local no funciona sin TLS
SameSite=NonesiObligatorio para cross-origin. Si fuera Lax o Strict el browser NO la mandaria desde portal.tenant.com a api.tenant.com
Path=/portal/authsiLimita el alcance: la cookie solo viaja a endpoints /portal/auth/* (login, logout, refresh-token), no a endpoints de datos
Max-Age=259200030 diasCoincide con +30 days que usa AuthService para persistir el refresh_expires_at

Nota sobre SameSite=None: este valor es obligatorio cuando portal y API estan en dominios distintos. Si el deployment los pone en el mismo origin (por ejemplo, sirviendo la SPA y la API detras del mismo reverse proxy), se podria usar SameSite=Lax. La implementacion actual asume cross-origin.


Variables de entorno relevantes

Definidas en portal-usuarios/.env.example. Solo las relacionadas con autenticacion:

VariableRequeridaUso en auth
VITE_BACKEND_URLsibaseURL del axios; sin trailing slash
VITE_TENANT_IDsiHeader X-Tenant-Id en cada request
VITE_SUCURSAL_IDsiHeader X-Sucursal-Id y sucursalId por defecto en LoginForm

Las demas variables (VITE_APP_NAME, VITE_LOGO_URL, VITE_PRIMARY_COLOR, etc.) son de branding y se documentan en Configuracion y Deployment.

En el backend la variable relevante es PORTAL_ALLOWED_ORIGIN (ver .env.dist), que define el origen del portal autorizado para CORS.


Notas y pendientes

  • Limpieza de cache TanStack Query en logout: el codigo actual no llama queryClient.clear() ni invalida queries durante logout(). Si un usuario A hace logout y otro usuario B hace login en la misma pestana, datos cacheados de A podrian seguir presentes hasta que las queries se reejecuten. Pendiente de validacion con stakeholders / equipo de seguridad.
  • Diferenciacion de errores en login: el frontend muestra siempre el mismo toast ('Credenciales invalidas...') sin distinguir entre INVALID_CREDENTIALS (401) y ACCOUNT_LOCKED (423). Esto es coherente con anti-enumeracion, pero el usuario bloqueado no recibe informacion sobre lockout. Pendiente de validacion con UX.
  • Decode de JWT sin firma: tokenStorage.decodeAccessToken lee claims sin verificar firma. Es un comentario explicito del archivo: la verificacion real ocurre en backend. Esto es correcto, pero implica que el PortalUser que ve la UI puede ser manipulado por un atacante con control del state -- el backend nunca confia en lo que dice el frontend.

Ver tambien

Backend

Frontend


NOTA IMPORTANTE: Documentacion retrospectiva. Validar con stakeholders antes de considerarla final. Las secciones marcadas como "Pendiente de validacion" requieren confirmacion del equipo.