feat(api): add an endpoint for sending SMS messages

https://github.com/mozilla/fxa-auth-server/pull/1648

r=vbudhram
This commit is contained in:
Phil Booth 2017-02-16 08:21:22 +00:00 коммит произвёл GitHub
Родитель 22734a13bd
Коммит d35d4420ce
16 изменённых файлов: 1370 добавлений и 64 удалений

Просмотреть файл

@ -64,7 +64,7 @@ function main() {
var Server = require('../lib/server')
var server = null
var mailer = null
var senders = null
var statsInterval = null
var database = null
var customs = null
@ -74,10 +74,10 @@ function main() {
log.stat(Password.stat())
}
require('../lib/mailer')(config, log)
require('../lib/senders')(config, log)
.done(
function(m) {
mailer = m
function(result) {
senders = result
var DB = require('../lib/db')(
config,
@ -102,7 +102,8 @@ function main() {
serverPublicKeys,
signer,
db,
mailer,
senders.email,
senders.sms,
Password,
config,
customs
@ -148,7 +149,7 @@ function main() {
function () {
customs.close()
try {
mailer.stop()
senders.email.stop()
} catch (e) {
// XXX: simplesmtp module may quit early and set socket to `false`, stopping it may fail
log.warn({ op: 'shutdown', message: 'Mailer client already disconnected' })

Просмотреть файл

@ -567,6 +567,32 @@ var conf = convict({
format: Array,
env: 'HPKP_PIN_SHA256'
}
},
sms: {
enabled: {
doc: 'Indicates whether POST /sms is enabled',
default: true,
format: Boolean,
env: 'SMS_ENABLED'
},
apiKey: {
doc: 'API key for the SMS service',
default: 'YOU MUST CHANGE ME',
format: String,
env: 'SMS_API_KEY'
},
apiSecret: {
doc: 'API secret for the SMS service',
default: 'YOU MUST CHANGE ME',
format: String,
env: 'SMS_API_SECRET'
},
installFirefoxLink: {
doc: 'Link for the installFirefox SMS template',
format: 'url',
default: 'https://mzl.la/1HOd4ec',
env: 'SMS_INSTALL_FIREFOX_LINK'
}
}
})

Просмотреть файл

@ -0,0 +1,5 @@
[
[ "CA", "16474909977" ],
[ "GB", "Firefox" ],
[ "US", "15036789977" ]
]

Просмотреть файл

@ -56,6 +56,7 @@ in a sign-in or sign-up flow:
|`email.verification.resent`|A sign-up verification email has been re-sent to a user.|
|`email.verify_code.clicked`|A user has clicked on the link in a confirmation/verification email.|
|`email.${templateName}.delivered`|An email was delivered to a user.|
|`sms.${templateName}.sent`|An SMS message has been sent to a user's phone.|
|`account.confirmed`|Sign-in to an existing account has been confirmed via email.|
|`account.reminder`|A new account has been verified via a reminder email.|
|`account.verified`|A new account has been verified via email.|

Просмотреть файл

@ -32,6 +32,10 @@ var ERRNO = {
ACCOUNT_RESET: 126,
INVALID_UNBLOCK_CODE: 127,
// MISSING_TOKEN: 128,
INVALID_PHONE_NUMBER: 129,
INVALID_REGION: 130,
INVALID_MESSAGE_ID: 131,
MESSAGE_REJECTED: 132,
SERVER_BUSY: 201,
FEATURE_NOT_ENABLED: 202,
UNEXPECTED_ERROR: 999
@ -476,5 +480,50 @@ AppError.invalidUnblockCode = function () {
})
}
AppError.invalidPhoneNumber = () => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_PHONE_NUMBER,
message: 'Invalid phone number'
})
}
AppError.invalidRegion = region => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_REGION,
message: 'Invalid region'
}, {
region
})
}
AppError.invalidMessageId = () => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_MESSAGE_ID,
message: 'Invalid message id'
})
}
AppError.messageRejected = (reason, reasonCode) => {
return new AppError({
code: 500,
error: 'Bad Request',
errno: ERRNO.MESSAGE_REJECTED,
message: 'Message rejected'
}, {
reason,
reasonCode
})
}
AppError.unexpectedError = () => {
return new AppError({})
}
module.exports = AppError
module.exports.ERRNO = ERRNO

Просмотреть файл

@ -54,7 +54,8 @@ const FLOW_EVENT_ROUTES = new Set([
'/account/login/send_unblock_code',
'/account/reset',
'/recovery_email/resend_code',
'/recovery_email/verify_code'
'/recovery_email/verify_code',
'/sms'
])
const PATH_PREFIX = /^\/v1/

Просмотреть файл

