Skip to content
This repository has been archived by the owner on Apr 3, 2019. It is now read-only.

Commit

Permalink
feat(server): include signinCode in the installFirefox SMS
Browse files Browse the repository at this point in the history
#1904

r=shane-tomlinson,vbudhram
  • Loading branch information
philbooth committed May 22, 2017
1 parent 2d5943c commit 2610d2f
Show file tree
Hide file tree
Showing 19 changed files with 327 additions and 40 deletions.
12 changes: 12 additions & 0 deletions config/index.js
Expand Up @@ -736,6 +736,12 @@ var conf = convict({
format: 'url',
env: 'SMS_INSTALL_FIREFOX_LINK'
},
installFirefoxWithSigninCodeBaseUri: {
doc: 'Base URI for the SMS template when the signinCodes feature is active',
default: 'https://accounts.firefox.com/m',
format: 'url',
env: 'SMS_SIGNIN_CODES_BASE_URI'
},
throttleWaitTime: {
doc: 'The number of seconds to wait if throttled by the SMS service provider',
default: 2,
Expand All @@ -762,6 +768,12 @@ var conf = convict({
format: 'duration',
env: 'SECONDARY_EMAIL_MIN_UNVERIFIED_ACCOUNT_TIME'
}
},
signinCodeSize: {
doc: 'signinCode size in bytes',
default: 6,
format: 'nat',
env: 'SIGNIN_CODE_SIZE'
}
})

Expand Down
12 changes: 11 additions & 1 deletion docs/api.md
Expand Up @@ -307,6 +307,10 @@ those common validations are defined here.

}), unknown(false), and('flowId', 'flowBeginTime'), optional

#### lib/features

* `schema`: array, items(string), optional

## API endpoints

### Account
Expand Down Expand Up @@ -2058,9 +2062,15 @@ Sends an SMS message.
* `metricsContext`: *metricsContext.schema*

<!--begin-request-body-post-sms-metricsContext-->

Metrics context data for the request.
<!--end-request-body-post-sms-metricsContext-->

* `features`: *features.schema*

<!--begin-request-body-post-sms-features-->
Enabled features for the request.
<!--end-request-body-post-sms-features-->

##### Error responses

Failing requests may be caused
Expand Down
18 changes: 18 additions & 0 deletions lib/db.js
Expand Up @@ -909,6 +909,24 @@ module.exports = (
)
}

DB.prototype.createSigninCode = function (uid) {
log.trace({ op: 'DB.createSigninCode' })

return random(config.signinCodeSize)
.then(code => {
const data = { uid, createdAt: Date.now() }
return this.pool.put(`/signinCodes/${code.toString('hex')}`, data)
.then(() => code, err => {
if (isRecordAlreadyExistsError(err)) {
log.warn({ op: 'DB.createSigninCode.duplicate' })
return this.createSigninCode(uid)
}

throw err
})
})
}

