packages/fxa-auth-server/lib/customs.js (209 lines of code) (raw):

/* 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 axios = require('axios'); const { config } = require('../config'); const { createHttpAgent, createHttpsAgent } = require('../lib/http-agent'); const { performance } = require('perf_hooks'); const localizeTimestamp = require('../../../libs/shared/l10n/src').localizeTimestamp({ supportedLanguages: config.get('i18n').supportedLanguages, defaultLanguage: config.get('i18n').defaultLanguage, }); const serviceName = 'customs'; class CustomsClient { constructor(url, log, error, statsd) { this.log = log; this.error = error; this.statsd = statsd; const customsHttpAgentConfig = config.get('customsHttpAgent'); if (url !== 'none') { this.httpAgent = createHttpAgent( customsHttpAgentConfig.maxSockets, customsHttpAgentConfig.maxFreeSockets, customsHttpAgentConfig.timeoutMs, customsHttpAgentConfig.freeSocketTimeoutMs ); this.httpsAgent = createHttpsAgent( customsHttpAgentConfig.maxSockets, customsHttpAgentConfig.maxFreeSockets, customsHttpAgentConfig.timeoutMs, customsHttpAgentConfig.freeSocketTimeoutMs ); this.axiosInstance = axios.create({ baseURL: url, httpAgent: this.httpAgent, httpsAgent: this.httpsAgent, }); } } async makeRequest(endpoint, requestData) { if (!this.axiosInstance) { return; } const method = endpoint.replaceAll('/', ''); const startTime = performance.now(); try { this.logHttpAgentStatus(); const response = await this.axiosInstance.post(endpoint, requestData); if (this.statsd) { this.statsd.timing( `${serviceName}.${method}.success`, performance.now() - startTime ); } return response.data; } catch (err) { if (this.statsd) { this.statsd.timing( `${serviceName}.${method}.failure`, performance.now() - startTime ); } if (err.errno > -1 || (err.statusCode && err.statusCode < 500)) { throw err; } else { throw this.error.backendServiceFailure( serviceName, 'POST', { method: 'POST', path: endpoint }, err ); } } } async check(request, email, action) { const result = await this.makeRequest('/check', { ...this.sanitizePayload({ ip: request.app.clientAddress, email, action, // Payload in this case is additional user related data (ie phone number) payload: this.sanitizePayload(request.payload), // Headers and query params are used only in the `check` endpoint to // verify request is from a real user query: request.query, headers: request.headers, }), }); this.optionallyReportStatsD('request.check', action, result); return this.handleCustomsResult(request, result); } async checkAuthenticated(request, uid, action) { const result = await this.makeRequest('/checkAuthenticated', { ...this.sanitizePayload({ action, ip: request.app.clientAddress, uid, }), }); this.optionallyReportStatsD('request.checkAuthenticated', action, result); return this.handleCustomsResult(request, result); } async checkIpOnly(request, action) { const result = await this.makeRequest('/checkIpOnly', { ...this.sanitizePayload({ action, ip: request.app.clientAddress, }), }); this.optionallyReportStatsD('request.checkIpOnly', action, result); return this.handleCustomsResult(request, result); } async flag(ip, info) { await this.makeRequest('/failedLoginAttempt', { ...this.sanitizePayload({ ip, email: info.email, errno: info.errno || this.error.ERRNO.UNEXPECTED_ERROR, }), }); } async reset(request, email) { await this.makeRequest('/passwordReset', { ...this.sanitizePayload({ ip: request.app.clientAddress, email, }), }); } /** * Remove sensitive fields from the payload before sending to customs. * * @param payload * @return {*} */ sanitizePayload(payload) { if (!payload) { return; } const clonePayload = { ...payload }; const fieldsToOmit = ['authPW', 'oldAuthPW', 'paymentToken']; fieldsToOmit.forEach((name) => delete clonePayload[name]); return clonePayload; } optionallyReportStatsD(name, action, options) { if (!options) { return; } if (this.statsd) { const tags = { action }; if (options.block != null) { tags.block = options.block; } if (options.suspect != null) { tags.suspect = options.suspect; } if (options.unblock != null) { tags.unblock = options.unblock; } if (options.blockReason != null) { tags.blockReason = options.blockReason; } this.statsd.increment(`${serviceName}.${name}`, tags); } } handleCustomsResult(request, result) { if (!result) { return; } if (result.suspect) { request.app.isSuspiciousRequest = true; } if (result.block) { // Log a flow event that the 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 this.error.tooManyRequests( result.retryAfter, retryAfterLocalized, unblock ); } throw this.error.requestBlocked(unblock); } } logHttpAgentStatus() { if (this.axiosInstance && this.statsd) { this.logStatus(this.httpAgent, 'httpAgent'); this.logStatus(this.httpsAgent, 'httpsAgent'); } } logStatus(agent, name) { if (agent) { const status = agent.getCurrentStatus(); this.statsd.gauge(`${name}.createSocketCount`, status.createSocketCount); this.statsd.gauge( `${name}.createSocketErrorCount`, status.createSocketErrorCount ); this.statsd.gauge(`${name}.closeSocketCount`, status.closeSocketCount); this.statsd.gauge(`${name}.errorSocketCount`, status.errorSocketCount); this.statsd.gauge( `${name}.timeoutSocketCount`, status.timeoutSocketCount ); this.statsd.gauge(`${name}.requestCount`, status.requestCount); Object.keys(status.freeSockets).forEach((addr, value) => { this.statsd.gauge(`${name}.freeSockets.${addr}`, value); }); Object.keys(status.sockets).forEach((addr, value) => { this.statsd.gauge(`${name}.sockets.${addr}`, value); }); } } } module.exports = CustomsClient;