Skip to content

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
    end

Validaciones del registro:

  1. dni_cuit debe tener formato valido (DNI: 7-8 digitos, CUIT: 11 digitos con formato XX-XXXXXXXX-X)
  2. email debe tener formato valido
  3. password debe cumplir politica minima (minimo 8 caracteres, al menos 1 numero)
  4. dni_cuit debe existir en ordcon.ccui del schema resuelto
  5. dni_cuit no debe estar ya registrado en portal_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
    end

Respuesta 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_until se 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
    end

Consideraciones del password reset:

  • El endpoint forgot-password siempre 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-password por hora por email (rate limiting).
  • El codigo usado se invalida inmediatamente. No puede reutilizarse.

Bloqueo por Intentos Fallidos

EventoAccion
Login fallido (intentos < 5)Incrementar failed_login_attempts
Login fallido (intento 5)Incrementar failed_login_attempts, SET locked_until = NOW() + 15 min
Login exitosoRESET failed_login_attempts = 0, SET locked_until = NULL
Cuenta bloqueada + intento de loginRetornar 423 sin verificar password
locked_until en el pasado + loginTratar 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:

  1. Firma del token (HS256 con PORTAL_JWT_SECRET)
  2. Expiracion (exp > tiempo actual)
  3. 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_token con expiracion en portal_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, OPTIONS
  • Access-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

EndpointLimiteVentana
POST /portal/auth/login10 requests1 minuto por IP
POST /portal/auth/register5 requests1 minuto por IP
POST /portal/auth/forgot-password3 requests1 hora por email
Rutas protegidas (general)100 requests1 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_id
  • sucursal_id
  • portal_user_id (si disponible)
  • ip
  • user_agent
  • timestamp
  • event: 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
    end

Diferencia 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

EndpointMetodoJWT requeridoDescripcion
/portal/auth/registerPOSTNoAuto-registro con validacion ordcon
/portal/auth/loginPOSTNoLogin con DNI/CUIT + password
/portal/auth/forgot-passwordPOSTNoSolicitar codigo de reset por email
/portal/auth/reset-passwordPOSTNoResetear password con codigo
/portal/auth/cambiar-passwordPUTSiCambiar password (requiere password actual)
/portal/auth/meGETSiDatos del usuario autenticado
/portal/auth/refresh-tokenPOSTNoRenovar access token usando refresh token
/portal/auth/logoutPOSTSiRevocar refresh token + invalidar sesion