Add an API to accept exposure scan data

This commit is contained in:
Vincent 2023-04-13 15:55:07 +02:00 коммит произвёл Vincent
Родитель 56300ad1e0
Коммит 0ab7ef3e76
13 изменённых файлов: 217 добавлений и 13 удалений

Просмотреть файл

@ -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 }

2
src/custom-types.d.ts поставляемый
Просмотреть файл

@ -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,

10
src/db/tables/subscribers_types.d.ts поставляемый Normal file
Просмотреть файл

@ -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;
}

40
src/external/onerep.js поставляемый
Просмотреть файл

@ -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: