Verificación de teléfono de usuario

Overview

services/api/src/modules/auth/phone-verification.service.js contiene la lógica de dominio para verificar teléfonos con Twilio Verify por SMS. Este archivo no habla con el SDK de Twilio directamente; esa responsabilidad vive en phone-verification.client.js, mientras que el controlador HTTP usa este servicio para exponer el flujo POST /auth/phone/verify/start y POST /auth/phone/verify/check .

El servicio normaliza teléfonos a E.164, valida que la cuenta esté activa y persiste User.phone junto con phoneVerifiedAt solo después de una aprobación real de Twilio. El diseño evita la enumeración de cuentas y deja la colisión real para la escritura final, donde el índice único parcial de User.phone actúa como autoridad final .

[!IMPORTANT] startPhoneVerificationForUser nunca consulta si un número pertenece a otra cuenta. La colisión real se resuelve solo en checkPhoneVerificationForUser, durante la escritura atómica final.

How it works

flowchart TD
    A[startPhoneVerificationForUser] --> B[Load user active state]
    B --> C[Normalize phone]
    C --> D{Same verified phone}
    D -->|yes| E[Return alreadyVerified true]
    D -->|no| F[startPhoneVerification client]
    F --> G[Return alreadyVerified false]

    H[checkPhoneVerificationForUser] --> I[Load user active state]
    I --> J[Normalize phone]
    J --> K[checkPhoneVerification client]
    K --> L{Approved}
    L -->|no| M[Throw PHONE_VERIFICATION_NOT_APPROVED]
    L -->|yes| N[Update user phone and phoneVerifiedAt]
    N --> O{Duplicate phone}
    O -->|yes| P[registrationConflict phone]
    O -->|no| Q{Modified count one}
    Q -->|no| R[Throw notActiveError]
    Q -->|yes| S[Return phoneVerifiedAt]

El flujo tiene dos ramas. startPhoneVerificationForUser solo prepara y dispara el SMS. checkPhoneVerificationForUser valida el código, escribe el teléfono y resuelve colisiones concurrentes con updateOne y el control de errores de MongoDB .

[!NOTE] La capa de proveedor separada usa startPhoneVerification y checkPhoneVerification. Esa capa encapsula Twilio y devuelve solo estados normalizados.

Key components

{
    "title": "startPhoneVerificationForUser",
    "content": "Funci\u00f3n p\u00fablica que valida la cuenta activa, normaliza el tel\u00e9fono y dispara Twilio Verify. Devuelve alreadyVerified para el caso propio no enumerable o false cuando inicia un SMS.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/phone-verification.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "checkPhoneVerificationForUser",
    "content": "Funci\u00f3n p\u00fablica que verifica el c\u00f3digo con Twilio, persiste phone y phoneVerifiedAt solo si el estado es approved, y traduce colisiones de phone al conflicto de registro.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/phone-verification.service.js",
    "badges": [
        "public"
    ]
}

Data & fields

El servicio trabaja con un conjunto pequeño de entradas y salidas. Las consultas a User usan solo los campos necesarios, y la escritura final ocurre en una sola actualización atómica .

Inputs de startPhoneVerificationForUser

[
    {
        "name": "userId",
        "type": "string",
        "required": true,
        "description": "Identificador de la cuenta autenticada."
    },
    {
        "name": "phone",
        "type": "string",
        "required": true,
        "description": "Teléfono de destino. Se normaliza con normalizePhoneE164 antes de llamar a Twilio."
    }
]

Inputs de checkPhoneVerificationForUser

[
    {
        "name": "userId",
        "type": "string",
        "required": true,
        "description": "Identificador de la cuenta autenticada."
    },
    {
        "name": "phone",
        "type": "string",
        "required": true,
        "description": "Teléfono de destino. Se normaliza con normalizePhoneE164 antes de verificar en Twilio."
    },
    {
        "name": "code",
        "type": "string",
        "required": true,
        "description": "Código de verificación recibido por SMS."
    }
]

Salidas del servicio

[
    {
        "name": "alreadyVerified",
        "type": "boolean",
        "required": true,
        "description": "Indica que el usuario pidió re-verificar su mismo número ya aprobado."
    },
    {
        "name": "phoneVerifiedAt",
        "type": "Date",
        "required": true,
        "description": "Marca temporal persistida cuando la verificación queda aprobada."
    }
]

Campos de User usados por este servicio

  • User._id [badge:read-only] string — Identificador usado en findById y updateOne.
  • User.accountStatus [badge:read-only] string — Debe ser active para permitir el flujo.
  • User.phone [badge:optional] string — Se escribe solo después de approved.
  • User.phoneVerifiedAt [badge:optional] Date — Se escribe solo después de approved.
  • User.phone [badge:read-only] string — También participa en el caso no enumerable del mismo teléfono ya verificado.

