Appearance
Autenticacion - Portal de Clientes
Estrategia
Autenticacion JWT con password. Misma estrategia que el Admin UI pero con un espacio de usuarios separado (portal_users en lugar de users). El JWT contiene portal_user_id como claim principal.
Payload del JWT:
json
{
"portal_user_id": "uuid-del-usuario",
"tenant_id": 1,
"sucursal_id": 1,
"iat": 1738000000,
"exp": 1738003600
}Diferencia clave con Admin UI: El claim es portal_user_id (no user_id). Esto separa completamente los espacios de autenticacion. Un token del portal no sirve para el Admin UI y viceversa.
Flujos de Autenticacion
1. Auto-Registro
El usuario se registra proporcionando DNI/CUIT, email y password. El sistema valida que el DNI/CUIT corresponda a un cliente existente en ordcon.
mermaid
sequenceDiagram
participant U as Usuario
participant API as Portal API
participant DB as Database
U->>API: POST /portal/auth/register
Note right of U: {dni_cuit, email, password, tenant_id, sucursal_id}
API->>DB: Resolver DB y schema desde tenant_id/sucursal_id
API->>DB: SELECT FROM ordcon WHERE ccui = dni_cuit
alt ordcon NO encontrado
API-->>U: 404 DNI/CUIT no registrado en el sistema
else ordcon encontrado
API->>DB: SELECT FROM portal_users WHERE dni_cuit = dni_cuit
alt Ya registrado
API-->>U: 409 Usuario ya existe
else No registrado
API->>API: Hash password con bcrypt
API->>DB: INSERT portal_users
API->>API: Generar JWT
API-->>U: 201 Created + JWT
end
endValidaciones del registro:
dni_cuitdebe tener formato valido (DNI: 7-8 digitos, CUIT: 11 digitos con formato XX-XXXXXXXX-X)emaildebe tener formato validopassworddebe cumplir politica minima (minimo 8 caracteres, al menos 1 numero)dni_cuitdebe existir enordcon.ccuidel schema resueltodni_cuitno debe estar ya registrado enportal_users
2. Login
mermaid
sequenceDiagram
participant U as Usuario
participant API as Portal API
participant DB as Database
U->>API: POST /portal/auth/login
Note right of U: {dni_cuit, password, tenant_id, sucursal_id}
API->>DB: Resolver DB y schema
API->>DB: SELECT FROM portal_users WHERE dni_cuit = dni_cuit
alt Usuario no encontrado
API-->>U: 401 Credenciales invalidas
else Usuario encontrado
API->>API: Verificar locked_until
alt Cuenta bloqueada
API-->>U: 423 Cuenta bloqueada hasta {locked_until}
else Cuenta activa
API->>API: Verificar password con bcrypt
alt Password incorrecta
API->>DB: INCREMENT failed_login_attempts
API->>API: Verificar si alcanza 5 intentos
alt 5 intentos alcanzados
API->>DB: SET locked_until = NOW() + 15 min
API-->>U: 423 Cuenta bloqueada por 15 minutos
else Menos de 5
API-->>U: 401 Credenciales invalidas
end
else Password correcta
API->>DB: RESET failed_login_attempts = 0, locked_until = NULL
API->>DB: UPDATE last_login = NOW()
API->>API: Generar JWT
API-->>U: 200 OK + JWT
end
end
endRespuesta exitosa del login:
json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "550e8400-e29b-41d4-a716-446655440000",
"expires_in": 3600,
"user": {
"portal_user_id": "uuid",
"nombre": "Juan Perez",
"email": "juan@example.com"
}
}El refresh_token es un UUID almacenado en portal_users. Solo hay UNA sesion activa por usuario: un nuevo login sobreescribe el refresh token anterior.
Notas de seguridad del login:
- El mensaje de error es generico ("Credenciales invalidas") tanto para usuario inexistente como para password incorrecto. No revelar cual fallo.
locked_untilse verifica ANTES de intentar validar el password. Si la cuenta esta bloqueada, no se intenta la verificacion.
3. Password Reset
Flujo de dos pasos: solicitar codigo por email, luego usar el codigo para establecer nuevo password.
mermaid
sequenceDiagram
participant U as Usuario
participant API as Portal API
participant DB as Database
participant E as Email Service
Note over U,E: Paso 1: Solicitar codigo
U->>API: POST /portal/auth/forgot-password
Note right of U: {email, tenant_id, sucursal_id}
API->>DB: SELECT FROM portal_users WHERE email = email
alt Usuario encontrado
API->>API: Generar codigo de 6 digitos + expiracion (15 min)
API->>DB: Guardar codigo hasheado + expiracion
API->>E: Enviar email con codigo
end
API-->>U: 200 OK (siempre, sin revelar si existe)
Note over U,E: Paso 2: Resetear password
U->>API: POST /portal/auth/reset-password
Note right of U: {email, code, new_password, tenant_id, sucursal_id}
API->>DB: SELECT FROM portal_users WHERE email = email
API->>API: Verificar codigo y expiracion
alt Codigo valido y no expirado
API->>API: Hash nuevo password con bcrypt
API->>DB: UPDATE password_hash, RESET failed_login_attempts
API->>DB: Invalidar codigo usado
API-->>U: 200 OK - Password actualizado
else Codigo invalido o expirado
API-->>U: 400 Codigo invalido o expirado
endConsideraciones del password reset:
- El endpoint
forgot-passwordsiempre retorna 200 OK, incluso si el email no existe. Esto previene enumeracion de usuarios. - El codigo tiene expiracion de 15 minutos.
- Maximo 3 solicitudes de
forgot-passwordpor hora por email (rate limiting). - El codigo usado se invalida inmediatamente. No puede reutilizarse.
Bloqueo por Intentos Fallidos
| Evento | Accion |
|---|---|
| Login fallido (intentos < 5) | Incrementar failed_login_attempts |
| Login fallido (intento 5) | Incrementar failed_login_attempts, SET locked_until = NOW() + 15 min |
| Login exitoso | RESET failed_login_attempts = 0, SET locked_until = NULL |
| Cuenta bloqueada + intento de login | Retornar 423 sin verificar password |
locked_until en el pasado + login | Tratar como cuenta desbloqueada, RESET contadores |
Regla: 5 intentos fallidos consecutivos resultan en bloqueo de 15 minutos. El bloqueo se levanta automaticamente cuando locked_until queda en el pasado.
JWT
Algoritmo y Secret
Algoritmo: HS256 (HMAC-SHA256) Secret: PORTAL_JWT_SECRET — variable de entorno separada en el backend .env
Separacion del Admin UI: El Admin UI usa RS256 con un par de claves diferente. Esta separacion por algoritmo + secreto garantiza que un JWT del portal NUNCA puede funcionar en endpoints del Admin UI y viceversa.
Generacion
php
$payload = [
'portal_user_id' => $portalUser->id, // UUID
'tenant_id' => $tenantId, // int
'sucursal_id' => $sucursalId, // int
'iat' => time(),
'exp' => time() + 3600, // 1 hora
];
$token = JWT::encode($payload, env('PORTAL_JWT_SECRET'), 'HS256');Validacion
En cada request protegido, el PortalAuthMiddleware valida:
- Firma del token (HS256 con
PORTAL_JWT_SECRET) - Expiracion (
exp> tiempo actual) - Claims requeridos presentes (
portal_user_id,tenant_id,sucursal_id)
Renovacion (Refresh Token)
El access token tiene expiracion de 1 hora. Para renovarlo sin re-login, se usa un refresh token:
Tiempos de expiracion:
- Access token: 1 hora (
exp = iat + 3600) - Refresh token: 7 dias (
refresh_token_expires = NOW() + INTERVAL '7 days')
Mecanismo:
- El refresh token es un UUID almacenado en
portal_users.refresh_tokencon expiracion enportal_users.refresh_token_expires - En login: se genera UUID, se guarda en
portal_users, se retorna al cliente junto con el access token - En refresh (
POST /portal/auth/refresh-token): se valida UUID + expiracion, se genera nuevo access JWT + nuevo refresh UUID (rotacion de ambos tokens) - Rotacion obligatoria: cada refresh invalida el refresh token anterior y genera uno nuevo. Un refresh token robado solo puede usarse una vez
- Solo UNA sesion activa por usuario: un nuevo login sobreescribe el refresh token anterior
- Logout revoca el refresh token (SET NULL)
- Si el refresh token expira (7 dias sin actividad), el usuario debe re-loguearse
Endpoint: POST /portal/auth/refresh-token
json
// Request
{
"refresh_token": "550e8400-e29b-41d4-a716-446655440000"
}
// Response 200
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "nuevo-uuid-generado",
"expires_in": 3600
}
// Response 401
{
"error": "INVALID_REFRESH_TOKEN",
"message": "Refresh token invalido o expirado"
}Seguridad
HTTPS Obligatorio
- Todas las rutas del portal requieren HTTPS
- Redirect automatico HTTP → HTTPS a nivel de nginx/reverse proxy
- Header HSTS:
Strict-Transport-Security: max-age=31536000; includeSubDomains
CORS
Wildcard * para endpoints del portal. Los endpoints estan protegidos por JWT, por lo que CORS no es la capa de seguridad.
Headers CORS:
Access-Control-Allow-Origin:*Access-Control-Allow-Methods:GET, POST, PUT, DELETE, OPTIONSAccess-Control-Allow-Headers:Authorization, Content-Type, X-Tenant-Id, X-Sucursal-Id
No se necesita configuracion de dominio por tenant en el backend. Agregar un nuevo tenant no requiere tocar CORS.
Email
El portal reutiliza la configuracion de email existente del sistema Bautista (SMTP/servicio ya configurado). No se configura un servicio de email independiente para el portal.
Rate Limiting
| Endpoint | Limite | Ventana |
|---|---|---|
POST /portal/auth/login | 10 requests | 1 minuto por IP |
POST /portal/auth/register | 5 requests | 1 minuto por IP |
POST /portal/auth/forgot-password | 3 requests | 1 hora por email |
| Rutas protegidas (general) | 100 requests | 1 minuto por token |
Password Hashing
- Algoritmo: bcrypt via
password_hash()de PHP - Cost factor: 12 (default de PHP 8.2+)
- Verificacion:
password_verify()de PHP - El password en texto plano NUNCA se almacena ni se loguea
Auditoria
Loguear todos los eventos de autenticacion con:
tenant_idsucursal_idportal_user_id(si disponible)ipuser_agenttimestampevent:login_success,login_failed,login_locked,register,password_reset_request,password_reset_complete,password_change
4. Cambiar Password (usuario autenticado)
Flujo para que un usuario logueado cambie su password desde la pagina de perfil. Requiere verificacion del password actual.
mermaid
sequenceDiagram
participant U as Usuario
participant API as Portal API
participant DB as Database
U->>API: PUT /portal/auth/cambiar-password
Note right of U: {current_password, new_password}<br/>Authorization: Bearer {jwt}
API->>API: Extraer portal_user_id del JWT
API->>DB: SELECT password_hash FROM portal_users WHERE id = portal_user_id
alt Usuario no encontrado
API-->>U: 404 Usuario no encontrado
else Usuario encontrado
API->>API: Verificar current_password con bcrypt
alt Password actual incorrecta
API-->>U: 401 Password actual incorrecto
else Password actual correcta
API->>API: Validar new_password (8 chars + 1 numero)
alt No cumple politica
API-->>U: 422 Password no cumple requisitos
else Cumple politica
API->>API: Hash new_password con bcrypt
API->>DB: UPDATE password_hash
API-->>U: 200 OK - Password actualizado
end
end
endDiferencia con reset-password: El reset usa un codigo enviado por email (para usuarios que olvidaron su password). El cambio de password requiere el password actual (para usuarios logueados que quieren actualizar su password).
Resumen de Endpoints de Auth
| Endpoint | Metodo | JWT requerido | Descripcion |
|---|---|---|---|
/portal/auth/register | POST | No | Auto-registro con validacion ordcon |
/portal/auth/login | POST | No | Login con DNI/CUIT + password |
/portal/auth/forgot-password | POST | No | Solicitar codigo de reset por email |
/portal/auth/reset-password | POST | No | Resetear password con codigo |
/portal/auth/cambiar-password | PUT | Si | Cambiar password (requiere password actual) |
/portal/auth/me | GET | Si | Datos del usuario autenticado |
/portal/auth/refresh-token | POST | No | Renovar access token usando refresh token |
/portal/auth/logout | POST | Si | Revocar refresh token + invalidar sesion |