Autenticación — Referencia técnica
Dónde vive esto
Backend
apps/backend/graphql/resolvers/user-authentication.resolver.js— register, login, loginWithApple, loginWithIdToken, OTP telefónico (este documento antes citaba un únicouser.resolver.js— ese archivo ya no existe; los resolvers de autenticación están divididos en varios archivosuser-*.resolver.js— corregido)apps/backend/graphql/resolvers/user-two-factor-auth.resolver.js—twoFactorStatus,setupTwoFactor/verifyTwoFactorSetup(flujo TOTP/app autenticadora),disableTwoFactor,regenerateBackupCodes, yenableTwoFactor(método SMS/correo, reexpuesto en esta sesión — ver el checklist a continuación);verifyTwoFactorCode/generateBackupCodes/verifyBackupCodesiguen eliminados de este resolver y del esquemaapps/backend/managers/user-managers/authentication.manager.js— lógica de negocio de inicio de sesión para email/contraseña, Apple, Firebase y OTP telefónicoapps/backend/managers/user-managers/password.manager.js— lógica de restablecimiento/cambio de contraseñaapps/backend/services/apple-auth.service.js— verifica los tokens de identidad y códigos de autorización de Appleapps/backend/services/firebase.service.js— verifica los tokens de ID de Firebase/Googleapps/backend/services/sms-auth.service.js— envía y verifica códigos OTP telefónicosapps/backend/graphql/context/auth-helper.js— resuelve el JWT del encabezado Authorization hacia el contexto de la solicitud
Frontend (web)
apps/frontend-nextjs/src/app/login/page.tsx— ruta de inicio de sesiónapps/frontend-nextjs/src/components/Login.tsx— formulario combinado de inicio de sesión y registro (email/contraseña víaRegisterDocument/LoginDocument, Google, Apple) — este documento antes decía que web no tenía formulario de registro; sí lo tiene (corregido)apps/frontend-nextjs/src/components/LoginTailwind.tsx— variante de inicio de sesión con estilo Tailwindapps/frontend-nextjs/src/components/ForgotPassword.tsx(ruta/forgot-password) — llama arequestPasswordResetapps/frontend-nextjs/src/components/ResetPassword.tsx(ruta/reset-password) — llama averifyResetTokeny luego aresetPasswordapps/frontend-nextjs/src/page-components/settings/SecuritySettingsPage.tsx(ruta/settings/security) —changePassword, el flujo completo de 2FA con TOTP:twoFactorStatus,setupTwoFactor,verifyTwoFactorSetup(muestra y deja que el usuario copie losbackupCodesdevueltos),disableTwoFactor,regenerateBackupCodes; además de la gestión de passkeys:myPasskeys,generatePasskeyRegistrationOptions/verifyPasskeyRegistration,deletePasskeyapps/frontend-nextjs/src/components/ProtectedRoute.tsx— redirige a /login cuando no está autenticadoapps/frontend-nextjs/src/components/PublicRoute.tsx— redirige a /home cuando ya inició sesiónapps/frontend-nextjs/src/contexts/AuthContext.tsx— contexto de React que contiene el estado del usuario/sesión autenticado
iOS
apps/ios/app/app/Features/Auth— módulo completo de autenticación: registro, login, Apple/Google sign-in, OTP telefónico, restablecimiento/cambio de contraseñaapps/ios/app/app/Core/Services/Auth/DeviceTokenService.swift— registra el token de dispositivo FCM después del login
Checklist de implementación técnica
-
register/login— resolvers conectados enuser-authentication.resolver.js; webLogin.tsxllama a ambos (RegisterDocument/LoginDocument) para un formulario combinado de login+registro; iOS también tiene ambos (RegisterViewModel) (este documento antes decía que web no tenía formulario de registro — corregido) -
loginWithIdToken(Google Sign-In / login por teléfono, renombrado deloginWithFirebasepara no exponer el nombre del proveedor en la mutación pública) — resolver conectado; se llama desde web (Login.tsx). iOS (FirebaseAuthManager.swift) todavía llama al nombre viejologinWithFirebase, que ya no existe en el schema - esto rompe actualmente el login con Google/teléfono en iOS hasta que se actualice el cliente iOS (y las operaciones depackages/graphql) -
loginWithApple— resolver conectado; se llama tanto desde web (Login.tsx) como desde iOS (AppleAuthManager.swift) -
requestPhoneOtp/loginWithPhone— resolvers conectados; solo iOS (RequestPhoneOtpUseCase,VerifyCodeViewModel) — se confirmó que no hay UI de login por teléfono en web (cero coincidencias derequestPhoneOtp/loginWithPhoneen todoapps/frontend-nextjs) -
setupTwoFactor/verifyTwoFactorSetup/disableTwoFactor/twoFactorStatus(flujo TOTP/app autenticadora) — resolvers conectados enuser-two-factor-auth.resolver.js; webSecuritySettingsPage.tsxllama a los cuatro (este documento antes decía que ninguno de los campos de 2FA se llamaba desde web ni iOS — corregido) -
enableTwoFactor(input: { method, phoneNumber })— Corregido en esta sesión: reexpuesto en el esquema (two-factor-auth.type.js) y conectado enuser-two-factor-auth.resolver.js, llamando a la lógica detwoFactorAuthManager.enableTwoFactorque había existido inalcanzable desde que se eliminó la mutation.verifyTwoFactorCode,generateBackupCodesyverifyBackupCodesiguen eliminados. Todavía no hay ningún llamador en el frontend paraenableTwoFactorenapps/frontend-nextjs(confirmado: cero coincidencias deenableTwoFactoren el código), así que el 2FA por SMS/correo vuelve a ser alcanzable vía GraphQL pero aún no se puede autoinscribir desde la UI — el 2FA por app autenticadora (setupTwoFactor) sigue siendo el único método que un usuario puede activar hoy desdeSecuritySettingsPage.tsx. -
verifyLoginTwoFactor(twoFactorToken, code)— resolver conectado enuser-authentication.resolver.js; completa un login que devolviórequiresTwoFactor: true. WebLogin.tsxlo llama, aceptando tanto un código TOTP como un código de respaldo -
setupTwoFactor/verifyTwoFactorSetupque devuelvenbackupCodes—SecuritySettingsPage.tsxlos muestra y deja que el usuario los copie (handleCopyBackupCodes) justo después de la configuración -
regenerateBackupCodes— resolver conectado enuser-two-factor-auth.resolver.js; webSecuritySettingsPage.tsxahora lo llama (handleRegenerateBackupCodes) para que un usuario pueda obtener un nuevo conjunto de códigos de respaldo más adelante, no solo ver el conjunto inicial (este documento antes decía que no había ningún llamador en el frontend — corregido) -
myPasskeys/deletePasskey— resolvers conectados enwebauthn.resolver.js; webSecuritySettingsPage.tsxlista las passkeys registradas del usuario y le permite eliminar una -
requestPasswordReset/verifyResetToken/resetPassword— resolvers conectados; webForgotPassword.tsx(/forgot-password) yResetPassword.tsx(/reset-password) los llaman; iOS también tiene este flujo (este documento antes decía que web no tenía pantalla de restablecimiento — corregido) -
changePassword— resolver conectado; webSecuritySettingsPage.tsxlo llama para cambios de contraseña de usuarios autenticados (este documento antes decía que web no tenía pantalla de cambio de contraseña — corregido) -
sendEmailVerification/resendEmailVerification/verifyEmail/isEmailVerified— resolvers conectados; se confirmó que no se llaman desde web en ningún lugar deapps/frontend-nextjs -
registerDeviceToken/getUserDeviceTokens/deleteDeviceToken— resolvers conectados; solo iOS (DeviceTokenService.swift,PushNotificationManager.swift) — se confirmó que no hay registro de token push en ningún lugar deapps/frontend-nextjs
Métodos de inicio de sesión
| Método | Mutación |
|---|---|
| Email + contraseña | register, login |
| Apple Sign-In | loginWithApple |
| Google / Firebase | loginWithIdToken (web); iOS sigue en el nombre viejo loginWithFirebase - roto hasta actualizarlo |
| Teléfono + OTP | requestPhoneOtp → loginWithPhone |
| Passkey (WebAuthn) | generatePasskeyAuthenticationOptions → verifyPasskeyAuthentication |
Registro
register crea una nueva cuenta de usuario y devuelve el objeto de usuario junto con un JWT. El cliente debe guardar el token y enviarlo como encabezado Authorization: Bearer <token> en las solicitudes posteriores.
Campos obligatorios: username, email, password. Opcionales: bio, dateOfBirth, gender, accountType.
mutation Register($input: UserRegistrationInput!) {
register(input: $input) {
user { id username email isEmailVerified }
token
}
}
Inicio de sesión con email
login autentica a un usuario existente mediante email o nombre de usuario más contraseña. Si tiene éxito, devuelve un nuevo JWT. El campo identifier acepta una dirección de email o un nombre de usuario — el backend prueba ambos.
mutation Login($input: UserLoginInput!) {
login(input: $input) {
user { id username }
token
}
}
Apple Sign-In
loginWithApple intercambia los tokens devueltos por el SDK nativo de Apple (identityToken y authorizationCode) por un JWT de Closegram. Apple solo devuelve el email y el nombre del usuario en el primer inicio de sesión, así que envíalos cuando estén disponibles. En inicios de sesión posteriores esos campos serán null — el backend ya tiene esos datos registrados.
Requiere configuración de Apple Developer y configuración de OAuth de Firebase. Consulta las variables APPLE_* en configuración de entorno (el antiguo archivo independiente APPLE_SIGNIN_IMPLEMENTATION.md se ha eliminado del repositorio).
mutation LoginWithApple(
$identityToken: String!
$authorizationCode: String!
$email: String
$firstName: String
$lastName: String
) {
loginWithApple(
identityToken: $identityToken
authorizationCode: $authorizationCode
email: $email
firstName: $firstName
lastName: $lastName
) {
user { id username }
token
}
}
Google / Firebase
loginWithIdToken acepta un token de ID de Firebase obtenido del SDK de Google Sign-In (la misma mutación también respalda el paso final del flujo de OTP telefónico - ver abajo). El backend valida el token contra Firebase, luego crea o actualiza la cuenta de Closegram y devuelve un JWT. Envía displayName y photoURL del objeto de usuario de Firebase para que el perfil se mantenga actualizado. La mutación se nombró intencionalmente según el mecanismo (un token de identidad verificado), no según el proveedor detrás de él - Firebase es un detalle de implementación que podría cambiar sin romper a quienes la llaman.
mutation LoginWithIdToken(
$idToken: String!
$email: String
$displayName: String
$photoURL: String
) {
loginWithIdToken(idToken: $idToken, email: $email, displayName: $displayName, photoURL: $photoURL) {
user { id username }
token
}
}
OTP telefónico
Flujo de dos pasos. El paso 1 solicita un código SMS enviado al número de teléfono indicado; el sessionId debe devolverse en el paso 2 para vincular ambas solicitudes. expiresIn indica al cliente cuántos segundos es válido el código.
# Step 1: request OTP — sends an SMS to the given number
mutation RequestOTP {
requestPhoneOtp(phone: "5512345678", countryCode: "+52") {
success sessionId expiresIn
}
}
# Step 2: verify the 6-digit code and receive a JWT
mutation LoginWithPhone($input: PhoneLoginInput!) {
loginWithPhone(input: $input) {
user { id username }
token
}
}
Gestión de contraseñas
requestPasswordReset envía un enlace de restablecimiento al email del usuario. verifyResetToken verifica si un token de restablecimiento sigue siendo válido antes de mostrar el formulario de nueva contraseña (le ahorra al usuario una ida y vuelta). resetPassword intercambia el token por una nueva contraseña; el token es de un solo uso. changePassword es para usuarios autenticados que conocen su contraseña actual.
mutation ResetPassword { resetPassword(token: "...", newPassword: "...") { success } }
mutation ChangePassword { changePassword(input: { currentPassword: "...", newPassword: "..." }) { success } }
query VerifyResetToken { verifyResetToken(token: "...") { valid message } }
Passkeys (WebAuthn)
Inicio de sesión sin contraseña basado en @simplewebauthn. El registro (generatePasskeyRegistrationOptions → verifyPasskeyRegistration) se hace ya con la sesión iniciada desde Ajustes → Seguridad; las credenciales viven en user_passkey. El login es una ceremonia de dos pasos:
mutation GeneratePasskeyAuthOptions($identifier: String) {
generatePasskeyAuthenticationOptions(identifier: $identifier) {
flowId
options
hasPasskeys
}
}
getAuthenticationOptions(identifier) (en services/passkey.service.js) busca las credenciales registradas del usuario y las devuelve como allowCredentials, guardando el reto en Redis bajo un flowId aleatorio. También devuelve hasPasskeys — true solo cuando el identificador tiene al menos una credencial registrada.
Por qué importa hasPasskeys (corrección del flujo de login). El botón "Continuar" de la pantalla inicial llama primero a generatePasskeyAuthenticationOptions, sin mostrar ninguna interfaz. Solo si hasPasskeys es true el cliente llama a navigator.credentials.get() (startAuthentication) y abre el diálogo de passkey del sistema. Cuando es false, el cliente salta la ceremonia por completo y baja al paso de contraseña, arrastrando el identificador ya escrito. Sin esto, un allowCredentials vacío hace que el navegador caiga en un flujo de credencial descubrible que muestra un diálogo del sistema ("usa una passkey / llave de seguridad") incluso para usuarios que nunca registraron una — lo cual es confuso. El servidor sigue generando un flowId + reto completo en todos los casos, así que el tiempo de respuesta no revela si la cuenta existe.
Luego el cliente verifica:
mutation VerifyPasskeyAuth($flowId: String!, $response: JSON!) {
verifyPasskeyAuthentication(flowId: $flowId, response: $response) {
token
user { id username }
}
}
verifyAuthentication(flowId, response) lee el reto de Redis, empareja response.id con un credentialId guardado, verifica la aserción contra WEBAUTHN_RP_ID / EXPECTED_ORIGIN y emite un token de sesión. Ver las variables de entorno WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN / WEBAUTHN_RP_NAME en configuración de entorno — deben definirse en producción o el registro falla con The RP ID "localhost" is invalid for this domain.
Las passkeys registradas se pueden listar y eliminar desde Ajustes → Seguridad mediante la consulta myPasskeys y la mutación deletePasskey(id).
Archivos clave: services/passkey.service.js, graphql/types/webauthn.type.js, graphql/resolvers/webauthn.resolver.js, database/models/UserPasskey.js; cliente components/Login.tsx (ceremonia de login) y page-components/settings/SecuritySettingsPage.tsx (registro, listado y eliminación).
Autenticación de dos factores
setupTwoFactor genera un secreto TOTP + código QR para una app autenticadora (Google Authenticator, Authy, etc.) — paso 1 de la inscripción. verifyTwoFactorSetup confirma el código de 6 dígitos y completa la inscripción, devolviendo un conjunto único de backupCodes. disableTwoFactor lo vuelve a desactivar (requiere un código válido). twoFactorStatus informa si 2FA está habilitado, cuántos códigos de respaldo quedan y cuándo se usó/configuró por última vez. regenerateBackupCodes emite un nuevo conjunto de códigos de respaldo más adelante; SecuritySettingsPage.tsx lo llama.
Una vez habilitado 2FA, login / loginWithApple / loginWithIdToken no devuelven un token directamente — devuelven requiresTwoFactor: true, un twoFactorToken de corta duración y twoFactorMethod (authenticator, sms o email). El cliente entonces llama a verifyLoginTwoFactor(twoFactorToken, code) — code puede ser el código TOTP de 6 dígitos o uno de los códigos de respaldo — para obtener el token de sesión real. Web Login.tsx implementa este segundo paso.
Un método aparte por SMS/email, enableTwoFactor(input: { method, phoneNumber }), permite que un usuario active 2FA por SMS/email desde cero. Corregido en esta sesión: esta mutation antes se había eliminado por completo del esquema GraphQL (junto con verifyTwoFactorCode, generateBackupCodes y verifyBackupCode, que siguen eliminadas), así que no había forma de habilitar de nuevo 2FA por SMS/email a través de la API aunque la lógica del manager detrás de ella nunca desapareció. Ahora volvió a estar en el esquema (two-factor-auth.type.js) y está conectada en user-two-factor-auth.resolver.js, que llama a twoFactorAuthManager.enableTwoFactor(userId, method, { phoneNumber }) y devuelve un TwoFactorSetupResponse (la misma forma que usa setupTwoFactor) — solo inicia la configuración para el método elegido (envía/genera un código, o un secreto TOTP + QR), siguiendo el mismo patrón de dos pasos que el flujo de la app autenticadora. Todavía no tiene ningún llamador en el frontend dentro de apps/frontend-nextjs, así que el 2FA por app autenticadora (setupTwoFactor) sigue siendo el único método en el que un usuario puede autoinscribirse desde la UI web hoy. La ruta de verificación sms/email dentro de two-factor-auth.manager.js todavía se ejecuta al iniciar sesión para cualquier cuenta que ya tenga ese método almacenado en user_two_factor, enviando un código nuevo y exigiéndolo mediante verifyLoginTwoFactor.
query TwoFactorStatus { twoFactorStatus { isEnabled backupCodesCount lastUsed setupDate } }
mutation SetupTwoFactor { setupTwoFactor { success message qrCode secret backupCodes } }
mutation EnableTwoFactor($input: EnableTwoFactorInput!) {
enableTwoFactor(input: $input) { success message method qrCode qrCodeSvg secret phoneNumber email codeLength expiresIn }
}
mutation VerifyTwoFactorSetup($input: TwoFactorSetupInput!) {
verifyTwoFactorSetup(input: $input) { success message backupCodes }
}
mutation DisableTwoFactor($input: TwoFactorVerifyInput!) { disableTwoFactor(input: $input) { success message } }
mutation VerifyLoginTwoFactor($twoFactorToken: String!, $code: String!) {
verifyLoginTwoFactor(twoFactorToken: $twoFactorToken, code: $code) { token user { id username } requiresTwoFactor }
}
mutation RegenerateBackupCodes { regenerateBackupCodes { success message backupCodes } }
Verificación de email
sendEmailVerification / resendEmailVerification envían ambos un email de verificación, pero resend aplica un período de espera para evitar spam. verifyEmail valida el token del enlace del email y marca la cuenta como verificada. isEmailVerified es una verificación ligera para condicionar flujos que requieren un email verificado.
mutation SendVerification { sendEmailVerification { success } }
mutation ResendVerification { resendEmailVerification { success } }
mutation VerifyEmail { verifyEmail(token: "...") { success } }
query IsEmailVerified { isEmailVerified }
Tokens de dispositivo (notificaciones push)
Al iniciar sesión, el cliente registra su token de dispositivo FCM para que el backend sepa dónde entregar las notificaciones push. platform puede ser ios, android o web. En iOS se registra un token VoIP separado para las notificaciones de llamadas (ver el documento de Llamadas).
mutation RegisterToken($input: RegisterDeviceTokenInput!) {
registerDeviceToken(input: $input) {
id token platform isActive
}
}
# List all registered tokens for the current user
query DeviceTokens { getUserDeviceTokens { id token platform deviceName isActive } }
# Remove a token when the user logs out of a device
mutation DeleteToken($tokenId: ID!) { deleteDeviceToken(tokenId: $tokenId) }
Componentes del frontend
| Componente | Descripción |
|---|---|
components/Login.tsx | Formulario combinado de inicio de sesión + registro, Google, Apple |
components/LoginTailwind.tsx | Variante con estilo Tailwind |
components/ForgotPassword.tsx | Solicita un email de restablecimiento de contraseña (/forgot-password) |
components/ResetPassword.tsx | Establece una nueva contraseña desde el enlace enviado por email (/reset-password) |
page-components/settings/SecuritySettingsPage.tsx | Cambiar contraseña, configurar/verificar/deshabilitar 2FA con TOTP/regenerar códigos de respaldo, listar/registrar/eliminar passkeys (/settings/security) |
components/ProtectedRoute.tsx | Redirige a /login cuando no está autenticado |
components/PublicRoute.tsx | Redirige a /home cuando ya inició sesión |