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
|
||||
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 }
|
|
@ -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,
|
||||
|
|
|
@ -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 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:
|
||||
|
|
Загрузка…
Ссылка в новой задаче