function wrapTokenNotFoundError (err) {
if (isNotFoundError(err)) {
err = error.invalidToken('The authentication token could not be found')
Expand Down
6 changes: 6 additions & 0 deletions lib/features.js
Expand Up @@ -5,6 +5,9 @@
'use strict'

const crypto = require('crypto')
const isA = require('joi')

const SCHEMA = isA.array().items(isA.string()).optional()

module.exports = config => {
const lastAccessTimeUpdates = config.lastAccessTimeUpdates
Expand Down Expand Up @@ -86,3 +89,6 @@ function hash (uid, key) {
return h.digest('hex')
}

// Joi schema for endpoints that can take a `features` parameter.
module.exports.schema = SCHEMA

2 changes: 1 addition & 1 deletion lib/routes/index.js
Expand Up @@ -49,7 +49,7 @@ module.exports = function (
)
const session = require('./session')(log, db)
const sign = require('./sign')(log, signer, db, config.domain, devices)
const smsRoute = require('./sms')(log, config, customs, smsImpl)
const smsRoute = require('./sms')(log, db, config, customs, smsImpl)
const util = require('./util')(
log,
config,
Expand Down
22 changes: 14 additions & 8 deletions lib/routes/sms.js
Expand Up @@ -11,11 +11,12 @@ const PhoneNumberUtil = require('google-libphonenumber').PhoneNumberUtil
const validators = require('./validators')

const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema
const FEATURES_SCHEMA = require('../features').schema
const TEMPLATE_NAMES = new Map([
[ 1, 'installFirefox' ]
])

module.exports = (log, config, customs, sms) => {
module.exports = (log, db, config, customs, sms) => {
if (! config.sms.enabled) {
return []
}
Expand All @@ -37,7 +38,8 @@ module.exports = (log, config, customs, sms) => {
payload: {
phoneNumber: isA.string().regex(validators.E164_NUMBER).required(),
messageId: isA.number().positive().required(),
metricsContext: METRICS_CONTEXT_SCHEMA
metricsContext: METRICS_CONTEXT_SCHEMA,
features: FEATURES_SCHEMA
}
}
},
Expand All @@ -50,12 +52,13 @@ module.exports = (log, config, customs, sms) => {
const templateName = TEMPLATE_NAMES.get(request.payload.messageId)
const acceptLanguage = request.app.acceptLanguage

let phoneNumberUtil, parsedPhoneNumber
let phoneNumberUtil, parsedPhoneNumber, senderId

customs.check(request, sessionToken.email, 'connectDeviceSms')
.then(parsePhoneNumber)
.then(validatePhoneNumber)
.then(getRegionSpecificSenderId)
.then(createSigninCode)
.then(sendMessage)
.then(logSuccess)
.then(createResponse)
Expand All @@ -74,19 +77,22 @@ module.exports = (log, config, customs, sms) => {

function getRegionSpecificSenderId () {
const region = phoneNumberUtil.getRegionCodeForNumber(parsedPhoneNumber)
const senderId = SENDER_IDS[region]

request.emitMetricsEvent(`sms.region.${region}`)

senderId = SENDER_IDS[region]
if (! senderId) {
throw error.invalidRegion(region)
}
}

return senderId
function createSigninCode () {
if (request.app.features.has('signinCodes')) {
return db.createSigninCode(sessionToken.uid)
}
}

function sendMessage (senderId) {
return sms.send(phoneNumber, senderId, templateName, acceptLanguage)
function sendMessage (signinCode) {
return sms.send(phoneNumber, senderId, templateName, acceptLanguage, signinCode)
}

function logSuccess () {
Expand Down
19 changes: 15 additions & 4 deletions lib/senders/sms.js
Expand Up @@ -22,7 +22,7 @@ module.exports = function (log, translator, templates, smsConfig) {
])

return {
send: function (phoneNumber, senderId, templateName, acceptLanguage) {
send: function (phoneNumber, senderId, templateName, acceptLanguage, signinCode) {
log.trace({
op: 'sms.send',
senderId: senderId,
Expand All @@ -32,7 +32,7 @@ module.exports = function (log, translator, templates, smsConfig) {

return P.resolve()
.then(function () {
var message = getMessage(templateName, acceptLanguage)
var message = getMessage(templateName, acceptLanguage, signinCode)

return sendSms(senderId, phoneNumber, message.trim())
})
Expand Down Expand Up @@ -84,17 +84,28 @@ module.exports = function (log, translator, templates, smsConfig) {
return P.promisify(object[methodName], { context: object })
}

function getMessage (templateName, acceptLanguage) {
function getMessage (templateName, acceptLanguage, signinCode) {
var template = templates['sms.' + templateName]

if (! template) {
log.error({ op: 'sms.getMessage.error', templateName: templateName })
throw error.invalidMessageId()
}

var link
if (signinCode) {
link = smsConfig.installFirefoxWithSigninCodeBaseUri + '/' + urlSafeBase64(signinCode)
} else {
link = smsConfig[templateName + 'Link']
}

return template({
link: smsConfig[templateName + 'Link'],
link: link,
translator: translator.getTranslator(acceptLanguage)
}).text
}

function urlSafeBase64 (buffer) {
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
}
7 changes: 7 additions & 0 deletions lib/server.js
Expand Up @@ -305,6 +305,13 @@ function create(log, error, config, routes, db, translator) {
}
)

server.ext('onPreHandler', (request, reply) => {
const features = request.payload && request.payload.features
request.app.features = new Set(Array.isArray(features) ? features : [])

reply.continue()
})

server.ext(
'onPreResponse',
function (request, reply) {
Expand Down
6 changes: 6 additions & 0 deletions scripts/api-docs.handlebars
Expand Up @@ -85,6 +85,12 @@ those common validations are defined here.
* `{{key}}`: {{{value}}}
{{/each}}

#### lib/features

{{#each features}}
* `{{key}}`: {{{value}}}
{{/each}}

## API endpoints
{{#each modules}}

Expand Down
16 changes: 14 additions & 2 deletions scripts/write-api-docs.js
Expand Up @@ -52,13 +52,15 @@ P.all([
parseRoutes().then(routes => marshallRouteData(docs, errors.definitionsMap, routes)),
parseValidators(),
parseMetricsContext(),
parseFeatures(),
docs,
errors
]))
.spread((modules, validators, metricsContext, docs, errors) => writeOutput(Object.assign({
.spread((modules, validators, metricsContext, features, docs, errors) => writeOutput(Object.assign({
modules,
validators,
metricsContext,
features,
errors: errors.definitions,
additionalErrorParams: errors.additionalErrorParams
}, docs), args.path))
Expand Down Expand Up @@ -746,8 +748,9 @@ function marshallValidation (validation) {
return validation
}

// HACK: Assumes single quotes, specific path
// HACK: Assumes single quotes, specific paths
validation = validation.replace(/require\('\.\.\/metrics\/context'\)/g, 'metricsContext')
validation = validation.replace(/require\('\.\.\/features'\)/g, 'features')

// HACK: Assumes we always import joi as `isA`
if (! /^isA\./.test(validation)) {
Expand All @@ -773,6 +776,15 @@ function parseMetricsContext () {
}))
}

function parseFeatures () {
// HACK: Assumes the location of lib/features.js
return parseModuleExports('../lib/features')
.then(features => features.map(item => {
item.value = marshallValidation(item.value)
return item
}))
}

function parseErrors () {
// HACK: Assumes the location of lib/error.js
return parseModule('../lib/error')
Expand Down
4 changes: 2 additions & 2 deletions test/client/api.js
Expand Up @@ -594,13 +594,13 @@ module.exports = config => {
)
}

ClientApi.prototype.smsSend = function (sessionTokenHex, phoneNumber, messageId) {
ClientApi.prototype.smsSend = function (sessionTokenHex, phoneNumber, messageId, features) {
return tokens.SessionToken.fromHex(sessionTokenHex)
.then(token => this.doRequest(
'POST',
`${this.baseURL}/sms`,
token,
{ phoneNumber, messageId }
{ phoneNumber, messageId, features }
))
}

Expand Down
4 changes: 2 additions & 2 deletions test/client/index.js
Expand Up @@ -466,8 +466,8 @@ module.exports = config => {
)
}

Client.prototype.smsSend = function (phoneNumber, messageId) {
return this.api.smsSend(this.sessionToken, phoneNumber, messageId)
Client.prototype.smsSend = function (phoneNumber, messageId, features) {
return this.api.smsSend(this.sessionToken, phoneNumber, messageId, features)
}

Client.prototype.smsStatus = function (country, clientIpAddress) {
Expand Down
7 changes: 6 additions & 1 deletion test/local/features.js
Expand Up @@ -25,14 +25,19 @@ const config = {
secondaryEmail: {}
}

const features = proxyquire('../../lib/features', {
const MODULE_PATH = '../../lib/features'

const features = proxyquire(MODULE_PATH, {
crypto: crypto
})(config)

describe('features', () => {
it(
'interface is correct',
() => {
assert.equal(typeof require(MODULE_PATH).schema, 'object', 'features.schema is object')
assert.notEqual(require(MODULE_PATH).schema, null, 'features.schema is not null')

assert.equal(typeof features, 'object', 'object type should be exported')
assert.equal(Object.keys(features).length, 3, 'object should have correct number of properties')
assert.equal(typeof features.isSampledUser, 'function', 'isSampledUser should be function')
Expand Down

0 comments on commit 2610d2f

Please sign in to comment.