Appearance
PortalAuthService
Estado: Planificado
Ubicacion: Modules/Portal/Auth/Service/PortalAuthService.php
Responsabilidad
Gestionar el ciclo completo de autenticacion de usuarios del portal: registro, login, recuperacion y cambio de password, y renovacion de JWT.
Reemplaza conceptualmente al antiguo ClientIdentificationService (identificacion passwordless). La nueva arquitectura requiere password y genera JWT con { portal_user_id, tenant_id, sucursal_id }.
Metodos
register()
Proposito: Auto-registro de un cliente existente en ordcon.
Parametros:
identifier: DNI o CUITidentifierType:'dni'o'cuit'email: Email del usuariopassword: Password en texto plano (se hashea internamente)tenantId: ID del tenantsucursalId: ID de la sucursal
Retorna:
json
{
"portal_user_id": "550e8400-e29b-41d4-a716-446655440000",
"nombre": "Juan Perez",
"email": "juan@example.com"
}Flujo:
mermaid
flowchart TD
A[Recibir datos de registro] --> B{Sucursal pertenece a tenant?}
B -->|No| C[Error: INVALID_SUCURSAL]
B -->|Si| D[Resolver DB + schema]
D --> E{Existe ordcon con DNI/CUIT?}
E -->|No| F[Error: ORDCON_NOT_FOUND]
E -->|Si| G{Ya existe portal_user para este ordcon?}
G -->|Si| H[Error: USER_ALREADY_EXISTS]
G -->|No| I[Hash password con bcrypt]
I --> J[INSERT portal_users]
J --> K[Retornar portal_user creado]Validaciones de negocio:
- La sucursal debe pertenecer al tenant
- El DNI/CUIT debe existir en ordcon del schema correspondiente
- No debe existir un portal_user previo para el mismo ordcon
- El email debe ser valido y unico dentro del tenant
login()
Proposito: Autenticar usuario y generar JWT.
Parametros:
identifier: DNI o CUITidentifierType:'dni'o'cuit'password: Password en texto planotenantId: ID del tenantsucursalId: ID de la sucursal
Retorna:
json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "550e8400-e29b-41d4-a716-446655440000",
"expires_in": 3600,
"user": {
"portal_user_id": "550e8400-e29b-41d4-a716-446655440000",
"nombre": "Juan Perez",
"email": "juan@example.com"
}
}El refresh_token es un UUID almacenado en portal_users.refresh_token. Solo hay UNA sesion activa por usuario.
JWT Payload:
json
{
"portal_user_id": "550e8400-e29b-41d4-a716-446655440000",
"tenant_id": 1,
"sucursal_id": 1,
"iat": 1712678400,
"exp": 1712682000
}El JWT NO expone nombres de base de datos, schemas ni informacion sensible. Solo IDs que el backend resuelve internamente.
Flujo:
- Validar que la sucursal pertenece al tenant
- Resolver DB y schema desde tenant_id/sucursal_id via ini.sistema
- Buscar portal_user por DNI/CUIT en el schema resuelto
- Si no existe -> Error INVALID_CREDENTIALS
- Verificar
locked_until(bloqueo por intentos fallidos) - Si bloqueado -> Error ACCOUNT_LOCKED
- Verificar password contra password_hash (bcrypt_verify)
- Si password incorrecto:
- Incrementar
failed_attempts - Si
failed_attempts >= 5-> establecerlocked_until = now + 15 min - Error INVALID_CREDENTIALS
- Incrementar
- Resetear
failed_attempts = 0, actualizarlast_login - Generar JWT con
{ portal_user_id, tenant_id, sucursal_id } - Generar refresh_token
- Retornar tokens + datos del usuario
forgotPassword()
Proposito: Generar codigo de recuperacion y enviarlo por email.
Parametros:
identifier: DNI o CUITidentifierType:'dni'o'cuit'tenantId: ID del tenantsucursalId: ID de la sucursal
Retorna: Siempre exito (no revela si el usuario existe)
Flujo:
- Resolver DB y schema
- Buscar portal_user por DNI/CUIT
- Si existe:
- Generar codigo numerico de 6 digitos
- Guardar en
portal_users.reset_codeconreset_code_expires_at = now + 15 min - Enviar email con el codigo al email registrado
- Si no existe: no hacer nada (misma respuesta)
Seguridad: La respuesta es identica exista o no el usuario, para prevenir enumeracion de cuentas.
resetPassword()
Proposito: Restablecer password usando el codigo enviado por email.
Parametros:
identifier: DNI o CUITidentifierType:'dni'o'cuit'code: Codigo de 6 digitospassword: Nuevo passwordtenantId: ID del tenantsucursalId: ID de la sucursal
Flujo:
- Resolver DB y schema
- Buscar portal_user por DNI/CUIT
- Verificar que
reset_codecoincide yreset_code_expires_at > now - Si invalido -> Error INVALID_CODE
- Hash nuevo password con bcrypt
- UPDATE portal_users SET password_hash, reset_code = NULL, reset_code_expires_at = NULL
- Resetear failed_attempts y locked_until
refreshToken()
Proposito: Renovar access JWT usando el refresh token UUID almacenado en portal_users.
Parametros:
refreshToken: UUID del refresh token actual
Retorna: Nuevo par access_token + refresh_token (UUID)
Flujo:
- Buscar
portal_usersporrefresh_token(UUID) - Si no existe → Error INVALID_REFRESH_TOKEN
- Verificar que
refresh_token_expires > NOW() - Si expirado → Error INVALID_REFRESH_TOKEN
- Verificar que el usuario no este bloqueado (
locked_until) - Generar nuevo access JWT con
{ portal_user_id, tenant_id, sucursal_id } - Generar nuevo UUID de refresh token
- UPDATE
portal_usersSETrefresh_token = nuevo_uuid,refresh_token_expires = NOW() + duracion - Retornar nuevo par de tokens
revokeRefreshToken()
Proposito: Revocar el refresh token del usuario (logout).
Parametros:
portalUserId: UUID del portal_user
Retorna: void
Flujo:
- UPDATE
portal_usersSETrefresh_token = NULL,refresh_token_expires = NULLWHEREid = portalUserId
Este metodo se invoca desde el endpoint POST /portal/auth/logout. Al poner en NULL el refresh token, la sesion queda invalidada. El access JWT existente seguira siendo valido hasta que expire (1 hora max), pero no podra renovarse.
changePassword()
Proposito: Cambiar el password del usuario autenticado. Requiere verificacion del password actual.
Parametros:
portalUserId: UUID del portal_user (extraido del JWT)currentPassword: Password actual en texto plano (para verificacion)newPassword: Nuevo password en texto plano (se hashea internamente)
Retorna: void (exito) o excepcion (error de validacion)
Flujo:
- Buscar
portal_usersporportalUserId - Verificar
currentPasswordcontrapassword_hash(bcrypt_verify) - Si no coincide → Error INVALID_PASSWORD
- Validar que
newPasswordcumple politica: minimo 8 caracteres + al menos 1 numero - Si no cumple → Error VALIDATION_ERROR
- Hash nuevo password con bcrypt
- UPDATE
portal_usersSETpassword_hash = nuevo_hash
Diferencia con resetPassword(): resetPassword() usa un codigo de 6 digitos enviado por email (para usuarios que olvidaron su password). changePassword() requiere el password actual (para usuarios logueados que quieren actualizarlo).
getProfile()
Proposito: Obtener datos del perfil del usuario. Merge de datos de ordcon (identidad) y portal_users (contacto/sesion).
Parametros:
portalUserId: UUID del portal_user (extraido del JWT)
Retorna:
json
{
"nombre": "Juan Perez",
"dni_cuit": "12345678",
"email": "juan@example.com",
"telefono": "1155443322",
"last_login": "2026-04-08T14:30:00Z"
}Flujo:
- Buscar
portal_usersporportalUserId→ obtenercliente_id,email,telefono,last_login - Buscar
ordconporcliente_id→ obtenercnom(nombre),ccui(DNI/CUIT) - Merge y retornar datos combinados
updateProfile()
Proposito: Actualizar datos de contacto del perfil (email, telefono). Solo actualiza portal_users, no ordcon.
Parametros:
portalUserId: UUID del portal_user (extraido del JWT)email: Nuevo email (opcional, valida formato si presente)telefono: Nuevo telefono (opcional)
Retorna: Perfil actualizado (mismo formato que getProfile())
Flujo:
- Validar que al menos un campo fue enviado
- Si
emailpresente: validar formato de email - UPDATE
portal_usersSET campos proporcionados WHEREid = portalUserId - Retornar perfil actualizado (merge ordcon + portal_users)
Tabla portal_users
sql
CREATE TABLE portal_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cliente_id INTEGER NOT NULL REFERENCES ordcon(cnro) ON DELETE CASCADE,
dni_cuit VARCHAR(20) NOT NULL,
email VARCHAR(255) NOT NULL,
telefono VARCHAR(50) NULL,
password_hash VARCHAR(255) NOT NULL,
refresh_token VARCHAR(255) NULL,
refresh_token_expires TIMESTAMP NULL,
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMP NULL,
reset_code VARCHAR(6) NULL,
reset_code_expires_at TIMESTAMP NULL,
last_login TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_portal_users_dni_cuit ON portal_users (dni_cuit);
CREATE UNIQUE INDEX uq_portal_users_refresh_token ON portal_users (refresh_token) WHERE refresh_token IS NOT NULL;Esta tabla vive en el mismo schema que ordcon (dinamico segun configuracion de ini.sistema).
Seguridad
Bloqueo por Intentos Fallidos
- Despues de 5 intentos fallidos, se bloquea por 15 minutos
locked_untilse evalua en cada intento de login- Login exitoso resetea
failed_attemptsa 0
Password
- Hash con bcrypt (PASSWORD_BCRYPT)
- Requisitos minimos: 8 caracteres, al menos 1 numero (sin requerimiento de mayusculas ni caracteres especiales)
- Verificacion con
password_verify()
Codigo de Recuperacion
- 6 digitos numericos aleatorios
- Expira en 15 minutos
- Se invalida despues del uso exitoso
- Un solo codigo activo por usuario
Rate Limiting
- Max 5 intentos de login por minuto por IP
- Max 3 solicitudes de forgot-password por hora por usuario
- Se aplica en el controller/middleware antes de llamar al servicio
Casos de Prueba
- Registro exitoso: DNI existe en ordcon, no existe portal_user -> crea usuario
- Registro con DNI inexistente: DNI no existe en ordcon -> error ORDCON_NOT_FOUND
- Registro duplicado: Ya existe portal_user para ese ordcon -> error USER_ALREADY_EXISTS
- Login exitoso: Credenciales correctas -> JWT con tenant_id + sucursal_id
- Login con password incorrecto: Incrementa failed_attempts
- Login con cuenta bloqueada: locked_until en el futuro -> error ACCOUNT_LOCKED
- Forgot-password con usuario existente: Genera codigo y envia email
- Forgot-password con usuario inexistente: Responde exito sin hacer nada
- Reset-password con codigo valido: Actualiza password_hash
- Reset-password con codigo expirado: Error INVALID_CODE
- Refresh-token valido: Genera nuevo par de tokens (rotacion: nuevo access + nuevo refresh UUID)
- Refresh-token expirado: Error INVALID_REFRESH_TOKEN (expiracion a los 7 dias)
- Cambiar password exitoso: Password actual correcto + nuevo cumple politica -> actualiza hash
- Cambiar password con password actual incorrecto: Error INVALID_PASSWORD
- Cambiar password con nuevo password debil: Error VALIDATION_ERROR (no cumple 8 chars + 1 numero)
- Obtener perfil: Retorna merge de ordcon (nombre, DNI/CUIT) + portal_users (email, telefono, last_login)
- Actualizar perfil con email valido: Actualiza email en portal_users, retorna perfil completo
- Actualizar perfil con email invalido: Error VALIDATION_ERROR
- Actualizar perfil sin campos: Error VALIDATION_ERROR (debe enviar al menos un campo)