@ -17,6 +17,7 @@ module.exports = function (
signer,
db,
mailer,
smsImpl,
Password,
config,
customs
@ -59,6 +60,7 @@ module.exports = function (
)
const session = require('./session')(log, isA, error, db)
const sign = require('./sign')(log, P, isA, error, signer, db, config.domain, devices)
const smsRoute = require('./sms')(log, isA, error, config, customs, smsImpl)
const util = require('./util')(
log,
random,
@ -75,6 +77,7 @@ module.exports = function (
password,
session,
sign,
smsRoute,
util
)
v1Routes.forEach(r => { r.path = basePath + '/v1' + r.path })

102
lib/routes/sms.js Normal file
Просмотреть файл

@ -0,0 +1,102 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const PhoneNumberUtil = require('google-libphonenumber').PhoneNumberUtil
const validators = require('./validators')
const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema
const SENDER_IDS = new Map(require('../../config/sms-sender-ids.json'))
module.exports = (log, isA, error, config, customs, sms) => {
if (! config.sms.enabled) {
return []
}
return [
{
method: 'POST',
path: '/sms',
config: {
auth: {
strategy: 'sessionToken'
},
validate: {
payload: {
phoneNumber: isA.string().regex(validators.E164_NUMBER).required(),
messageId: isA.number().positive().required(),
metricsContext: METRICS_CONTEXT_SCHEMA
}
}
},
handler (request, reply) {
log.begin('sms.send', request)
request.validateMetricsContext()
const sessionToken = request.auth.credentials
const phoneNumber = request.payload.phoneNumber
const messageId = request.payload.messageId
const acceptLanguage = request.app.acceptLanguage
let phoneNumberUtil, parsedPhoneNumber
customs.check(request, sessionToken.email, 'connectDeviceSms')
.then(parsePhoneNumber)
.then(validatePhoneNumber)
.then(getRegionSpecificSenderId)
.then(sendMessage)
.then(logSuccess)
.then(createResponse)
.then(reply, reply)
function parsePhoneNumber () {
phoneNumberUtil = PhoneNumberUtil.getInstance()
parsedPhoneNumber = phoneNumberUtil.parse(phoneNumber)
}
function validatePhoneNumber () {
if (! phoneNumberUtil.isValidNumber(parsedPhoneNumber)) {
throw error.invalidPhoneNumber()
}
}
function getRegionSpecificSenderId () {
const region = phoneNumberUtil.getRegionCodeForNumber(parsedPhoneNumber)
const senderId = SENDER_IDS.get(region)
if (! senderId) {
throw error.invalidRegion(region)
}
return senderId
}
function sendMessage (senderId) {
return sms.send(phoneNumber, senderId, messageId, acceptLanguage)
.catch(err => {
if (err.status === 500) {
throw error.messageRejected(err.reason, err.reasonCode)
}
if (err.status === 400) {
throw error.invalidMessageId()
}
throw new error.unexpectedError()
})
}
function logSuccess () {
return request.emitMetricsEvent(`sms.${messageId}.sent`)
}
function createResponse () {
return {}
}
}
}
]
}

Просмотреть файл

@ -16,6 +16,9 @@ module.exports.URLSAFEBASE64 = /^[a-zA-Z0-9-_]*$/
module.exports.BASE_36 = /^[a-zA-Z0-9]*$/
// Crude phone number validation. The handler code does it more thoroughly.
exports.E164_NUMBER = /^\+[1-9]\d{1,14}$/
// Match display-safe unicode characters.
// We're pretty liberal with what's allowed in a unicode string,
// but we exclude the following classes of characters:

Просмотреть файл

@ -2,22 +2,26 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var P = require('./promise')
var createMailer = require('fxa-auth-mailer')
'use strict'
module.exports = function (config, log) {
var defaultLanguage = config.i18n.defaultLanguage
const P = require('./promise')
const createSenders = require('fxa-auth-mailer')
return createMailer(
module.exports = (config, log) => {
const defaultLanguage = config.i18n.defaultLanguage
return createSenders(
log,
{
locales: config.i18n.supportedLanguages,
defaultLanguage: defaultLanguage,
mail: config.smtp
mail: config.smtp,
sms: config.sms
}
)
.then(
function (mailer) {
senders => {
const mailer = senders.email
mailer.sendVerifyCode = function (account, code, opts) {
return P.resolve(mailer.verifyEmail(
{
@ -151,7 +155,7 @@ module.exports = function (config, log) {
}
))
}
return mailer
return senders
}
)
}

809
npm-shrinkwrap.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -45,6 +45,7 @@
"fxa-geodb": "0.0.7",
"fxa-jwtool": "0.7.1",
"fxa-shared": "1.0.3",
"google-libphonenumber": "2.0.10",
"hapi": "14.2.0",
"hapi-auth-hawk": "3.0.1",
"hapi-fxa-oauth": "2.2.0",

Просмотреть файл

@ -248,7 +248,7 @@ function createMailer () {
locales: config.i18n.supportedLanguages,
defaultLanguage: defaultLanguage,
mail: config.smtp
}, sender)
}, sender).email
}
function checkRequiredOption(optionName) {

65
scripts/send-sms.js Executable file
Просмотреть файл

@ -0,0 +1,65 @@
#!/usr/bin/env node
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const config = require('../config').getProperties()
const NOT_SET = 'YOU MUST CHANGE ME'
if (config.sms.apiKey === NOT_SET || config.sms.apiSecret === NOT_SET) {
fail('Come back and try again when you\'ve set SMS_API_KEY and SMS_API_SECRET.')
}
const args = parseArgs()
const log = require('../lib/log')(config.log.level, 'send-sms')
require('../lib/senders')(config, log)
.then(senders => {
return senders.sms.send.apply(null, args)
})
.then(() => {
console.log('SENT!')
})
.catch(error => {
let message = error.message
if (error.reason && error.reasonCode) {
message = `${message}: ${error.reasonCode} ${error.reason}`
}
fail(message)
})
function fail (message) {
console.error(message)
process.exit(1)
}
function parseArgs () {
let acceptLanguage, messageId, senderId, phoneNumber
switch (process.argv.length) {
/* eslint-disable indent, no-fallthrough */
case 6:
acceptLanguage = process.argv[5]
case 5:
messageId = process.argv[4]
case 4:
senderId = process.argv[3]
case 3:
phoneNumber = process.argv[2]
break
default:
fail(`Usage: ${process.argv[1]} phoneNumber [senderId] [messageId] [acceptLanguage]`)
/* eslint-enable indent, no-fallthrough */
}
return [
phoneNumber,
senderId || 'Firefox',
messageId || 1,
acceptLanguage || 'en'
]
}

Просмотреть файл

@ -12,9 +12,9 @@ describe('mailer locales', () => {
let mailer
before(() => {
return require('../../../lib/mailer')(config, log)
.then(m => {
mailer = m
return require('../../../lib/senders')(config, log)
.then(result => {
mailer = result.email
})
})

326
test/local/routes/sms.js Normal file
Просмотреть файл

@ -0,0 +1,326 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const AppError = require('../../../lib/error')
const assert = require('insist')
const getRoute = require('../../routes_helpers').getRoute
const isA = require('joi')
const mocks = require('../../mocks')
const P = require('../../../lib/promise')
const sinon = require('sinon')
const sms = {}
function makeRoutes (options) {
options = options || {}
const log = options.log || mocks.mockLog()
return require('../../../lib/routes/sms')(log, isA, AppError, options.config, mocks.mockCustoms(), sms)
}
function runTest (route, request) {
return new P((resolve, reject) => {
route.handler(request, response => {
if (response instanceof Error) {
reject(response)
} else {
resolve(response)
}
})
})
}
describe('/sms', () => {
let log, config, routes, route, request
beforeEach(() => {
log = mocks.spyLog()
config = {
sms: {
enabled: true
}
}
routes = makeRoutes({ log, config })
route = getRoute(routes, '/sms')
request = mocks.mockRequest({
credentials: {
email: 'foo@example.org'
},
log: log,
payload: {
messageId: 42,
metricsContext: {
flowBeginTime: Date.now(),
flowId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
}
}
})
})
describe('sms.send succeeds', () => {
beforeEach(() => {
sms.send = sinon.spy(() => P.resolve())
})
describe('USA phone number', () => {
beforeEach(() => {
request.payload.phoneNumber = '+18885083401'
return runTest(route, request)
})
it('called log.begin correctly', () => {
assert.equal(log.begin.callCount, 1)
const args = log.begin.args[0]
assert.equal(args.length, 2)
assert.equal(args[0], 'sms.send')
assert.equal(args[1], request)
})
it('called request.validateMetricsContext correctly', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
const args = request.validateMetricsContext.args[0]
assert.equal(args.length, 0)
})
it('called sms.send correctly', () => {
assert.equal(sms.send.callCount, 1)
const args = sms.send.args[0]
assert.equal(args.length, 4)
assert.equal(args[0], '+18885083401')
assert.equal(args[1], '15036789977')
assert.equal(args[2], 42)
assert.equal(args[3], 'en-US')
})
it('called log.flowEvent correctly', () => {
assert.equal(log.flowEvent.callCount, 1)
const args = log.flowEvent.args[0]
assert.equal(args.length, 1)
assert.equal(args[0].event, 'sms.42.sent')
assert.equal(args[0].flow_id, request.payload.metricsContext.flowId)
})
})
describe('Canada phone number', () => {
beforeEach(() => {
request.payload.phoneNumber = '+14168483114'
return runTest(route, request)
})
it('called log.begin once', () => {
assert.equal(log.begin.callCount, 1)
})
it('called request.validateMetricsContext once', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
})
it('called sms.send correctly', () => {
assert.equal(sms.send.callCount, 1)
const args = sms.send.args[0]
assert.equal(args[0], '+14168483114')
assert.equal(args[1], '16474909977')
})
it('called log.flowEvent once', () => {
assert.equal(log.flowEvent.callCount, 1)
})
})
describe('UK phone number', () => {
beforeEach(() => {
request.payload.phoneNumber = '+442078553000'
return runTest(route, request)
})
it('called log.begin once', () => {
assert.equal(log.begin.callCount, 1)
})
it('called request.validateMetricsContext once', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
})
it('called sms.send correctly', () => {
assert.equal(sms.send.callCount, 1)
const args = sms.send.args[0]
assert.equal(args[0], '+442078553000')
assert.equal(args[1], 'Firefox')
})
it('called log.flowEvent once', () => {
assert.equal(log.flowEvent.callCount, 1)
})
})
describe('invalid phone number', () => {
let err
beforeEach(() => {
request.payload.phoneNumber = '+15551234567'
return runTest(route, request)
.catch(e => {
err = e
})
})
it('called log.begin once', () => {
assert.equal(log.begin.callCount, 1)
})
it('called request.validateMetricsContext once', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
})
it('did not call sms.send', () => {
assert.equal(sms.send.callCount, 0)
})
it('did not call log.flowEvent', () => {
assert.equal(log.flowEvent.callCount, 0)
})
it('threw the correct error data', () => {
assert.ok(err instanceof AppError)
assert.equal(err.errno, AppError.ERRNO.INVALID_PHONE_NUMBER)
assert.equal(err.message, 'Invalid phone number')
})
})
describe('invalid region', () => {
let err
beforeEach(() => {
request.payload.phoneNumber = '+886287861100'
return runTest(route, request)
.catch(e => {
err = e
})
})
it('called log.begin once', () => {
assert.equal(log.begin.callCount, 1)
})
it('called request.validateMetricsContext once', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
})
it('did not call sms.send', () => {
assert.equal(sms.send.callCount, 0)
})
it('did not call log.flowEvent', () => {
assert.equal(log.flowEvent.callCount, 0)
})
it('threw the correct error data', () => {
assert.ok(err instanceof AppError)
assert.equal(err.errno, AppError.ERRNO.INVALID_REGION)
assert.equal(err.message, 'Invalid region')
assert.equal(err.output.payload.region, 'TW')
})
})
})
describe('sms.send fails with 500 error', () => {
let err
beforeEach(() => {
sms.send = sinon.spy(() => P.reject({
status: 500,
reason: 'wibble',
reasonCode: 7
}))
request.payload.phoneNumber = '+18885083401'
return runTest(route, request)
.catch(e => {
err = e
})
})
it('called log.begin once', () => {
assert.equal(log.begin.callCount, 1)
})
it('called request.validateMetricsContext once', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
})
it('called sms.send once', () => {
assert.equal(sms.send.callCount, 1)
})
it('did not call log.flowEvent', () => {
assert.equal(log.flowEvent.callCount, 0)
})
it('threw the correct error data', () => {
assert.ok(err instanceof AppError)
assert.equal(err.errno, AppError.ERRNO.MESSAGE_REJECTED)
assert.equal(err.message, 'Message rejected')
assert.equal(err.output.payload.reason, 'wibble')
assert.equal(err.output.payload.reasonCode, 7)
})
})
describe('sms.send fails with 400 error', () => {
let err
beforeEach(() => {
sms.send = sinon.spy(() => P.reject({
status: 400
}))
request.payload.phoneNumber = '+18885083401'
return runTest(route, request)
.catch(e => {
err = e
})
})
it('called log.begin once', () => {
assert.equal(log.begin.callCount, 1)
})
it('called request.validateMetricsContext once', () => {
assert.equal(request.validateMetricsContext.callCount, 1)
})
it('called sms.send once', () => {
assert.equal(sms.send.callCount, 1)
})
it('did not call log.flowEvent', () => {
assert.equal(log.flowEvent.callCount, 0)
})
it('threw the correct error data', () => {
assert.ok(err instanceof AppError)
assert.equal(err.errno, AppError.ERRNO.INVALID_MESSAGE_ID)
assert.equal(err.message, 'Invalid message id')
})
})
})
describe('/sms disabled', () => {
let log, config, routes
beforeEach(() => {
log = mocks.spyLog()
config = {
sms: {
enabled: false
}
}
routes = makeRoutes({ log, config })
})
it('routes was empty array', () => {
assert.ok(Array.isArray(routes))
assert.equal(routes.length, 0)
})
})