Appearance
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:
| Token | Storage frontend | Como viaja | Vencimiento | Quien 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 token | Cookie httpOnly portal_refresh_token | Cookie automatica del browser | 30 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
localStoragenisessionStorageporque 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.
| Paso | Actor | Accion |
|---|---|---|
| 1 | Usuario | Completa formulario con DNI y contrasena |
| 2 | LoginForm | Valida con Zod (loginSchema) y dispara useLogin().mutateAsync |
| 3 | AuthContext.login | apiClient.post('/backend/portal/auth/login', { sucursalId, dni, plainPassword }) |
| 4 | Backend (PortalAuthController::login) | Valida credenciales, emite access + refresh token, setea cookie portal_refresh_token |
| 5 | Frontend | Recibe access_token en el body, lo guarda en state via setAccessTokenState |
| 6 | Frontend | Decodifica claims con tokenStorage.decodeAccessToken (sin verificar firma) y arma PortalUser |
| 7 | LoginView | Llama onSuccess() que navega a /dashboard |
| 8 | authedRoute.beforeLoad | Permite acceso porque auth.isAuthenticated === true |
Notas:
- El
sucursalIdse inyecta comohidden inputdesde la variable de entornoVITE_SUCURSAL_ID. - Si las credenciales son invalidas (
401), el form muestratoast.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
| Paso | Codigo | Accion |
|---|---|---|
| 1 | apiClient.interceptors.response.use(error) | Detecta error.response.status === 401 y !original._retry |
| 2 | Interceptor | Marca original._retry = true para evitar loops |
| 3 | Interceptor | Llama handleUnauthorized() (de auth-refresh.ts) |
| 4 | handleUnauthorized | Si ya hay un refreshPromise en curso, devuelve el mismo promise (de-duplicacion) |
| 5 | doRefresh | refreshAxios.post('/backend/portal/auth/refresh-token', {}, { withCredentials: true }) |
| 6 | Browser | Manda automaticamente la cookie portal_refresh_token |
| 7 | Backend | Verifica el refresh token, rota uno nuevo, devuelve nuevo access_token y reemplaza la cookie |
| 8 | doRefresh | Llama setAccessToken(token) -> propaga a AuthContext via _setAccessToken |
| 9 | Interceptor | Reintenta la request original con el nuevo token (return apiClient(original)) |
| 10 | Si refresh falla | El interceptor llama _onLogout?.(), que limpia el estado en AuthContext |
Detalles clave
- De-duplicacion de refresh: la cola en
auth-refresh.tsgarantiza que N requests paralelos que reciben401disparen UNA sola llamada al endpoint de refresh. - Instancia axios separada para refresh (
refreshAxios): NO tiene interceptores de respuesta, evita loop infinito401 -> refresh -> 401 -> refresh. - Hidratacion al montar la app (
AuthProvideruseEffect): al iniciar, se intentaPOST /backend/portal/auth/refresh-tokenpara reobtener access token desde la cookie. Si la cookie esta o expiro, se queda en estado no autenticado yisHydratingpasa afalse.
Flujo de logout
Implementado en AuthContext.logout.
| Paso | Codigo | Accion |
|---|---|---|
| 1 | Componente UI | Llama useAuth().logout() |
| 2 | AuthContext.logout | apiClient.post('/backend/portal/auth/logout') |
| 3 | Backend (PortalAuthController::logout) | Lee la cookie portal_refresh_token, llama AuthService::invalidateRefreshTokenByUuid, borra la cookie con Max-Age=0 |
| 4 | Frontend | Aunque el endpoint falle, el bloque finally limpia state local: setAccessTokenState(null) y setUser(null) |
| 5 | Router | Las rutas autenticadas detectan isAuthenticated === false y redirigen a /login |
Que se limpia y donde:
| Recurso | Limpieza |
|---|---|
Cookie portal_refresh_token | Backend: Set-Cookie: portal_refresh_token=; ... Max-Age=0 |
| Refresh token persistido en DB | Backend: AuthService::invalidateRefreshTokenByUuid |
| Access token en memoria | Frontend: setAccessTokenState(null) |
Objeto PortalUser en context | Frontend: setUser(null) |
| Cache de TanStack Query | No 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:
| Setting | Valor | Por que |
|---|---|---|
baseURL | import.meta.env.VITE_BACKEND_URL | Cada tenant apunta a su API |
withCredentials: true | siempre | Necesario para que el browser MANDE y RECIBA la cookie de refresh |
Headers X-Tenant-Id / X-Sucursal-Id | desde import.meta.env | El 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 respuesta | Valor | Por que |
|---|---|---|
Access-Control-Allow-Origin | PORTAL_ALLOWED_ORIGIN (origen especifico, NUNCA *) | El wildcard * es incompatible con Allow-Credentials: true |
Access-Control-Allow-Credentials | true | Sin esto el browser ignora la cookie en respuestas cross-origin |
Access-Control-Allow-Headers | Content-Type, Authorization, X-Requested-With, X-Tenant-Id, X-Sucursal-Id | Headers que el portal envia |
Access-Control-Allow-Methods | GET, POST, PUT, DELETE, OPTIONS | OPTIONS responde 200 directo (preflight short-circuit) |
Configuracion de la cookie
Definida en PortalAuthController::setRefreshCookie:
portal_refresh_token={value}; HttpOnly; Secure; SameSite=None; Path=/portal/auth; Max-Age=2592000| Atributo | Valor | Razon |
|---|---|---|
HttpOnly | si | JS no la puede leer (defensa contra XSS) |
Secure | si | Solo viaja por HTTPS. Implica que en local no funciona sin TLS |
SameSite=None | si | Obligatorio para cross-origin. Si fuera Lax o Strict el browser NO la mandaria desde portal.tenant.com a api.tenant.com |
Path=/portal/auth | si | Limita el alcance: la cookie solo viaja a endpoints /portal/auth/* (login, logout, refresh-token), no a endpoints de datos |
Max-Age=2592000 | 30 dias | Coincide 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 usarSameSite=Lax. La implementacion actual asume cross-origin.
Variables de entorno relevantes
Definidas en portal-usuarios/.env.example. Solo las relacionadas con autenticacion:
| Variable | Requerida | Uso en auth |
|---|---|---|
VITE_BACKEND_URL | si | baseURL del axios; sin trailing slash |
VITE_TENANT_ID | si | Header X-Tenant-Id en cada request |
VITE_SUCURSAL_ID | si | Header 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 durantelogout(). 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 entreINVALID_CREDENTIALS(401) yACCOUNT_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.decodeAccessTokenlee claims sin verificar firma. Es un comentario explicito del archivo: la verificacion real ocurre en backend. Esto es correcto, pero implica que elPortalUserque 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
Modules/Portal/Application/Auth/Services/AuthService.php- Logica de login, register, refresh, logout, forgot/reset password.Modules/Portal/Infrastructure/Http/Routes/AuthRoutes.php- Definicion de endpoints publicos.Modules/Portal/Presentation/Auth/Controllers/PortalAuthController.php- Manejo HTTP, set/clear de cookie de refresh.Modules/Portal/Infrastructure/Http/Middleware/PortalCorsMiddleware.php- CORS especifico del portal.- Documentacion de seguridad backend
Frontend
src/contexts/AuthContext.tsx- Provider conlogin,logout,register, hidratacion al montar.src/lib/api-client.ts- Singleton axios, interceptors request/response.src/lib/auth-refresh.ts- Cola singleton para de-duplicar refresh paralelos.src/lib/token-storage.ts- Decode de JWT para leer claims en UI.src/routes/router.ts-authedRoute.beforeLoadque redirige a/loginsi no esta autenticado.
NOTA IMPORTANTE: Documentacion retrospectiva. Validar con stakeholders antes de considerarla final. Las secciones marcadas como "Pendiente de validacion" requieren confirmacion del equipo.