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

Commit

Permalink
feat(server): add endpoint for consuming signinCodes
Browse files Browse the repository at this point in the history
#1906

r=vbudhram,shane-tomlinson
  • Loading branch information
philbooth committed May 29, 2017
1 parent 23593c7 commit f10655d
Show file tree
Hide file tree
Showing 25 changed files with 569 additions and 79 deletions.
42 changes: 40 additions & 2 deletions docs/api.md
Expand Up @@ -57,6 +57,8 @@ see [`mozilla/fxa-js-client`](https://github.com/mozilla/fxa-js-client).
* [GET /session/status (:lock: sessionToken)](#get-sessionstatus)
* [Sign](#sign)
* [POST /certificate/sign (:lock: sessionToken)](#post-certificatesign)
* [Signin codes](#signin-codes)
* [POST /signinCodes/consume](#post-signincodesconsume)
* [Sms](#sms)
* [POST /sms (:lock: sessionToken)](#post-sms)
* [GET /sms/status (:lock: sessionToken)](#get-smsstatus)
Expand Down Expand Up @@ -238,6 +240,8 @@ for `code` and `errno` are:
Email already exists
* `code: 400, errno: 145`:
Reset password with this email type is not currently supported
* `code: 400, errno: 146`:
Invalid signin code
* `code: 503, errno: 201`:
Service unavailable
* `code: 503, errno: 202`:
Expand Down Expand Up @@ -295,17 +299,20 @@ those common validations are defined here.
* `HEX_STRING`: `/^(?:[a-fA-F0-9]{2})+$/`
* `URLSAFEBASE64`: `/^[a-zA-Z0-9-_]*$/`
* `BASE_36`: `/^[a-zA-Z0-9]*$/`
* `URL_SAFE_BASE_64`: `/^[A-Za-z0-9_-]*$/`
* `DISPLAY_SAFE_UNICODE`: `/^(?:[^\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uD800-\uDFFF\uE000-\uF8FF\uFFF9-\uFFFF])*$/`
* `service`: `string, max(16), regex(/^[a-zA-Z0-9\-]*$/g)`
* `E164_NUMBER`: `/^\+[1-9]\d{1,14}$/`

#### lib/metrics/context

* `schema`: object({
* `SCHEMA`: object({
* `flowId`: string, length(64), regex(HEX_STRING), optional
* `flowBeginTime`: number, integer, positive, optional

}), unknown(false), and('flowId', 'flowBeginTime'), optional
}), unknown(false), and('flowId', 'flowBeginTime')
* `schema`: SCHEMA.optional
* `requiredSchema`: SCHEMA.required

#### lib/features

Expand Down Expand Up @@ -2036,6 +2043,37 @@ by the following errors
Missing parameter in request body


### Signin codes

#### POST /signinCodes/consume
<!--begin-route-post-signincodesconsume-->
Exchange a single-use signin code
for an email address.
<!--end-route-post-signincodesconsume-->

##### Request body

* `code`: *string, regex(validators.URL_SAFE_BASE_64), length(CODE_LENGTH), required*

<!--begin-request-body-post-signincodesconsume-code-->
The signin code.
<!--end-request-body-post-signincodesconsume-code-->

* `metricsContext`: *metricsContext.requiredSchema*

<!--begin-request-body-post-signincodesconsume-metricsContext-->
Metrics context data for the new flow.
<!--end-request-body-post-signincodesconsume-metricsContext-->

##### Response body

* `email`: *validators.email.required*

<!--begin-response-body-post-signincodesconsume-email-->
The email address associated with the signin code.
<!--end-response-body-post-signincodesconsume-email-->


### Sms

#### POST /sms
Expand Down
1 change: 1 addition & 0 deletions docs/metrics-events.md
Expand Up @@ -149,6 +149,7 @@ in a sign-in or sign-up flow:
|`email.${templateName}.delivered`|An email was delivered to a user.|
|`sms.region.${region}`|A user has tried to send SMS to `region`.|
|`sms.${templateName}.sent`|An SMS message has been sent to a user's phone.|
|`signinCode.consumed`|A sign-in code has been consumed on the server.|
|`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.|
Expand Down
69 changes: 48 additions & 21 deletions lib/customs.js
Expand Up @@ -53,27 +53,8 @@ module.exports = function (log, error) {
}
)
.then(
function (result) {
if (result.suspect) {
request.app.isSuspiciousRequest = true
}
if (result.block) {
// log a flow event that user got blocked.
request.emitMetricsEvent('customs.blocked')

var unblock = !! result.unblock
if (result.retryAfter) {
// create a localized retryAfterLocalized value from retryAfter, for example '713' becomes '12 minutes'.
var retryAfterLocalized = localizeTimestamp.format(Date.now() + (result.retryAfter * 1000),
request.headers['accept-language'])

throw error.tooManyRequests(result.retryAfter, retryAfterLocalized, unblock)
} else {
throw error.requestBlocked(unblock)
}
}
},
function (err) {
handleCustomsResult.bind(request),
err => {
log.error({ op: 'customs.check.1', email: email, action: action, err: err })
// If this happens, either:
// - (1) the url in config doesn't point to a real customs server
Expand All @@ -83,6 +64,34 @@ module.exports = function (log, error) {
)
}

function handleCustomsResult (result) {
const request = this

if (result.suspect) {
request.app.isSuspiciousRequest = true
}

if (result.block) {
// Log a flow event that user got blocked.
request.emitMetricsEvent('customs.blocked')

const unblock = !! result.unblock

if (result.retryAfter) {
// Create a localized retryAfterLocalized value from retryAfter.
// For example '713' becomes '12 minutes' in English.
const retryAfterLocalized = localizeTimestamp.format(
Date.now() + result.retryAfter * 1000,
request.headers['accept-language']
)

throw error.tooManyRequests(result.retryAfter, retryAfterLocalized, unblock)
}

throw error.requestBlocked(unblock)
}
}

Customs.prototype.checkAuthenticated = function (action, ip, uid) {
log.trace({ op: 'customs.checkAuthenticated', action: action, uid: uid })

Expand Down Expand Up @@ -113,6 +122,24 @@ module.exports = function (log, error) {
)
}

Customs.prototype.checkIpOnly = function (request, action) {
log.trace({ op: 'customs.checkIpOnly', action: action })
return this.pool.post('/checkIpOnly', {
ip: request.app.clientAddress,
action: action
})
.then(
handleCustomsResult.bind(request),
err => {
log.error({ op: 'customs.checkIpOnly.1', action: action, err: err })
// If this happens, either:
// - (1) the url in config doesn't point to a real customs server
// - (2) the customs server returned an internal server error
// Either way, allow the request through so we fail open.
}
)
}

Customs.prototype.flag = function (ip, info) {
var email = info.email
var errno = info.errno || error.ERRNO.UNEXPECTED_ERROR
Expand Down
13 changes: 13 additions & 0 deletions lib/db.js
Expand Up @@ -927,6 +927,19 @@ module.exports = (
})
}

DB.prototype.consumeSigninCode = function (code) {
log.trace({ op: 'DB.consumeSigninCode', code })

return this.pool.post(`/signinCodes/${code.toString('hex')}/consume`)
.catch(err => {
if (isNotFoundError(err)) {
throw error.invalidSigninCode()
}

throw err
})
}

function wrapTokenNotFoundError (err) {
if (isNotFoundError(err)) {
err = error.invalidToken('The authentication token could not be found')
Expand Down
10 changes: 10 additions & 0 deletions lib/error.js
Expand Up @@ -53,6 +53,7 @@ var ERRNO = {
SECONDARY_EMAIL_UNKNOWN: 143,
VERIFIED_SECONDARY_EMAIL_EXISTS: 144,
RESET_PASSWORD_WITH_SECONDARY_EMAIL: 145,
INVALID_SIGNIN_CODE: 146,

SERVER_BUSY: 201,
FEATURE_NOT_ENABLED: 202,
Expand Down Expand Up @@ -659,6 +660,15 @@ AppError.cannotResetPasswordWithSecondaryEmail = () => {
})
}

AppError.invalidSigninCode = function () {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_SIGNIN_CODE,
message: 'Invalid signin code'
})
}

AppError.unexpectedError = () => {
return new AppError({})
}
Expand Down
7 changes: 5 additions & 2 deletions lib/metrics/context.js
Expand Up @@ -18,7 +18,6 @@ const SCHEMA = isA.object({
})
.unknown(false)
.and('flowId', 'flowBeginTime')
.optional()

module.exports = function (log, config) {
const cache = require('../cache')(log, config, 'fxa-metrics~')
Expand Down Expand Up @@ -236,4 +235,8 @@ function calculateFlowTime (time, flowBeginTime) {
return time - flowBeginTime
}

module.exports.schema = SCHEMA
// HACK: Force the API docs to expand SCHEMA inline
module.exports.SCHEMA = SCHEMA
module.exports.schema = SCHEMA.optional()
module.exports.requiredSchema = SCHEMA.required()

29 changes: 26 additions & 3 deletions lib/mock-nexmo.js
Expand Up @@ -10,7 +10,22 @@
* the message is sent successfully.
*/

function MockNexmo(log, balanceThreshold) {
function MockNexmo(log, config) {
const balanceThreshold = config.sms.balanceThreshold
const mailerOptions = {
host: config.smtp.host,
secure: config.smtp.secure,
ignoreTLS: ! config.smtp.secure,
port: config.smtp.port
}
if (config.smtp.user && config.smtp.password) {
mailerOptions.auth = {
user: config.smtp.user,
password: config.smtp.password
}
}
const mailer = require('nodemailer').createTransport(mailerOptions)

return {
account: {
/**
Expand All @@ -37,8 +52,16 @@ function MockNexmo(log, balanceThreshold) {

log.info({ op: 'sms.send.mock' })

callback(null, {
messages: [{ status: '0' }]
// HACK: Enable remote tests to see what was sent
mailer.sendMail({
from: `sms.${senderId}@restmail.net`,
to: `sms.${phoneNumber}@restmail.net`,
subject: 'MockNexmo.message.sendSms',
text: message
}, () => {
callback(null, {
messages: [{ status: '0' }]
})
})
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/routes/index.js
Expand Up @@ -49,6 +49,7 @@ module.exports = function (
)
const session = require('./session')(log, db)
const sign = require('./sign')(log, signer, db, config.domain, devices)
const signinCodes = require('./signin-codes')(log, db, customs)
const smsRoute = require('./sms')(log, db, config, customs, smsImpl)
const util = require('./util')(
log,
Expand All @@ -63,6 +64,7 @@ module.exports = function (
account,
password,
session,
signinCodes,
sign,
smsRoute,
util
Expand Down
61 changes: 61 additions & 0 deletions lib/routes/signin-codes.js
@@ -0,0 +1,61 @@
/* 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 isA = require('joi')
const validators = require('./validators')

const METRICS_CONTEXT_SCHEMA = require('../metrics/context').requiredSchema

module.exports = (log, db, customs) => {
return [
{
method: 'POST',
path: '/signinCodes/consume',
config: {
validate: {
payload: {
code: isA.string().regex(validators.URL_SAFE_BASE_64).required(),
metricsContext: METRICS_CONTEXT_SCHEMA
}
},
response: {
schema: {
email: validators.email().required()
}
}
},
handler (request, reply) {
log.begin('signinCodes.consume', request)
request.validateMetricsContext()

customs.checkIpOnly(request, 'consumeSigninCode')
.then(bufferizeSigninCode)
.then(consumeSigninCode)
.then(reply, reply)

function bufferizeSigninCode () {
let base64 = request.payload.code.replace(/-/g, '+').replace(/_/g, '/')

const padCount = base64.length % 4
for (let i = 0; i < padCount; ++i) {
base64 += '='
}

return Buffer.from(base64, 'base64')
}

function consumeSigninCode (code) {
return db.consumeSigninCode(code)
.then(result => {
return request.emitMetricsEvent('signinCode.consumed')
.then(() => result)
})
}
}
}
]
}

3 changes: 3 additions & 0 deletions lib/routes/validators.js
Expand Up @@ -13,6 +13,9 @@ module.exports.URLSAFEBASE64 = /^[a-zA-Z0-9-_]*$/

module.exports.BASE_36 = /^[a-zA-Z0-9]*$/

// RFC 4648, section 5
module.exports.URL_SAFE_BASE_64 = /^[A-Za-z0-9_-]+$/

// Crude phone number validation. The handler code does it more thoroughly.
exports.E164_NUMBER = /^\+[1-9]\d{1,14}$/

Expand Down
2 changes: 1 addition & 1 deletion lib/senders/index.js
Expand Up @@ -16,7 +16,7 @@ module.exports = function (log, config, error, bounces, translator, sender) {
.then(function (templates) {
return {
email: new Mailer(translator, templates, config.smtp, sender),
sms: createSms(log, translator, templates, config.sms)
sms: createSms(log, translator, templates, config)
}
})
}
Expand Down
5 changes: 3 additions & 2 deletions lib/senders/sms.js
Expand Up @@ -9,8 +9,9 @@ var MockNexmo = require('../mock-nexmo')
var P = require('bluebird')
var error = require('../error')

module.exports = function (log, translator, templates, smsConfig) {
var nexmo = smsConfig.useMock ? new MockNexmo(log, smsConfig.balanceThreshold) : new Nexmo({
module.exports = function (log, translator, templates, config) {
var smsConfig = config.sms
var nexmo = smsConfig.useMock ? new MockNexmo(log, config) : new Nexmo({
apiKey: smsConfig.apiKey,
apiSecret: smsConfig.apiSecret
})
Expand Down

0 comments on commit f10655d

Please sign in to comment.