370 строки
10 KiB
JavaScript
370 строки
10 KiB
JavaScript
/* 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 fs = require('fs')
|
|
const Hapi = require('hapi')
|
|
const joi = require('joi')
|
|
const Raven = require('raven')
|
|
const path = require('path')
|
|
const url = require('url')
|
|
const userAgent = require('./userAgent')
|
|
const schemeRefreshToken = require('./scheme-refresh-token')
|
|
|
|
const { HEX_STRING, IP_ADDRESS } = require('./routes/validators')
|
|
|
|
function trimLocale(header) {
|
|
if (! header) {
|
|
return header
|
|
}
|
|
if (header.length < 256) {
|
|
return header.trim()
|
|
}
|
|
var parts = header.split(',')
|
|
var str = parts[0]
|
|
if (str.length >= 255) { return null }
|
|
for (var i = 1; i < parts.length && str.length + parts[i].length < 255; i++) {
|
|
str += ',' + parts[i]
|
|
}
|
|
return str.trim()
|
|
}
|
|
|
|
function logEndpointErrors(response, log) {
|
|
// When requests to DB timeout and fail for unknown reason they are an 'EndpointError'.
|
|
// The error response hides error information from the user, but we log it here
|
|
// to better understand the DB timeouts.
|
|
if (response.__proto__ && response.__proto__.name === 'EndpointError') {
|
|
var endpointLog = {
|
|
message: response.message,
|
|
reason: response.reason
|
|
}
|
|
if (response.attempt && response.attempt.method) {
|
|
// log the DB attempt to understand the action
|
|
endpointLog.method = response.attempt.method
|
|
}
|
|
log.error('server.EndpointError', endpointLog)
|
|
}
|
|
}
|
|
|
|
function configureSentry(server, config) {
|
|
const sentryDsn = config.sentryDsn
|
|
if (sentryDsn) {
|
|
Raven.config(sentryDsn, {})
|
|
server.events.on({ name: 'request', channels: 'error' }, function (request, event) {
|
|
const err = event && event.error || null
|
|
let exception = ''
|
|
if (err && err.stack) {
|
|
try {
|
|
exception = err.stack.split('\n')[0]
|
|
} catch (e) {
|
|
// ignore bad stack frames
|
|
}
|
|
}
|
|
|
|
Raven.captureException(err, {
|
|
extra: {
|
|
exception: exception
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
async function create (log, error, config, routes, db, oauthdb, translator) {
|
|
const getGeoData = require('./geodb')(log)
|
|
const metricsContext = require('./metrics/context')(log, config)
|
|
const metricsEvents = require('./metrics/events')(log, config)
|
|
|
|
// Hawk needs to calculate request signatures based on public URL,
|
|
// not the local URL to which it is bound.
|
|
var publicURL = url.parse(config.publicUrl)
|
|
var defaultPorts = {
|
|
'http:': 80,
|
|
'https:': 443
|
|
}
|
|
var hawkOptions = {
|
|
host: publicURL.hostname,
|
|
port: publicURL.port ? publicURL.port : defaultPorts[publicURL.protocol],
|
|
|
|
// We're seeing massive clock skew in deployed clients, and it's
|
|
// making auth harder than it needs to be. This effectively disables
|
|
// the timestamp checks by setting it to a humongous value.
|
|
timestampSkewSec: 20 * 365 * 24 * 60 * 60, // 20 years, +/- a few days
|
|
|
|
nonceFunc: function nonceCheck(key, nonce, ts) {
|
|
// Since we've disabled timestamp checks, there's not much point
|
|
// keeping a nonce cache. Instead we use this as an opportunity
|
|
// to report on the clock skew values seen in the wild.
|
|
var skew = (Date.now() / 1000) - (+ts)
|
|
log.trace('server.nonceFunc', { skew: skew })
|
|
}
|
|
}
|
|
|
|
|
|
function makeCredentialFn(dbGetFn) {
|
|
return function (id) {
|
|
log.trace('DB.getToken', { id: id })
|
|
if (! HEX_STRING.test(id)) {
|
|
return null
|
|
}
|
|
|
|
return dbGetFn(id)
|
|
.then(token => {
|
|
if (token.expired(Date.now())) {
|
|
const err = error.invalidToken('The authentication token has expired')
|
|
|
|
if (token.constructor.tokenTypeID === 'sessionToken') {
|
|
return db.pruneSessionTokens(token.uid, [ token ])
|
|
.catch(() => {})
|
|
.then(() => { throw err })
|
|
}
|
|
return null
|
|
}
|
|
return token
|
|
})
|
|
|
|
}
|
|
}
|
|
|
|
var serverOptions = {
|
|
host: config.listen.host,
|
|
port: config.listen.port,
|
|
routes: {
|
|
cors: {
|
|
additionalExposedHeaders: ['Timestamp', 'Accept-Language'],
|
|
origin: config.corsOrigin
|
|
},
|
|
security: {
|
|
hsts: {
|
|
maxAge: 15552000,
|
|
includeSubdomains: true
|
|
}
|
|
},
|
|
state: {
|
|
parse: false
|
|
},
|
|
payload: {
|
|
maxBytes: 16384
|
|
},
|
|
files: {
|
|
relativeTo: path.dirname(__dirname)
|
|
},
|
|
validate: {
|
|
options: {
|
|
stripUnknown: true
|
|
},
|
|
failAction: async (request, h, err) => {
|
|
// Starting with Hapi 17, the framework hides the validation info
|
|
// We want the full validation information and use it in `onPreResponse` below
|
|
|
|
// See: https://github.com/hapijs/hapi/issues/3706#issuecomment-349765943
|
|
throw err;
|
|
}
|
|
},
|
|
},
|
|
load: {
|
|
sampleInterval: 1000,
|
|
maxEventLoopDelay: config.maxEventLoopDelay
|
|
},
|
|
}
|
|
|
|
if (config.useHttps) {
|
|
serverOptions.tls = {
|
|
key: fs.readFileSync(config.keyPath),
|
|
cert: fs.readFileSync(config.certPath)
|
|
}
|
|
}
|
|
|
|
var server = new Hapi.Server(serverOptions)
|
|
|
|
server.ext('onRequest', (request, h) => {
|
|
log.begin('server.onRequest', request)
|
|
return h.continue
|
|
})
|
|
|
|
server.ext('onPreAuth', (request, h) => {
|
|
defineLazyGetter(request.app, 'remoteAddressChain', () => {
|
|
const xff = (request.headers['x-forwarded-for'] || '').split(/\s*,\s*/)
|
|
|
|
xff.push(request.info.remoteAddress)
|
|
|
|
return xff.map(address => address.trim())
|
|
.filter(address => ! joi.validate(address, IP_ADDRESS.required()).error)
|
|
})
|
|
|
|
defineLazyGetter(request.app, 'clientAddress', () => {
|
|
const remoteAddressChain = request.app.remoteAddressChain
|
|
let clientAddressIndex = remoteAddressChain.length - (config.clientAddressDepth || 1)
|
|
|
|
if (clientAddressIndex < 0) {
|
|
clientAddressIndex = 0
|
|
}
|
|
|
|
return remoteAddressChain[clientAddressIndex]
|
|
})
|
|
|
|
defineLazyGetter(request.app, 'acceptLanguage', () => trimLocale(request.headers['accept-language']))
|
|
defineLazyGetter(request.app, 'locale', () => translator.getLocale(request.app.acceptLanguage))
|
|
|
|
defineLazyGetter(request.app, 'ua', () => userAgent(request.headers['user-agent']))
|
|
defineLazyGetter(request.app, 'geo', () => getGeoData(request.app.clientAddress))
|
|
defineLazyGetter(request.app, 'metricsContext', () => metricsContext.get(request))
|
|
|
|
defineLazyGetter(request.app, 'devices', () => {
|
|
let uid
|
|
|
|
if (request.auth && request.auth.credentials) {
|
|
uid = request.auth.credentials.uid
|
|
} else if (request.payload && request.payload.uid) {
|
|
uid = request.payload.uid
|
|
}
|
|
|
|
return db.devices(uid)
|
|
})
|
|
|
|
if (request.headers.authorization) {
|
|
// Log some helpful details for debugging authentication problems.
|
|
log.trace('server.onPreAuth', {
|
|
rid: request.id,
|
|
path: request.path,
|
|
auth: request.headers.authorization,
|
|
type: request.headers['content-type'] || ''
|
|
})
|
|
}
|
|
|
|
return h.continue
|
|
})
|
|
|
|
server.ext('onPreHandler', (request, h) => {
|
|
const features = request.payload && request.payload.features
|
|
request.app.features = new Set(Array.isArray(features) ? features : [])
|
|
|
|
return h.continue
|
|
})
|
|
|
|
server.ext('onPreResponse', (request) => {
|
|
let response = request.response
|
|
if (response.isBoom) {
|
|
logEndpointErrors(response, log)
|
|
response = error.translate(request, response)
|
|
if (config.env !== 'prod') {
|
|
response.backtrace(request.app.traced)
|
|
}
|
|
}
|
|
response.header('Timestamp', '' + Math.floor(Date.now() / 1000))
|
|
log.summary(request, response)
|
|
return response
|
|
})
|
|
|
|
// configure Sentry
|
|
configureSentry(server, config)
|
|
|
|
server.decorate('request', 'stashMetricsContext', metricsContext.stash)
|
|
server.decorate('request', 'gatherMetricsContext', metricsContext.gather)
|
|
server.decorate('request', 'propagateMetricsContext', metricsContext.propagate)
|
|
server.decorate('request', 'clearMetricsContext', metricsContext.clear)
|
|
server.decorate('request', 'validateMetricsContext', metricsContext.validate)
|
|
server.decorate('request', 'setMetricsFlowCompleteSignal', metricsContext.setFlowCompleteSignal)
|
|
|
|
server.decorate('request', 'emitMetricsEvent', metricsEvents.emit)
|
|
server.decorate('request', 'emitRouteFlowEvent', metricsEvents.emitRouteFlowEvent)
|
|
|
|
server.stat = function() {
|
|
return {
|
|
stat: 'mem',
|
|
rss: server.load.rss,
|
|
heapUsed: server.load.heapUsed
|
|
}
|
|
}
|
|
|
|
await server.register(require('hapi-auth-hawk'))
|
|
|
|
server.auth.strategy(
|
|
'sessionToken',
|
|
'hawk',
|
|
{
|
|
getCredentialsFunc: makeCredentialFn(db.sessionToken.bind(db)),
|
|
hawk: hawkOptions
|
|
}
|
|
)
|
|
server.auth.strategy(
|
|
'keyFetchToken',
|
|
'hawk',
|
|
{
|
|
getCredentialsFunc: makeCredentialFn(db.keyFetchToken.bind(db)),
|
|
hawk: hawkOptions
|
|
}
|
|
)
|
|
server.auth.strategy(
|
|
// This strategy fetches the keyFetchToken with its
|
|
// verification state. It doesn't check that state.
|
|
'keyFetchTokenWithVerificationStatus',
|
|
'hawk',
|
|
{
|
|
getCredentialsFunc: makeCredentialFn(db.keyFetchTokenWithVerificationStatus.bind(db)),
|
|
hawk: hawkOptions
|
|
}
|
|
)
|
|
server.auth.strategy(
|
|
'accountResetToken',
|
|
'hawk',
|
|
{
|
|
getCredentialsFunc: makeCredentialFn(db.accountResetToken.bind(db)),
|
|
hawk: hawkOptions
|
|
}
|
|
)
|
|
server.auth.strategy(
|
|
'passwordForgotToken',
|
|
'hawk',
|
|
{
|
|
getCredentialsFunc: makeCredentialFn(db.passwordForgotToken.bind(db)),
|
|
hawk: hawkOptions
|
|
}
|
|
)
|
|
server.auth.strategy(
|
|
'passwordChangeToken',
|
|
'hawk',
|
|
{
|
|
getCredentialsFunc: makeCredentialFn(db.passwordChangeToken.bind(db)),
|
|
hawk: hawkOptions
|
|
}
|
|
)
|
|
await server.register(require('hapi-fxa-oauth'))
|
|
|
|
server.auth.strategy('oauthToken', 'fxa-oauth', config.oauth)
|
|
|
|
server.auth.scheme('fxa-oauth-refreshToken', schemeRefreshToken(db, oauthdb))
|
|
|
|
server.auth.strategy('refreshToken', 'fxa-oauth-refreshToken')
|
|
|
|
// routes should be registered after all auth strategies have initialized:
|
|
// ref: http://hapijs.com/tutorials/auth
|
|
|
|
server.route(routes)
|
|
return server
|
|
}
|
|
|
|
function defineLazyGetter (object, key, getter) {
|
|
let value
|
|
Object.defineProperty(object, key, {
|
|
get () {
|
|
if (! value) {
|
|
value = getter()
|
|
}
|
|
|
|
return value
|
|
}
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
create: create,
|
|
// Functions below exported for testing
|
|
_configureSentry: configureSentry,
|
|
_logEndpointErrors: logEndpointErrors,
|
|
_trimLocale: trimLocale
|
|
}
|