Skip to content

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 CUIT
  • identifierType: 'dni' o 'cuit'
  • email: Email del usuario
  • password: Password en texto plano (se hashea internamente)
  • tenantId: ID del tenant
  • sucursalId: 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 CUIT
  • identifierType: 'dni' o 'cuit'
  • password: Password en texto plano
  • tenantId: ID del tenant
  • sucursalId: 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:

  1. Validar que la sucursal pertenece al tenant
  2. Resolver DB y schema desde tenant_id/sucursal_id via ini.sistema
  3. Buscar portal_user por DNI/CUIT en el schema resuelto
  4. Si no existe -> Error INVALID_CREDENTIALS
  5. Verificar locked_until (bloqueo por intentos fallidos)
  6. Si bloqueado -> Error ACCOUNT_LOCKED
  7. Verificar password contra password_hash (bcrypt_verify)
  8. Si password incorrecto:
    • Incrementar failed_attempts
    • Si failed_attempts >= 5 -> establecer locked_until = now + 15 min
    • Error INVALID_CREDENTIALS
  9. Resetear failed_attempts = 0, actualizar last_login
  10. Generar JWT con { portal_user_id, tenant_id, sucursal_id }
  11. Generar refresh_token
  12. Retornar tokens + datos del usuario

forgotPassword()

Proposito: Generar codigo de recuperacion y enviarlo por email.

Parametros:

  • identifier: DNI o CUIT
  • identifierType: 'dni' o 'cuit'
  • tenantId: ID del tenant
  • sucursalId: ID de la sucursal

Retorna: Siempre exito (no revela si el usuario existe)

Flujo:

  1. Resolver DB y schema
  2. Buscar portal_user por DNI/CUIT
  3. Si existe:
    • Generar codigo numerico de 6 digitos
    • Guardar en portal_users.reset_code con reset_code_expires_at = now + 15 min
    • Enviar email con el codigo al email registrado
  4. 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 CUIT
  • identifierType: 'dni' o 'cuit'
  • code: Codigo de 6 digitos
  • password: Nuevo password
  • tenantId: ID del tenant
  • sucursalId: ID de la sucursal

Flujo:

  1. Resolver DB y schema
  2. Buscar portal_user por DNI/CUIT
  3. Verificar que reset_code coincide y reset_code_expires_at > now
  4. Si invalido -> Error INVALID_CODE
  5. Hash nuevo password con bcrypt
  6. UPDATE portal_users SET password_hash, reset_code = NULL, reset_code_expires_at = NULL
  7. 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:

  1. Buscar portal_users por refresh_token (UUID)
  2. Si no existe → Error INVALID_REFRESH_TOKEN
  3. Verificar que refresh_token_expires > NOW()
  4. Si expirado → Error INVALID_REFRESH_TOKEN
  5. Verificar que el usuario no este bloqueado (locked_until)
  6. Generar nuevo access JWT con { portal_user_id, tenant_id, sucursal_id }
  7. Generar nuevo UUID de refresh token
  8. UPDATE portal_users SET refresh_token = nuevo_uuid, refresh_token_expires = NOW() + duracion
  9. Retornar nuevo par de tokens

revokeRefreshToken()

Proposito: Revocar el refresh token del usuario (logout).

Parametros:

  • portalUserId: UUID del portal_user

Retorna: void

Flujo:

  1. UPDATE portal_users SET refresh_token = NULL, refresh_token_expires = NULL WHERE id = 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:

  1. Buscar portal_users por portalUserId
  2. Verificar currentPassword contra password_hash (bcrypt_verify)
  3. Si no coincide → Error INVALID_PASSWORD
  4. Validar que newPassword cumple politica: minimo 8 caracteres + al menos 1 numero
  5. Si no cumple → Error VALIDATION_ERROR
  6. Hash nuevo password con bcrypt
  7. UPDATE portal_users SET password_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:

  1. Buscar portal_users por portalUserId → obtener cliente_id, email, telefono, last_login
  2. Buscar ordcon por cliente_id → obtener cnom (nombre), ccui (DNI/CUIT)
  3. 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:

  1. Validar que al menos un campo fue enviado
  2. Si email presente: validar formato de email
  3. UPDATE portal_users SET campos proporcionados WHERE id = portalUserId
  4. 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_until se evalua en cada intento de login
  • Login exitoso resetea failed_attempts a 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

  1. Registro exitoso: DNI existe en ordcon, no existe portal_user -> crea usuario
  2. Registro con DNI inexistente: DNI no existe en ordcon -> error ORDCON_NOT_FOUND
  3. Registro duplicado: Ya existe portal_user para ese ordcon -> error USER_ALREADY_EXISTS
  4. Login exitoso: Credenciales correctas -> JWT con tenant_id + sucursal_id
  5. Login con password incorrecto: Incrementa failed_attempts
  6. Login con cuenta bloqueada: locked_until en el futuro -> error ACCOUNT_LOCKED
  7. Forgot-password con usuario existente: Genera codigo y envia email
  8. Forgot-password con usuario inexistente: Responde exito sin hacer nada
  9. Reset-password con codigo valido: Actualiza password_hash
  10. Reset-password con codigo expirado: Error INVALID_CODE
  11. Refresh-token valido: Genera nuevo par de tokens (rotacion: nuevo access + nuevo refresh UUID)
  12. Refresh-token expirado: Error INVALID_REFRESH_TOKEN (expiracion a los 7 dias)
  13. Cambiar password exitoso: Password actual correcto + nuevo cumple politica -> actualiza hash
  14. Cambiar password con password actual incorrecto: Error INVALID_PASSWORD
  15. Cambiar password con nuevo password debil: Error VALIDATION_ERROR (no cumple 8 chars + 1 numero)
  16. Obtener perfil: Retorna merge de ordcon (nombre, DNI/CUIT) + portal_users (email, telefono, last_login)
  17. Actualizar perfil con email valido: Actualiza email en portal_users, retorna perfil completo
  18. Actualizar perfil con email invalido: Error VALIDATION_ERROR
  19. Actualizar perfil sin campos: Error VALIDATION_ERROR (debe enviar al menos un campo)