diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 8febb8ce234..1fbbf56f48a 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - multi-pds-auth + - plivo-entryway env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/packages/dev-env/src/bin.ts b/packages/dev-env/src/bin.ts index 82e768ed1f0..f6aefeaf017 100644 --- a/packages/dev-env/src/bin.ts +++ b/packages/dev-env/src/bin.ts @@ -1,7 +1,7 @@ import './env' import { generateMockSetup } from './mock' import { TestNetwork } from './network' -import { mockMailer, mockTwilio } from './util' +import { mockMailer, mockPhoneVerifier } from './util' const run = async () => { console.log(` @@ -21,6 +21,7 @@ const run = async () => { dbPostgresSchema: 'pds', enableDidDocWithSession: true, phoneVerificationRequired: true, + phoneVerificationProvider: 'twilio', twilioAccountSid: 'ACXXXXXXX', twilioAuthToken: 'AUTH', twilioServiceSid: 'VAXXXXXXXX', @@ -31,7 +32,7 @@ const run = async () => { plc: { port: 2582 }, }) mockMailer(network.pds) - mockTwilio(network.pds) + mockPhoneVerifier(network.pds) await generateMockSetup(network) console.log( diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index acd02732ef4..5dabe282377 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -78,7 +78,7 @@ export async function generateMockSetup(env: TestNetwork) { for (const user of users) { let verificationCode: string | undefined = undefined let verificationPhone: string | undefined = undefined - if (env.pds.ctx.twilio) { + if (env.pds.ctx.phoneVerifier) { verificationPhone = `+1111111111${_i}` await clients.loggedout.api.com.atproto.temp.requestPhoneVerification({ phoneNumber: verificationPhone, diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index e76a31afada..8b57d549778 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -78,10 +78,10 @@ export const uniqueLockId = () => { return lockId } -export const mockTwilio = (pds: TestPds) => { - if (!pds.ctx.twilio) return +export const mockPhoneVerifier = (pds: TestPds) => { + if (!pds.ctx.phoneVerifier) return - pds.ctx.twilio.sendCode = async (number: string) => { + pds.ctx.phoneVerifier.sendCode = async (number: string) => { if (!pds.mockedPhoneCodes[number]) { const code = crypto.randomStr(4, 'base10').slice(0, 6) pds.mockedPhoneCodes[number] = code @@ -90,7 +90,7 @@ export const mockTwilio = (pds: TestPds) => { console.log(`☎️ Phone verification code sent to ${number}: ${code}`) } - pds.ctx.twilio.verifyCode = async (number: string, code: string) => { + pds.ctx.phoneVerifier.verifyCode = async (number: string, code: string) => { if (pds.mockedPhoneCodes[number] === code) { delete pds.mockedPhoneCodes[number] return true diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 54d4ec5a564..16c9eadd729 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -21,6 +21,7 @@ import { didDocForSession } from './util' import { getPdsEndpoint } from '../../../../pds-agents' import { isThisPds } from '../../../proxy' import { dbLogger as log } from '../../../../logger' +import { normalizePhoneNumber } from '../../../../phone-verification/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -483,7 +484,7 @@ const ensurePhoneVerification = async ( phone?: string, code?: string, ): Promise => { - if (!ctx.cfg.phoneVerification.required || !ctx.twilio) { + if (!ctx.cfg.phoneVerification.required || !ctx.phoneVerifier) { return } @@ -503,8 +504,8 @@ const ensurePhoneVerification = async ( 'InvalidPhoneVerification', ) } - const normalizedPhone = ctx.twilio.normalizePhoneNumber(phone) - const verified = await ctx.twilio.verifyCode(normalizedPhone, code) + const normalizedPhone = normalizePhoneNumber(phone) + const verified = await ctx.phoneVerifier.verifyCode(normalizedPhone, code) if (!verified) { throw new InvalidRequestError( 'Could not verify phone number. Please try again.', diff --git a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts index 55d691d022a..32aacfd9308 100644 --- a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts @@ -3,6 +3,7 @@ import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' import { HOUR, MINUTE } from '@atproto/common' import { countAll } from '../../../../db/util' +import { normalizePhoneNumber } from '../../../../phone-verification/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.temp.requestPhoneVerification({ @@ -17,7 +18,7 @@ export default function (server: Server, ctx: AppContext) { }, ], handler: async ({ input }) => { - if (!ctx.twilio || !ctx.cfg.phoneVerification.required) { + if (!ctx.phoneVerifier || !ctx.cfg.phoneVerification.required) { throw new InvalidRequestError('phone verification not enabled') } if ( @@ -29,9 +30,7 @@ export default function (server: Server, ctx: AppContext) { } const accountsPerPhoneNumber = ctx.cfg.phoneVerification.accountsPerPhoneNumber - const phoneNumber = ctx.twilio.normalizePhoneNumber( - input.body.phoneNumber, - ) + const phoneNumber = normalizePhoneNumber(input.body.phoneNumber) const res = await ctx.db.db .selectFrom('phone_verification') @@ -44,7 +43,7 @@ export default function (server: Server, ctx: AppContext) { ) } - await ctx.twilio.sendCode(phoneNumber) + await ctx.phoneVerifier.sendCode(phoneNumber) }, }) } diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 161b5e1abec..c318cddd0c5 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -130,12 +130,49 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { required: false, } if (env.phoneVerificationRequired) { - assert(env.twilioAccountSid) - assert(env.twilioServiceSid) + const provider = env.phoneVerificationProvider + let providerCfg: TwilioConfig | PlivoConfig | MultiVerifierConfig + if (provider === 'twilio') { + assert(env.twilioAccountSid) + assert(env.twilioServiceSid) + providerCfg = { + provider, + accountSid: env.twilioAccountSid, + serviceSid: env.twilioServiceSid, + } + } else if (provider === 'plivo') { + assert(env.plivoAuthId) + assert(env.plivoAppId) + providerCfg = { + provider, + authId: env.plivoAuthId, + appId: env.plivoAppId, + } + } else if (provider === 'multi') { + assert(env.twilioAccountSid) + assert(env.twilioServiceSid) + assert(env.plivoAuthId) + assert(env.plivoAppId) + + providerCfg = { + provider, + twilio: { + provider: 'twilio', + accountSid: env.twilioAccountSid, + serviceSid: env.twilioServiceSid, + }, + plivo: { + provider: 'plivo', + authId: env.plivoAuthId, + appId: env.plivoAppId, + }, + } + } else { + throw new Error(`invalid phone verification provider: ${provider}`) + } phoneVerificationCfg = { required: true, - twilioAccountSid: env.twilioAccountSid, - twilioServiceSid: env.twilioServiceSid, + provider: providerCfg, accountsPerPhoneNumber: env.accountsPerPhoneNumber ?? 3, bypassPhoneNumber: env.bypassPhoneNumber, } @@ -332,8 +369,7 @@ export type InvitesConfig = export type PhoneVerificationConfig = | { required: true - twilioAccountSid: string - twilioServiceSid: string + provider: TwilioConfig | PlivoConfig | MultiVerifierConfig accountsPerPhoneNumber: number bypassPhoneNumber?: string } @@ -341,6 +377,24 @@ export type PhoneVerificationConfig = required: false } +export type TwilioConfig = { + provider: 'twilio' + accountSid: string + serviceSid: string +} + +export type PlivoConfig = { + provider: 'plivo' + authId: string + appId: string +} + +export type MultiVerifierConfig = { + provider: 'multi' + twilio: TwilioConfig + plivo: PlivoConfig +} + export type EmailConfig = { smtpUrl: string fromAddress: string diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 0156bf3445f..a9fbd97e732 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -51,11 +51,15 @@ export const readEnv = (): ServerEnvironment => { // phone verification phoneVerificationRequired: envBool('PDS_PHONE_VERIFICATION_REQUIRED'), + phoneVerificationProvider: envStr('PDS_PHONE_VERIFICATION_PROVIDER'), accountsPerPhoneNumber: envInt('PDS_ACCOUNTS_PER_PHONE_NUMBER'), bypassPhoneNumber: envStr('PDS_BYPASS_PHONE_NUMBER'), twilioAccountSid: envStr('PDS_TWILIO_ACCOUNT_SID'), twilioAuthToken: envStr('PDS_TWILIO_AUTH_TOKEN'), twilioServiceSid: envStr('PDS_TWILIO_SERVICE_SID'), + plivoAuthId: envStr('PDS_PLIVO_AUTH_ID'), + plivoAuthToken: envStr('PDS_PLIVO_AUTH_TOKEN'), + plivoAppId: envStr('PDS_PLIVO_APP_ID'), // email emailSmtpUrl: envStr('PDS_EMAIL_SMTP_URL'), @@ -173,11 +177,15 @@ export type ServerEnvironment = { // phone verification phoneVerificationRequired?: boolean + phoneVerificationProvider?: string accountsPerPhoneNumber?: number bypassPhoneNumber?: string twilioAccountSid?: string twilioAuthToken?: string twilioServiceSid?: string + plivoAuthId?: string + plivoAuthToken?: string + plivoAppId?: string // email emailSmtpUrl?: string diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index cddc6b6b7e8..d6b689a15b5 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -66,6 +66,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { triagePassword: env.triagePassword ?? env.moderatorPassword ?? env.adminPassword, twilioAuthToken: env.twilioAuthToken, + plivoAuthToken: env.plivoAuthToken, repoSigningKey, plcRotationKey, } @@ -79,6 +80,7 @@ export type ServerSecrets = { moderatorPassword: string triagePassword: string twilioAuthToken?: string + plivoAuthToken?: string repoSigningKey: SigningKeyKms | SigningKeyMemory plcRotationKey: SigningKeyKms | SigningKeyMemory } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index d5714de47f5..383e4158836 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -22,12 +22,15 @@ import { DiskBlobStore } from './storage' import { getRedisClient } from './redis' import { RuntimeFlags } from './runtime-flags' import { PdsAgents } from './pds-agents' -import { TwilioClient } from './twilio' import assert from 'assert' import { SignupLimiter } from './signup-queue/limiter' import { SignupActivator } from './signup-queue/activator' import { createCourierClient, authWithApiKey as courierAuth } from './courier' import { DAY } from '@atproto/common' +import { PhoneVerifier } from './phone-verification/util' +import { TwilioClient } from './phone-verification/twilio' +import { PlivoClient } from './phone-verification/plivo' +import { MultiVerifier } from './phone-verification/multi' export type AppContextOptions = { db: Database @@ -50,7 +53,7 @@ export type AppContextOptions = { pdsAgents: PdsAgents repoSigningKey: crypto.Keypair plcRotationKey: crypto.Keypair - twilio?: TwilioClient + phoneVerifier?: PhoneVerifier signupLimiter: SignupLimiter signupActivator: SignupActivator cfg: ServerConfig @@ -77,7 +80,7 @@ export class AppContext { public pdsAgents: PdsAgents public repoSigningKey: crypto.Keypair public plcRotationKey: crypto.Keypair - public twilio?: TwilioClient + public phoneVerifier?: PhoneVerifier public signupLimiter: SignupLimiter public signupActivator: SignupActivator public cfg: ServerConfig @@ -103,7 +106,7 @@ export class AppContext { this.pdsAgents = opts.pdsAgents this.repoSigningKey = opts.repoSigningKey this.plcRotationKey = opts.plcRotationKey - this.twilio = opts.twilio + this.phoneVerifier = opts.phoneVerifier this.signupLimiter = opts.signupLimiter this.signupActivator = opts.signupActivator this.cfg = opts.cfg @@ -223,14 +226,37 @@ export class AppContext { crawlers, }) - let twilio: TwilioClient | undefined = undefined + let phoneVerifier: PhoneVerifier | undefined = undefined if (cfg.phoneVerification.required) { - assert(secrets.twilioAuthToken) - twilio = new TwilioClient({ - accountSid: cfg.phoneVerification.twilioAccountSid, - serviceSid: cfg.phoneVerification.twilioServiceSid, - authToken: secrets.twilioAuthToken, - }) + if (cfg.phoneVerification.provider.provider === 'twilio') { + assert(secrets.twilioAuthToken, 'expected twilio auth token') + phoneVerifier = new TwilioClient({ + accountSid: cfg.phoneVerification.provider.accountSid, + serviceSid: cfg.phoneVerification.provider.serviceSid, + authToken: secrets.twilioAuthToken, + }) + } else if (cfg.phoneVerification.provider.provider === 'plivo') { + assert(secrets.plivoAuthToken, 'expected plivo auth token') + phoneVerifier = new PlivoClient(db, { + authId: cfg.phoneVerification.provider.authId, + appId: cfg.phoneVerification.provider.appId, + authToken: secrets.plivoAuthToken, + }) + } else if (cfg.phoneVerification.provider.provider === 'multi') { + assert(secrets.twilioAuthToken, 'expected twilio auth token') + assert(secrets.plivoAuthToken, 'expected plivo auth token') + const twilio = new TwilioClient({ + accountSid: cfg.phoneVerification.provider.twilio.accountSid, + serviceSid: cfg.phoneVerification.provider.twilio.serviceSid, + authToken: secrets.twilioAuthToken, + }) + const plivo = new PlivoClient(db, { + authId: cfg.phoneVerification.provider.plivo.authId, + appId: cfg.phoneVerification.provider.plivo.appId, + authToken: secrets.plivoAuthToken, + }) + phoneVerifier = new MultiVerifier(db, twilio, plivo) + } } const signupLimiter = new SignupLimiter(db) @@ -285,7 +311,7 @@ export class AppContext { repoSigningKey, plcRotationKey, pdsAgents, - twilio, + phoneVerifier, signupLimiter, signupActivator, cfg, diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 57599aa12d9..d8bc5f73b0a 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -19,6 +19,7 @@ import * as repoSeq from './tables/repo-seq' import * as appMigration from './tables/app-migration' import * as runtimeFlag from './tables/runtime-flag' import * as phoneVerification from './tables/phone-verification' +import * as plivoSession from './tables/plivo-session' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & @@ -39,7 +40,8 @@ export type DatabaseSchemaType = appMigration.PartialDB & emailToken.PartialDB & moderation.PartialDB & repoSeq.PartialDB & - phoneVerification.PartialDB + phoneVerification.PartialDB & + plivoSession.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/pds/src/db/migrations/20240208T000037762Z-plivo-session.ts b/packages/pds/src/db/migrations/20240208T000037762Z-plivo-session.ts new file mode 100644 index 00000000000..372dfc98fa1 --- /dev/null +++ b/packages/pds/src/db/migrations/20240208T000037762Z-plivo-session.ts @@ -0,0 +1,14 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('plivo_session') + .addColumn('phoneNumber', 'varchar', (col) => col.primaryKey()) + .addColumn('sessionId', 'varchar', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('plivo_session').execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index a23b1c36275..cbdaf92d8c3 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -10,3 +10,4 @@ export * as _20231011T155513453Z from './20231011T155513453Z-takedown-ref' export * as _20231031T222409283Z from './20231031T222409283Z-user-account-pds' export * as _20240117T001106576Z from './20240117T001106576Z-phone-verification' export * as _20240124T005600811Z from './20240124T005600811Z-signup-queue' +export * as _20240208T000037762Z from './20240208T000037762Z-plivo-session' diff --git a/packages/pds/src/db/tables/plivo-session.ts b/packages/pds/src/db/tables/plivo-session.ts new file mode 100644 index 00000000000..835d6a71fa0 --- /dev/null +++ b/packages/pds/src/db/tables/plivo-session.ts @@ -0,0 +1,9 @@ +export interface PlivoSession { + phoneNumber: string + sessionId: string + createdAt: string +} + +export const tableName = 'plivo_session' + +export type PartialDB = { [tableName]: PlivoSession } diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index e1b5abe3174..cc451c7ae19 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -13,6 +13,7 @@ export const mailerLogger = subsystemLogger('pds:mailer') export const labelerLogger = subsystemLogger('pds:labler') export const crawlerLogger = subsystemLogger('pds:crawler') export const twilioLogger = subsystemLogger('pds:twilio') +export const plivoLogger = subsystemLogger('pds:plivo') export const httpLogger = subsystemLogger('pds') export const loggerMiddleware = pinoHttp({ diff --git a/packages/pds/src/phone-verification/multi.ts b/packages/pds/src/phone-verification/multi.ts new file mode 100644 index 00000000000..ba08adfb601 --- /dev/null +++ b/packages/pds/src/phone-verification/multi.ts @@ -0,0 +1,93 @@ +import Database from '../db' +import { PlivoClient } from './plivo' +import { TwilioClient } from './twilio' +import { SECOND } from '@atproto/common' +import { randomIntFromSeed } from '@atproto/crypto' +import { PhoneVerifier } from './util' +import { InvalidRequestError } from '@atproto/xrpc-server' + +const PLIVO_RATIO_FLAG = 'phone-verification:plivoRatio' +const SECOND_TRY_FLAG = 'phone-verification:attemptSecondTry' + +export class MultiVerifier implements PhoneVerifier { + plivoRatio = 0 + attemptSecondTry = false + lastRefreshed = 0 + + constructor( + public db: Database, + public twilio: TwilioClient, + public plivo: PlivoClient, + ) {} + + async checkRefreshRatio() { + if (Date.now() - this.lastRefreshed > 30 * SECOND) { + await this.refreshRatio() + } + } + + async refreshRatio() { + const res = await this.db.db + .selectFrom('runtime_flag') + .where('name', '=', PLIVO_RATIO_FLAG) + .orWhere('name', '=', SECOND_TRY_FLAG) + .selectAll() + .execute() + + this.plivoRatio = parseMaybeInt( + res.find((val) => val.name === PLIVO_RATIO_FLAG)?.value, + ) + this.attemptSecondTry = + res.find((val) => val.name === SECOND_TRY_FLAG)?.value === 'true' + + this.lastRefreshed = Date.now() + } + + async sendCode(phoneNumber: string) { + await this.checkRefreshRatio() + const id = await randomIntFromSeed(phoneNumber, 10, 0) + if (id < this.plivoRatio) { + await this.plivo.sendCode(phoneNumber) + } else { + await this.twilio.sendCode(phoneNumber) + } + } + + async verifyCode(phoneNumber: string, code: string) { + await this.checkRefreshRatio() + const id = await randomIntFromSeed(phoneNumber, 10, 0) + const firstTry = + id < this.plivoRatio + ? () => this.plivo.verifyCode(phoneNumber, code) + : () => this.twilio.verifyCode(phoneNumber, code) + const secondTry = + id < this.plivoRatio + ? () => this.twilio.verifyCode(phoneNumber, code) + : () => this.plivo.verifyCode(phoneNumber, code) + try { + const verified = await firstTry() + if (!verified) { + throw new InvalidRequestError( + 'Could not verify phone number. Please try again.', + 'InvalidPhoneVerification', + ) + } + return verified + } catch (err) { + if (this.attemptSecondTry) { + return await secondTry() + } else { + throw err + } + } + } +} + +const parseMaybeInt = (str?: string): number => { + if (!str) return 0 + const parsed = parseInt(str) + if (isNaN(parsed) || parsed < 0) { + return 0 + } + return parsed +} diff --git a/packages/pds/src/phone-verification/plivo.ts b/packages/pds/src/phone-verification/plivo.ts new file mode 100644 index 00000000000..f0bad5e0ac7 --- /dev/null +++ b/packages/pds/src/phone-verification/plivo.ts @@ -0,0 +1,126 @@ +import * as ui8 from 'uint8arrays' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { plivoLogger as log } from '../logger' +import Database from '../db' +import { excluded } from '../db/util' + +type Opts = { + authId: string + authToken: string + appId: string +} + +export class PlivoClient { + authId: string + appId: string + private authorization: string + + constructor(public db: Database, opts: Opts) { + this.authId = opts.authId + this.appId = opts.appId + this.authorization = + 'Basic ' + + ui8.toString( + ui8.fromString(`${opts.authId}:${opts.authToken}`, 'utf8'), + 'base64pad', + ) + } + + async sendCode(phoneNumber: string) { + try { + // @NOTE: the trailing slash on the url is necessary + const res = await this.makeReq( + `https://api.plivo.com/v1/Account/${this.authId}/Verify/Session/`, + { + app_uuid: this.appId, + recipient: phoneNumber, + channel: 'sms', + }, + ) + const sessionId = res['session_uuid'] + if (!sessionId) { + throw new Error('no session id recieved') + } + await this.db.db + .insertInto('plivo_session') + .values({ + phoneNumber, + sessionId, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => + oc.column('phoneNumber').doUpdateSet({ + sessionId: excluded(this.db.db, 'sessionId'), + createdAt: excluded(this.db.db, 'createdAt'), + }), + ) + .execute() + } catch (err) { + log.error({ err, phoneNumber }, 'error sending plivo code') + throw new InvalidRequestError('Could not send verification text') + } + } + + async verifyCode(phoneNumber: string, code: string) { + const res = await this.db.db + .selectFrom('plivo_session') + .selectAll() + .where('phoneNumber', '=', phoneNumber) + .executeTakeFirst() + if (!res) { + throw new InvalidRequestError( + 'No verification session exists. Please try again', + 'InvalidPhoneVerification', + ) + } + + const sessionId = res.sessionId + + try { + // @NOTE: the trailing slash on the url is necessary + await this.makeReq( + `https://api.plivo.com/v1/Account/${this.authId}/Verify/Session/${sessionId}/`, + { + OTP: code, + }, + ) + return true + } catch (err) { + log.error( + { err, phoneNumber, sessionId, code }, + 'error sending plivo code', + ) + throw new InvalidRequestError( + 'Could not verify code. Please try again', + 'InvalidPhoneVerification', + ) + } + } + + private async makeReq(url: string, body: Record) { + const reqInit: RequestInit & { duplex: 'half' } = { + method: 'POST', + headers: { + authorization: this.authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + duplex: 'half', + } + + const res = await fetch(url, reqInit) + + if (res.status >= 400) { + let err: string + if (res.headers.get('content-type')?.startsWith('application/json')) { + const body = await res.json() + err = body['error'] ?? body['message'] + } else { + err = await res.text() + } + throw new Error(err) + } + + return await res.json() + } +} diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/phone-verification/twilio.ts similarity index 69% rename from packages/pds/src/twilio.ts rename to packages/pds/src/phone-verification/twilio.ts index eec698865a3..e2a2e5b579e 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/phone-verification/twilio.ts @@ -1,6 +1,7 @@ import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' import twilio from 'twilio' -import { twilioLogger as log } from './logger' +import { twilioLogger as log } from '../logger' +import { PhoneVerifier } from './util' type Opts = { accountSid: string @@ -10,7 +11,7 @@ type Opts = { type VerifyClient = ReturnType -export class TwilioClient { +export class TwilioClient implements PhoneVerifier { verifyClient: VerifyClient constructor(opts: Opts) { @@ -20,23 +21,6 @@ export class TwilioClient { ).verify.v2.services(opts.serviceSid) } - normalizePhoneNumber(phoneNumber: string) { - let normalized = phoneNumber.trim().replaceAll(/\(|\)|-| /g, '') - if (!normalized.startsWith('+')) { - if (normalized.length === 10) { - normalized = '+1' + normalized - } else { - normalized = '+' + normalized - } - } - // https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164 - const valid = /^\+[1-9]\d{1,14}$/.test(normalized) - if (!valid) { - throw new InvalidRequestError('Invalid phone number') - } - return normalized - } - async sendCode(phoneNumber: string) { try { await this.verifyClient.verifications.create({ @@ -69,7 +53,10 @@ export class TwilioClient { return res.status === 'approved' } catch (err) { log.error({ err, phoneNumber, code }, 'error verifying twilio code') - throw new UpstreamFailureError('Could not verify code. Please try again') + throw new UpstreamFailureError( + 'Could not verify code. Please try again', + 'InvalidPhoneVerification', + ) } } } diff --git a/packages/pds/src/phone-verification/util.ts b/packages/pds/src/phone-verification/util.ts new file mode 100644 index 00000000000..ebd4a540f33 --- /dev/null +++ b/packages/pds/src/phone-verification/util.ts @@ -0,0 +1,23 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' + +export interface PhoneVerifier { + sendCode(phoneNumber: string): Promise + verifyCode(phoneNumber: string, code: string): Promise +} + +export const normalizePhoneNumber = (phoneNumber: string) => { + let normalized = phoneNumber.trim().replaceAll(/\(|\)|-| /g, '') + if (!normalized.startsWith('+')) { + if (normalized.length === 10) { + normalized = '+1' + normalized + } else { + normalized = '+' + normalized + } + } + // https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164 + const valid = /^\+[1-9]\d{1,14}$/.test(normalized) + if (!valid) { + throw new InvalidRequestError('Invalid phone number') + } + return normalized +} diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts index a904bf7151c..568b13a116e 100644 --- a/packages/pds/tests/phone-verification.test.ts +++ b/packages/pds/tests/phone-verification.test.ts @@ -17,6 +17,7 @@ describe('phone verification', () => { dbPostgresSchema: 'phone_verification', pds: { phoneVerificationRequired: true, + phoneVerificationProvider: 'twilio', twilioAccountSid: 'ACXXXXXXX', twilioAuthToken: 'AUTH', twilioServiceSid: 'VAXXXXXXXX', @@ -24,10 +25,10 @@ describe('phone verification', () => { }, }) ctx = network.pds.ctx - assert(ctx.twilio) + assert(ctx.phoneVerifier) verificationCodes = {} sentCodes = [] - ctx.twilio.sendCode = async (number: string) => { + ctx.phoneVerifier.sendCode = async (number: string) => { if (!verificationCodes[number]) { const code = crypto.randomStr(4, 'base10').slice(0, 6) verificationCodes[number] = code @@ -35,7 +36,7 @@ describe('phone verification', () => { const code = verificationCodes[number] sentCodes.push({ code, number }) } - ctx.twilio.verifyCode = async (number: string, code: string) => { + ctx.phoneVerifier.verifyCode = async (number: string, code: string) => { if (verificationCodes[number] === code) { delete verificationCodes[number] return true