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]
startPhoneVerificationForUsernunca consulta si un número pertenece a otra cuenta. La colisión real se resuelve solo encheckPhoneVerificationForUser, 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
startPhoneVerificationycheckPhoneVerification. 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 enfindByIdyupdateOne.User.accountStatus[badge:read-only]string— Debe seractivepara permitir el flujo.User.phone[badge:optional]string— Se escribe solo después deapproved.User.phoneVerifiedAt[badge:optional]Date— Se escribe solo después deapproved.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 unApiError401 con el mensajeUsuario no autenticado.startPhoneVerificationForUser(userId, phone)— Busca al usuario, exigeaccountStatus === 'active', normaliza el teléfono y llama astartPhoneVerification.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 persisteUser.phoneniphoneVerifiedAt.checkPhoneVerificationForUser(userId, { phone, code })— Busca al usuario activo, normaliza el teléfono y consultacheckPhoneVerification.checkPhoneVerificationForUser(userId, { phone, code })— Si Twilio no respondeapproved, lanzaApiError400 con códigoPHONE_VERIFICATION_NOT_APPROVED.checkPhoneVerificationForUser(userId, { phone, code })— Si Twilio aprueba, ejecutaupdateOney escribephoneyphoneVerifiedAt.checkPhoneVerificationForUser(userId, { phone, code })— Convierte una colisión dephoneenregistrationConflict('phone').checkPhoneVerificationForUser(userId, { phone, code })— Si la escritura no modifica una fila, vuelve a lanzarnotActiveError().
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
- Iniciar SMS | La cuenta debe estar activa. El teléfono se normaliza y se envía a Twilio.
- Reusar teléfono ya aprobado | Si el número coincide con el teléfono verificado actual, el servicio devuelve
alreadyVerified: true. - Verificar código | Twilio debe responder
approvedpara que exista persistencia. - Escribir estado final | La escritura guarda
phoneyphoneVerifiedAten una sola operación. - 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]
startPhoneVerificationForUserpuede enviar un SMS a un número de otra cuenta. Eso es intencional. El servicio no hace pre-chequeo de pertenencia.
[!NOTE] Twilio
404en la comprobación se normaliza comonot_found. El servicio lo trata igual que un código incorrecto o expirado.
[!IMPORTANT] La escritura final usa
updateOnecon filtroaccountStatus: 'active'. Si la cuenta cambia de estado entre la verificación y la escritura, el servicio falla de forma segura.
Related
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— Validareq.body, llama a este servicio y responde con JSON mínimo .services/api/src/modules/auth/auth.service.js— AportaregistrationConflictyduplicateFieldFromMongoError, reutilizados para la colisión dephone.services/api/src/models/User.js— Define la autoridad de colisión sobreUser.phoney el almacenamiento dephoneVerifiedAt.services/api/src/utils/phone.js— ProporcionanormalizePhoneE164, 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.