Estos campos coinciden con el contrato descrito en la documentación del módulo de autenticación y con la lógica de selección de campos del servicio .

Behaviors

  • notActiveError() — Crea un ApiError 401 con el mensaje Usuario no autenticado.
  • startPhoneVerificationForUser(userId, phone) — Busca al usuario, exige accountStatus === 'active', normaliza el teléfono y llama a startPhoneVerification.
  • startPhoneVerificationForUser(userId, phone) — Devuelve { alreadyVerified: true } solo cuando el mismo usuario pide el mismo número ya verificado.
  • startPhoneVerificationForUser(userId, phone) — Devuelve { alreadyVerified: false } después de iniciar el SMS con Twilio.
  • startPhoneVerificationForUser(userId, phone) — Nunca persiste User.phone ni phoneVerifiedAt.
  • checkPhoneVerificationForUser(userId, { phone, code }) — Busca al usuario activo, normaliza el teléfono y consulta checkPhoneVerification.
  • checkPhoneVerificationForUser(userId, { phone, code }) — Si Twilio no responde approved, lanza ApiError 400 con código PHONE_VERIFICATION_NOT_APPROVED.
  • checkPhoneVerificationForUser(userId, { phone, code }) — Si Twilio aprueba, ejecuta updateOne y escribe phone y phoneVerifiedAt.
  • checkPhoneVerificationForUser(userId, { phone, code }) — Convierte una colisión de phone en registrationConflict('phone').
  • checkPhoneVerificationForUser(userId, { phone, code }) — Si la escritura no modifica una fila, vuelve a lanzar notActiveError().

El servicio usa duplicateFieldFromMongoError y registrationConflict desde auth.service.js. Eso mantiene alineado este flujo con el resto de la autenticación cuando MongoDB detecta E11000 sobre phone .

API

El contrato HTTP está en phone-verification.controller.js, que valida entrada y devuelve respuestas mínimas. Este servicio solo ejecuta la lógica de negocio que esas rutas consumen .

POST /auth/phone/verify/start

{
    "title": "Iniciar verificaci\u00f3n de tel\u00e9fono",
    "description": "Normaliza el tel\u00e9fono, valida que la cuenta est\u00e9 activa y dispara Twilio Verify por SMS. No persiste el tel\u00e9fono.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/auth/phone/verify/start",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"phone\": \"+52 55 1234 5678\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "SMS iniciado o n\u00famero ya verificado por el mismo usuario.",
            "body": "{\n  \"alreadyVerified\": false,\n  \"message\": \"Te enviamos un codigo de verificacion por SMS.\"\n}"
        },
        "400": {
            "description": "El tel\u00e9fono no pasa la validaci\u00f3n de entrada.",
            "body": "{\n  \"code\": \"INVALID_PHONE_FORMAT\",\n  \"message\": \"El formato del telefono no es valido\"\n}"
        },
        "401": {
            "description": "La cuenta no est\u00e1 activa o la sesi\u00f3n no es v\u00e1lida.",
            "body": "{\n  \"message\": \"Usuario no autenticado\"\n}"
        },
        "502": {
            "description": "Twilio no pudo enviar el c\u00f3digo.",
            "body": "{\n  \"code\": \"PHONE_VERIFICATION_SEND_FAILED\",\n  \"message\": \"No se pudo enviar el codigo de verificacion. Intenta de nuevo mas tarde.\"\n}"
        },
        "503": {
            "description": "La verificaci\u00f3n de tel\u00e9fono no est\u00e1 configurada en este entorno.",
            "body": "{\n  \"code\": \"PHONE_VERIFICATION_NOT_CONFIGURED\",\n  \"message\": \"La verificacion de telefono no esta disponible en este entorno\"\n}"
        }
    }
}

POST /auth/phone/verify/check

{
    "title": "Confirmar verificaci\u00f3n de tel\u00e9fono",
    "description": "Verifica el c\u00f3digo SMS con Twilio y persiste phone y phoneVerifiedAt solo cuando el estado es approved.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/auth/phone/verify/check",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"phone\": \"+52 55 1234 5678\",\n  \"code\": \"123456\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "El tel\u00e9fono qued\u00f3 verificado y se guard\u00f3 la marca temporal.",
            "body": "{\n  \"message\": \"Telefono verificado correctamente.\",\n  \"phoneVerifiedAt\": \"2026-01-01T00:00:00.000Z\"\n}"
        },
        "400": {
            "description": "El c\u00f3digo no fue aprobado o ya expir\u00f3.",
            "body": "{\n  \"code\": \"PHONE_VERIFICATION_NOT_APPROVED\",\n  \"message\": \"El codigo no es valido o ya expiro\"\n}"
        },
        "401": {
            "description": "La cuenta no est\u00e1 activa o la sesi\u00f3n no es v\u00e1lida.",
            "body": "{\n  \"message\": \"Usuario no autenticado\"\n}"
        },
        "502": {
            "description": "Twilio no pudo comprobar el c\u00f3digo.",
            "body": "{\n  \"code\": \"PHONE_VERIFICATION_CHECK_FAILED\",\n  \"message\": \"No se pudo verificar el codigo. Intenta de nuevo mas tarde.\"\n}"
        },
        "503": {
            "description": "La verificaci\u00f3n de tel\u00e9fono no est\u00e1 configurada en este entorno.",
            "body": "{\n  \"code\": \"PHONE_VERIFICATION_NOT_CONFIGURED\",\n  \"message\": \"La verificacion de telefono no esta disponible en este entorno\"\n}"
        }
    }
}

El controlador responde con un JSON mínimo. Para start, solo expone alreadyVerified y message. Para check, solo expone message y phoneVerifiedAt .

Workflows

Este módulo soporta dos trayectos runtime muy claros. El primero inicia el SMS. El segundo valida el código y persiste el teléfono si procede .

sequenceDiagram
    participant Controller as HTTP controller
    participant Service as phone verification service
    participant UserModel as User
    participant TwilioClient as phone verification client

    Controller->>Service: startPhoneVerificationForUser userId phone
    Service->>UserModel: findById select accountStatus phone phoneVerifiedAt
    Service->>Service: normalizePhoneE164
    alt same verified number
        Service-->>Controller: alreadyVerified true
    else other case
        Service->>TwilioClient: startPhoneVerification phone
        TwilioClient-->>Service: ok
        Service-->>Controller: alreadyVerified false
    end

    Controller->>Service: checkPhoneVerificationForUser userId phone code
    Service->>UserModel: findById select accountStatus
    Service->>Service: normalizePhoneE164
    Service->>TwilioClient: checkPhoneVerification phone code
    alt not approved
        Service-->>Controller: ApiError 400 PHONE_VERIFICATION_NOT_APPROVED
    else approved
        Service->>UserModel: updateOne set phone phoneVerifiedAt
        alt duplicate phone
            Service-->>Controller: registrationConflict phone
        else modifiedCount one
            Service-->>Controller: phoneVerifiedAt
        end
    end
  1. Iniciar SMS | La cuenta debe estar activa. El teléfono se normaliza y se envía a Twilio.
  2. Reusar teléfono ya aprobado | Si el número coincide con el teléfono verificado actual, el servicio devuelve alreadyVerified: true.
  3. Verificar código | Twilio debe responder approved para que exista persistencia.
  4. Escribir estado final | La escritura guarda phone y phoneVerifiedAt en una sola operación.
  5. Resolver colisión | Si otra cuenta ya ocupa ese número, la colisión se traduce al conflicto de registro.

Gotchas & edge cases

[!WARNING] notActiveError() devuelve 401 tanto para sesión inválida como para cuenta inactiva. El mensaje es el mismo en ambos casos.

[!CAUTION] startPhoneVerificationForUser puede enviar un SMS a un número de otra cuenta. Eso es intencional. El servicio no hace pre-chequeo de pertenencia.

[!NOTE] Twilio 404 en la comprobación se normaliza como not_found. El servicio lo trata igual que un código incorrecto o expirado.

[!IMPORTANT] La escritura final usa updateOne con filtro accountStatus: 'active'. Si la cuenta cambia de estado entre la verificación y la escritura, el servicio falla de forma segura.

  • services/api/src/modules/auth/phone-verification.client.js — Encapsula Twilio Verify y devuelve solo estados normalizados. También contiene los overrides de tests .
  • services/api/src/modules/auth/phone-verification.controller.js — Valida req.body, llama a este servicio y responde con JSON mínimo .
  • services/api/src/modules/auth/auth.service.js — Aporta registrationConflict y duplicateFieldFromMongoError, reutilizados para la colisión de phone .
  • services/api/src/models/User.js — Define la autoridad de colisión sobre User.phone y el almacenamiento de phoneVerifiedAt .
  • services/api/src/utils/phone.js — Proporciona normalizePhoneE164, usado antes de hablar con Twilio .
  • services/api/src/modules/auth/auth.validation.js — Valida la entrada HTTP que luego consume el controlador .

[!TIP] El contrato funcional completo de esta verificación está alineado con la separación dominio-proveedor descrita en la documentación del módulo auth.