Add an API to accept exposure scan data
This commit is contained in:
Родитель
56300ad1e0
Коммит
0ab7ef3e76
|
@ -59,6 +59,9 @@ HIBP_THROTTLE_MAX_TRIES=5
|
||||||
# Authorization token for HIBP to present to /hibp/notify endpoint
|
# Authorization token for HIBP to present to /hibp/notify endpoint
|
||||||
HIBP_NOTIFY_TOKEN=unsafe-default-token-for-dev
|
HIBP_NOTIFY_TOKEN=unsafe-default-token-for-dev
|
||||||
|
|
||||||
|
# OneRep API for exposure scanning
|
||||||
|
ONEREP_API_KEY=
|
||||||
|
|
||||||
# Firefox Remote Settings
|
# Firefox Remote Settings
|
||||||
FX_REMOTE_SETTINGS_WRITER_SERVER=https://settings-writer.prod.mozaws.net/v1
|
FX_REMOTE_SETTINGS_WRITER_SERVER=https://settings-writer.prod.mozaws.net/v1
|
||||||
FX_REMOTE_SETTINGS_WRITER_USER=
|
FX_REMOTE_SETTINGS_WRITER_USER=
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* 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/. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.table('subscribers', (table) => {
|
||||||
|
table.integer('onerep_profile_id').nullable()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.table('subscribers', (table) => {
|
||||||
|
table.dropColumn('onerep_profile_id')
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
/* 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/. */
|
||||||
|
|
||||||
|
/** @type {HTMLDivElement} */
|
||||||
|
// @ts-ignore: We guard against a null value by not calling init():
|
||||||
|
const exposuresSetupPartial = document.querySelector("[data-partial='exposuresSetup']")
|
||||||
|
|
||||||
|
if (exposuresSetupPartial) {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
const dataEl = /** @type {HTMLTemplateElement} */ (exposuresSetupPartial.querySelector('#data'))
|
||||||
|
const csrfToken = /** @type {string} */ (dataEl.dataset.csrfToken)
|
||||||
|
|
||||||
|
const mockButton = /** @type {HTMLButtonElement} */ (exposuresSetupPartial.querySelector('#storeMockData'))
|
||||||
|
mockButton.addEventListener('click', async () => {
|
||||||
|
await storeMockData(csrfToken)
|
||||||
|
document.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} csrfToken
|
||||||
|
*/
|
||||||
|
async function storeMockData (csrfToken) {
|
||||||
|
/** @type {import('../../../external/onerep').ProfileData} */
|
||||||
|
const requestBody = {
|
||||||
|
city: 'Tulsa',
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
state: 'OK'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/user/exposures/', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-csrf-token': csrfToken,
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
mode: 'same-origin',
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Immediately caught to show an error message.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import("../../../controllers/request-breach-scan").RequestBreachScanResponse} */
|
||||||
|
const responseBody = await res.json()
|
||||||
|
|
||||||
|
if (!responseBody.success) {
|
||||||
|
throw new Error('Immediately caught to show an error message.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,12 +6,18 @@ import { mainLayout } from '../views/mainLayout.js'
|
||||||
import { generateToken } from '../utils/csrf.js'
|
import { generateToken } from '../utils/csrf.js'
|
||||||
import { exposuresSetup } from '../views/partials/exposuresSetup.js'
|
import { exposuresSetup } from '../views/partials/exposuresSetup.js'
|
||||||
import { exposuresList } from '../views/partials/exposuresList.js'
|
import { exposuresList } from '../views/partials/exposuresList.js'
|
||||||
|
import { listScanResults } from '../external/onerep.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('express').RequestHandler}
|
* @type {import('express').RequestHandler}
|
||||||
*/
|
*/
|
||||||
async function exposuresPage (req, res) {
|
async function exposuresPage (req, res, next) {
|
||||||
const showDashboard = await hasSetUpExposureScanning(req.user)
|
if (!req.user) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const user = req.user
|
||||||
|
const showDashboard = hasSetUpExposureScanning(user)
|
||||||
|
|
||||||
if (!showDashboard) {
|
if (!showDashboard) {
|
||||||
/**
|
/**
|
||||||
|
@ -21,12 +27,13 @@ async function exposuresPage (req, res) {
|
||||||
partial: exposuresSetup,
|
partial: exposuresSetup,
|
||||||
csrfToken: generateToken(res, req),
|
csrfToken: generateToken(res, req),
|
||||||
nonce: res.locals.nonce,
|
nonce: res.locals.nonce,
|
||||||
fxaProfile: req.user?.fxa_profile_json
|
fxaProfile: user.fxa_profile_json
|
||||||
}
|
}
|
||||||
res.send(mainLayout(data))
|
res.send(mainLayout(data))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scanResults = await listScanResults(user.onerep_profile_id)
|
||||||
/**
|
/**
|
||||||
* @type {MainViewPartialData<import('../views/partials/exposuresList').PartialParameters>}
|
* @type {MainViewPartialData<import('../views/partials/exposuresList').PartialParameters>}
|
||||||
*/
|
*/
|
||||||
|
@ -34,20 +41,19 @@ async function exposuresPage (req, res) {
|
||||||
partial: exposuresList,
|
partial: exposuresList,
|
||||||
csrfToken: generateToken(res, req),
|
csrfToken: generateToken(res, req),
|
||||||
nonce: res.locals.nonce,
|
nonce: res.locals.nonce,
|
||||||
fxaProfile: req.user?.fxa_profile_json
|
fxaProfile: user.fxa_profile_json,
|
||||||
|
scanResults
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send(mainLayout(data))
|
res.send(mainLayout(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('express').Request['user']} user
|
* @param {NonNullable<import('express').Request['user']>} user
|
||||||
* @returns {Promise<boolean>} Whether the user has set up exposure scanning already
|
* @returns {user is import('express').Request['user'] & { onerep_profile_id: number }} Whether the user has set up exposure scanning already
|
||||||
*/
|
*/
|
||||||
async function hasSetUpExposureScanning (user) {
|
function hasSetUpExposureScanning (user) {
|
||||||
// TODO: Once the back-end supports storing a user's exposure scan data,
|
return typeof user.onerep_profile_id === 'number'
|
||||||
// check whether it has been entered:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { exposuresPage }
|
export { exposuresPage }
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* 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/. */
|
||||||
|
|
||||||
|
import { createProfile, createScan, parseExposureScanData } from '../external/onerep.js'
|
||||||
|
import { setOnerepProfileId } from '../db/tables/subscribers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ success: false }} StoreExposureScanDataErrorResponse
|
||||||
|
* @typedef {{ success: true }} StoreExposureScanDataSuccessResponse
|
||||||
|
* @typedef {StoreExposureScanDataErrorResponse | StoreExposureScanDataSuccessResponse} StoreExposureScanDataResponse
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {import('express').RequestHandler<import('../external/onerep').ProfileData, StoreExposureScanDataResponse>} */
|
||||||
|
async function storeExposureScanData (req, res, next) {
|
||||||
|
if (req.method !== 'POST' || !req.user) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
if (typeof req.user.onerep_profile_id === 'number') {
|
||||||
|
// Exposure scan data is already set for this user
|
||||||
|
res.status(409).send({ success: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exposureScanInput = parseExposureScanData(req.body)
|
||||||
|
if (exposureScanInput === null) {
|
||||||
|
res.status(400).send({ success: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const onerepProfileId = await createProfile(exposureScanInput)
|
||||||
|
await createScan(onerepProfileId)
|
||||||
|
await setOnerepProfileId(req.user, onerepProfileId)
|
||||||
|
|
||||||
|
/** @type {StoreExposureScanDataSuccessResponse} */
|
||||||
|
const successResponse = {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
res.json(successResponse)
|
||||||
|
return
|
||||||
|
} catch (ex) {
|
||||||
|
res.status(500).send({ success: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { storeExposureScanData }
|
|
@ -40,7 +40,7 @@ type FxaProfile = {
|
||||||
}
|
}
|
||||||
declare namespace Express {
|
declare namespace Express {
|
||||||
export interface Request {
|
export interface Request {
|
||||||
user?: {
|
user?: (import('./db/tables/subscribers_types').SubscriberRow) & {
|
||||||
// TODO: Finish the type definition of the user object
|
// TODO: Finish the type definition of the user object
|
||||||
fxa_profile_json?: FxaProfile;
|
fxa_profile_json?: FxaProfile;
|
||||||
};
|
};
|
||||||
|
|
|
@ -124,6 +124,18 @@ async function removeFxAData (subscriber) {
|
||||||
return updatedSubscriber
|
return updatedSubscriber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./subscribers_types').SubscriberRow} subscriber
|
||||||
|
* @param {number} onerepProfileId
|
||||||
|
*/
|
||||||
|
async function setOnerepProfileId (subscriber, onerepProfileId) {
|
||||||
|
await knex('subscribers')
|
||||||
|
.where('id', subscriber.id)
|
||||||
|
.update({
|
||||||
|
onerep_profile_id: onerepProfileId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function setBreachesLastShownNow (subscriber) {
|
async function setBreachesLastShownNow (subscriber) {
|
||||||
// TODO: turn 2 db queries into a single query (also see #942)
|
// TODO: turn 2 db queries into a single query (also see #942)
|
||||||
const nowDateTime = new Date()
|
const nowDateTime = new Date()
|
||||||
|
@ -314,6 +326,7 @@ export {
|
||||||
updateFxAData,
|
updateFxAData,
|
||||||
removeFxAData,
|
removeFxAData,
|
||||||
updateFxAProfileData,
|
updateFxAProfileData,
|
||||||
|
setOnerepProfileId,
|
||||||
setBreachesLastShownNow,
|
setBreachesLastShownNow,
|
||||||
setAllEmailsToPrimary,
|
setAllEmailsToPrimary,
|
||||||
setBreachesResolved,
|
setBreachesResolved,
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
// This file contains types for subscribers.js until it's fully typed
|
||||||
|
|
||||||
|
export type SubscriberRow = {
|
||||||
|
id: number;
|
||||||
|
sha1: string;
|
||||||
|
email: string;
|
||||||
|
verification_token: string;
|
||||||
|
verified: boolean;
|
||||||
|
onerep_profile_id?: number;
|
||||||
|
}
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
import AppConstants from '../appConstants.js'
|
import AppConstants from '../appConstants.js'
|
||||||
import mozlog from '../utils/log.js'
|
import mozlog from '../utils/log.js'
|
||||||
|
import { parseE164PhoneNumber, parseIso8601Datetime } from '../utils/parse.js'
|
||||||
|
import { usStates } from '../utils/states.js'
|
||||||
const log = mozlog('external.onerep')
|
const log = mozlog('external.onerep')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,11 +16,13 @@ async function onerepFetch (path, options = {}) {
|
||||||
const url = 'https://api.onerep.com' + path
|
const url = 'https://api.onerep.com' + path
|
||||||
const headers = new Headers(options.headers)
|
const headers = new Headers(options.headers)
|
||||||
headers.set('Authorization', `Basic ${Buffer.from(`${AppConstants.ONEREP_API_KEY}:`).toString('base64')}`)
|
headers.set('Authorization', `Basic ${Buffer.from(`${AppConstants.ONEREP_API_KEY}:`).toString('base64')}`)
|
||||||
|
headers.set('Accept', 'application/json')
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
return fetch(url, { ...options, headers })
|
return fetch(url, { ...options, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} OneRepProfile
|
* @typedef {object} ProfileData
|
||||||
* @property {string} first_name
|
* @property {string} first_name
|
||||||
* @property {string} last_name
|
* @property {string} last_name
|
||||||
* @property {string} city
|
* @property {string} city
|
||||||
|
@ -28,7 +32,7 @@ async function onerepFetch (path, options = {}) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {OneRepProfile} profileData
|
* @param {ProfileData} profileData
|
||||||
* @returns {Promise<number>} Profile ID
|
* @returns {Promise<number>} Profile ID
|
||||||
*/
|
*/
|
||||||
export async function createProfile (profileData) {
|
export async function createProfile (profileData) {
|
||||||
|
@ -78,6 +82,38 @@ export async function createProfile (profileData) {
|
||||||
return savedProfile.id
|
return savedProfile.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} body
|
||||||
|
* @returns {ProfileData | null}
|
||||||
|
*/
|
||||||
|
export function parseExposureScanData (body) {
|
||||||
|
const state = usStates.find(state => typeof body === 'object' && state === body.state)
|
||||||
|
if (
|
||||||
|
typeof body !== 'object' ||
|
||||||
|
typeof body.first_name !== 'string' ||
|
||||||
|
body.first_name.length === 0 ||
|
||||||
|
typeof body.last_name !== 'string' ||
|
||||||
|
body.last_name.length === 0 ||
|
||||||
|
typeof body.city !== 'string' ||
|
||||||
|
body.city.length === 0 ||
|
||||||
|
typeof body.state !== 'string' ||
|
||||||
|
typeof state !== 'string' ||
|
||||||
|
(typeof body.birth_date !== 'string' && typeof body.birth_date !== 'undefined') ||
|
||||||
|
(typeof body.phone_number !== 'string' && typeof body.phone_number !== 'undefined')
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
first_name: body.first_name,
|
||||||
|
last_name: body.last_name,
|
||||||
|
city: body.city,
|
||||||
|
state,
|
||||||
|
birth_date: parseIso8601Datetime(body.birth_date)?.toISOString(),
|
||||||
|
phone_number: parseE164PhoneNumber(body.phone_number) ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} profileId
|
* @param {number} profileId
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { asyncMiddleware } from '../../../middleware/util.js'
|
||||||
import { requireSessionUser } from '../../../middleware/auth.js'
|
import { requireSessionUser } from '../../../middleware/auth.js'
|
||||||
import { methodNotAllowed } from '../../../middleware/error.js'
|
import { methodNotAllowed } from '../../../middleware/error.js'
|
||||||
import { putBreachResolution, getBreaches } from '../../../controllers/breaches.js'
|
import { putBreachResolution, getBreaches } from '../../../controllers/breaches.js'
|
||||||
|
import { storeExposureScanData } from '../../../controllers/storeExposureScanData.js'
|
||||||
import {
|
import {
|
||||||
addEmail,
|
addEmail,
|
||||||
resendEmail,
|
resendEmail,
|
||||||
|
@ -20,6 +21,7 @@ const router = Router()
|
||||||
// breaches
|
// breaches
|
||||||
router.put('/breaches', requireSessionUser, asyncMiddleware(putBreachResolution))
|
router.put('/breaches', requireSessionUser, asyncMiddleware(putBreachResolution))
|
||||||
router.get('/breaches', requireSessionUser, asyncMiddleware(getBreaches))
|
router.get('/breaches', requireSessionUser, asyncMiddleware(getBreaches))
|
||||||
|
router.post('/exposures', requireSessionUser, asyncMiddleware(storeExposureScanData))
|
||||||
router.post('/email', requireSessionUser, asyncMiddleware(addEmail))
|
router.post('/email', requireSessionUser, asyncMiddleware(addEmail))
|
||||||
router.post('/resend-email', requireSessionUser, asyncMiddleware(resendEmail))
|
router.post('/resend-email', requireSessionUser, asyncMiddleware(resendEmail))
|
||||||
router.post('/remove-email', requireSessionUser, asyncMiddleware(removeEmail))
|
router.post('/remove-email', requireSessionUser, asyncMiddleware(removeEmail))
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
/**
|
/**
|
||||||
* @typedef {object} PartialParameters
|
* @typedef {object} PartialParameters
|
||||||
* @property {string} csrfToken
|
* @property {string} csrfToken
|
||||||
|
* @property {import("../../external/onerep").ListScanResultsResponse} scanResults
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -12,4 +13,5 @@
|
||||||
*/
|
*/
|
||||||
export const exposuresList = data => `
|
export const exposuresList = data => `
|
||||||
This page will show the user's exposures dashboard, when they have already set up exposure scanning.
|
This page will show the user's exposures dashboard, when they have already set up exposure scanning.
|
||||||
|
<pre>${JSON.stringify(data.scanResults, null, 2)}</pre>
|
||||||
`
|
`
|
||||||
|
|
|
@ -11,5 +11,7 @@
|
||||||
* @type {ViewPartial<PartialParameters>}
|
* @type {ViewPartial<PartialParameters>}
|
||||||
*/
|
*/
|
||||||
export const exposuresSetup = data => `
|
export const exposuresSetup = data => `
|
||||||
|
<template id="data" data-csrf-token="${data.csrfToken}"></template>
|
||||||
This page will allow the user to enter their information to do a scan for public data exposures.
|
This page will allow the user to enter their information to do a scan for public data exposures.
|
||||||
|
<button id='storeMockData' class='primary'>Store mock data</button>
|
||||||
`
|
`
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"src/controllers/exposures.js",
|
"src/controllers/exposures.js",
|
||||||
"src/external/onerep.js",
|
"src/external/onerep.js",
|
||||||
"src/utils/emailAddress.js",
|
"src/utils/emailAddress.js",
|
||||||
|
"src/utils/log.js",
|
||||||
"src/utils/states.js",
|
"src/utils/states.js",
|
||||||
"src/utils/parse.js",
|
"src/utils/parse.js",
|
||||||
// Replace the above with the following when our entire codebase has type annotations:
|
// Replace the above with the following when our entire codebase has type annotations:
|
||||||
|
|
Загрузка…
Ссылка в новой задаче