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
HIBP_NOTIFY_TOKEN=unsafe-default-token-for-dev
# OneRep API for exposure scanning
ONEREP_API_KEY=
# Firefox Remote Settings
FX_REMOTE_SETTINGS_WRITER_SERVER=https://settings-writer.prod.mozaws.net/v1
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 { exposuresSetup } from '../views/partials/exposuresSetup.js'
import { exposuresList } from '../views/partials/exposuresList.js'
import { listScanResults } from '../external/onerep.js'
/**
* @type {import('express').RequestHandler}
*/
async function exposuresPage (req, res) {
const showDashboard = await hasSetUpExposureScanning(req.user)
async function exposuresPage (req, res, next) {
if (!req.user) {
next()
return
}
const user = req.user
const showDashboard = hasSetUpExposureScanning(user)
if (!showDashboard) {
/**
@ -21,12 +27,13 @@ async function exposuresPage (req, res) {
partial: exposuresSetup,
csrfToken: generateToken(res, req),
nonce: res.locals.nonce,
fxaProfile: req.user?.fxa_profile_json
fxaProfile: user.fxa_profile_json
}
res.send(mainLayout(data))
return
}
const scanResults = await listScanResults(user.onerep_profile_id)
/**
* @type {MainViewPartialData<import('../views/partials/exposuresList').PartialParameters>}
*/
@ -34,20 +41,19 @@ async function exposuresPage (req, res) {
partial: exposuresList,
csrfToken: generateToken(res, req),
nonce: res.locals.nonce,
fxaProfile: req.user?.fxa_profile_json
fxaProfile: user.fxa_profile_json,
scanResults
}
res.send(mainLayout(data))
}
/**
* @param {import('express').Request['user']} user
* @returns {Promise<boolean>} Whether the user has set up exposure scanning already
* @param {NonNullable<import('express').Request['user']>} user
* @returns {user is import('express').Request['user'] & { onerep_profile_id: number }} Whether the user has set up exposure scanning already
*/
async function hasSetUpExposureScanning (user) {
// TODO: Once the back-end supports storing a user's exposure scan data,
// check whether it has been entered:
return false
function hasSetUpExposureScanning (user) {
return typeof user.onerep_profile_id === 'number'
}
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 {
export interface Request {
user?: {
user?: (import('./db/tables/subscribers_types').SubscriberRow) & {
// TODO: Finish the type definition of the user object
fxa_profile_json?: FxaProfile;
};

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

@ -124,6 +124,18 @@ async function removeFxAData (subscriber) {
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) {
// TODO: turn 2 db queries into a single query (also see #942)
const nowDateTime = new Date()
@ -314,6 +326,7 @@ export {
updateFxAData,
removeFxAData,
updateFxAProfileData,
setOnerepProfileId,
setBreachesLastShownNow,
setAllEmailsToPrimary,
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 mozlog from '../utils/log.js'
import { parseE164PhoneNumber, parseIso8601Datetime } from '../utils/parse.js'
import { usStates } from '../utils/states.js'
const log = mozlog('external.onerep')
/**
@ -14,11 +16,13 @@ async function onerepFetch (path, options = {}) {
const url = 'https://api.onerep.com' + path
const headers = new Headers(options.headers)
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 })
}
/**
* @typedef {object} OneRepProfile
* @typedef {object} ProfileData
* @property {string} first_name
* @property {string} last_name
* @property {string} city
@ -28,7 +32,7 @@ async function onerepFetch (path, options = {}) {
*/
/**
* @param {OneRepProfile} profileData
* @param {ProfileData} profileData
* @returns {Promise<number>} Profile ID
*/
export async function createProfile (profileData) {
@ -78,6 +82,38 @@ export async function createProfile (profileData) {
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
* @returns {Promise<void>}

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

@ -8,6 +8,7 @@ import { asyncMiddleware } from '../../../middleware/util.js'
import { requireSessionUser } from '../../../middleware/auth.js'
import { methodNotAllowed } from '../../../middleware/error.js'
import { putBreachResolution, getBreaches } from '../../../controllers/breaches.js'
import { storeExposureScanData } from '../../../controllers/storeExposureScanData.js'
import {
addEmail,
resendEmail,
@ -20,6 +21,7 @@ const router = Router()
// breaches
router.put('/breaches', requireSessionUser, asyncMiddleware(putBreachResolution))
router.get('/breaches', requireSessionUser, asyncMiddleware(getBreaches))
router.post('/exposures', requireSessionUser, asyncMiddleware(storeExposureScanData))
router.post('/email', requireSessionUser, asyncMiddleware(addEmail))
router.post('/resend-email', requireSessionUser, asyncMiddleware(resendEmail))
router.post('/remove-email', requireSessionUser, asyncMiddleware(removeEmail))

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

@ -5,6 +5,7 @@
/**
* @typedef {object} PartialParameters
* @property {string} csrfToken
* @property {import("../../external/onerep").ListScanResultsResponse} scanResults
*/
/**
@ -12,4 +13,5 @@
*/
export const exposuresList = data => `
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>}
*/
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.
<button id='storeMockData' class='primary'>Store mock data</button>
`

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

@ -11,6 +11,7 @@
"src/controllers/exposures.js",
"src/external/onerep.js",
"src/utils/emailAddress.js",
"src/utils/log.js",
"src/utils/states.js",
"src/utils/parse.js",
// Replace the above with the following when our entire codebase has type annotations: