зеркало из https://github.com/mozilla/fxa.git
268 строки
7.2 KiB
TypeScript
268 строки
7.2 KiB
TypeScript
/* 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/. */
|
|
|
|
/**
|
|
* Hapi routes and controller for support panel.
|
|
*
|
|
* @module
|
|
*/
|
|
import hapi from '@hapi/hapi';
|
|
import fs from 'fs';
|
|
import handlebars from 'handlebars';
|
|
import { Logger } from 'mozlog';
|
|
import path from 'path';
|
|
import requests from 'request-promise-native';
|
|
import joi from 'typesafe-joi';
|
|
|
|
const MS_IN_SEC = 1000;
|
|
|
|
const queryValidator = joi
|
|
.object()
|
|
.keys({
|
|
requestTicket: joi
|
|
.number()
|
|
.integer()
|
|
.optional(),
|
|
uid: joi
|
|
.string()
|
|
.required()
|
|
.hex()
|
|
.length(32),
|
|
})
|
|
.required();
|
|
|
|
type supportQuery = joi.Literal<typeof queryValidator>;
|
|
|
|
// Note that these `*.Response` interfaces are purely for access to known
|
|
// response keys and not an attempt to validate the return payloads from
|
|
// fxa-auth-db-mysql.
|
|
export interface AccountResponse {
|
|
email: string;
|
|
emailVerified: boolean;
|
|
locale: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
interface Device {
|
|
name: string;
|
|
type: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
export interface DevicesResponse extends Array<Device> {}
|
|
|
|
interface Subscription {
|
|
created: number;
|
|
current_period_end: number;
|
|
current_period_start: number;
|
|
plan_changed: number | null;
|
|
previous_product: string | null;
|
|
product_name: string;
|
|
status: string;
|
|
subscription_id: string;
|
|
}
|
|
|
|
export interface SubscriptionResponse extends Array<Subscription> {}
|
|
|
|
interface SigninLocation {
|
|
city: string;
|
|
state: string;
|
|
stateCode: string;
|
|
country: string;
|
|
countryCode: string;
|
|
lastAccessTime: number | Date;
|
|
}
|
|
|
|
export interface SigninLocationResponse extends Array<SigninLocation> {}
|
|
|
|
export interface TotpTokenResponse {
|
|
sharedSecret: string;
|
|
epoch: number;
|
|
verified: boolean;
|
|
enabled: boolean;
|
|
}
|
|
|
|
/** SupportController configuration */
|
|
export type SupportConfig = {
|
|
authHeader: string;
|
|
authdbUrl: string;
|
|
authServer: {
|
|
secretBearerToken: string;
|
|
signinLocationsSearchPath: string;
|
|
subscriptionsSearchPath: string;
|
|
url: string;
|
|
};
|
|
};
|
|
|
|
class SupportController {
|
|
constructor(
|
|
private readonly logger: Logger,
|
|
private readonly config: SupportConfig,
|
|
private template: handlebars.TemplateDelegate<any>
|
|
) {}
|
|
|
|
public async heartbeat(request: hapi.Request, h: hapi.ResponseToolkit) {
|
|
return h.response({}).code(200);
|
|
}
|
|
|
|
public async displayUser(request: hapi.Request, h: hapi.ResponseToolkit) {
|
|
const query = (request.query as unknown) as supportQuery;
|
|
const uid = query.uid;
|
|
const requestTicket = query.requestTicket || 'ticket-unknown';
|
|
const opts = {
|
|
json: true,
|
|
};
|
|
// This is the user who is asking for the information:
|
|
this.logger.info('infoRequest', {
|
|
authUser: request.headers[this.config.authHeader.toLowerCase()],
|
|
requestTicket,
|
|
uid,
|
|
});
|
|
|
|
let account: AccountResponse;
|
|
let devices: DevicesResponse;
|
|
let subscriptions: SubscriptionResponse;
|
|
let signinLocations: SigninLocationResponse = [];
|
|
|
|
const accountInfoReqPromises = [
|
|
`${this.config.authdbUrl}/account/${uid}`,
|
|
`${this.config.authdbUrl}/account/${uid}/devices`,
|
|
].map(url => requests.get({ ...opts, url }));
|
|
|
|
try {
|
|
[account, devices] = await Promise.all(accountInfoReqPromises);
|
|
} catch (err) {
|
|
this.logger.error('infoFetch', { err });
|
|
return h.response('<h1>Unable to fetch user</h1>').code(500);
|
|
}
|
|
|
|
const authServerRequestOptions = {
|
|
...opts,
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.authServer.secretBearerToken}`,
|
|
},
|
|
};
|
|
|
|
try {
|
|
subscriptions = await requests.get({
|
|
...authServerRequestOptions,
|
|
url: `${this.config.authServer.url}${
|
|
this.config.authServer.subscriptionsSearchPath
|
|
}?uid=${uid}&email=${encodeURIComponent(account.email)}`,
|
|
});
|
|
} catch (err) {
|
|
this.logger.error('subscriptionsFetch', { err });
|
|
return h.response('<h1>Unable to fetch subscriptions</h1>').code(500);
|
|
}
|
|
|
|
const formattedSubscriptions = subscriptions.map(s => ({
|
|
...s,
|
|
created: String(new Date(s.created * MS_IN_SEC)),
|
|
current_period_end: String(new Date(s.current_period_end * MS_IN_SEC)),
|
|
current_period_start: String(
|
|
new Date(s.current_period_start * MS_IN_SEC)
|
|
),
|
|
plan_changed: s.plan_changed
|
|
? String(new Date(s.plan_changed * MS_IN_SEC))
|
|
: 'N/A',
|
|
previous_product: s.previous_product || 'N/A',
|
|
}));
|
|
|
|
try {
|
|
const locations: SigninLocationResponse = await requests.get({
|
|
...authServerRequestOptions,
|
|
url: `${this.config.authServer.url}${this.config.authServer.signinLocationsSearchPath}?uid=${uid}`,
|
|
});
|
|
signinLocations = locations.map(v => ({
|
|
...v,
|
|
lastAccessTime: new Date(v.lastAccessTime),
|
|
}));
|
|
} catch (err) {
|
|
this.logger.error('locationsFetch', { err });
|
|
}
|
|
|
|
let totpEnabled: boolean;
|
|
try {
|
|
const totpResponse = await requests.get({
|
|
json: true,
|
|
resolveWithFullResponse: true,
|
|
url: `${this.config.authdbUrl}/totp/${uid}`,
|
|
});
|
|
totpEnabled = (totpResponse.body as TotpTokenResponse).enabled;
|
|
} catch (err) {
|
|
if (err.response?.statusCode === 404) {
|
|
totpEnabled = false;
|
|
} else {
|
|
this.logger.error('totpFetch', { err });
|
|
return h.response('<h1>Unable to fetch user</h1>').code(500);
|
|
}
|
|
}
|
|
|
|
const context = {
|
|
created: String(new Date(account.createdAt)),
|
|
devices: devices.map(d => {
|
|
return {
|
|
created: String(new Date(d.createdAt)),
|
|
name: d.name,
|
|
type: d.type,
|
|
};
|
|
}),
|
|
email: account.email,
|
|
emailVerified: !!account.emailVerified,
|
|
locale: account.locale,
|
|
signinLocations,
|
|
subscriptionStatus: subscriptions.length > 0,
|
|
subscriptions: formattedSubscriptions,
|
|
twoFactorAuth: totpEnabled,
|
|
uid,
|
|
};
|
|
const payload = this.template(context);
|
|
return h.response(payload).code(200);
|
|
}
|
|
}
|
|
|
|
/** Initialize the provided Hapi server with the support routes */
|
|
export function init(
|
|
logger: Logger,
|
|
config: SupportConfig,
|
|
server: hapi.Server
|
|
) {
|
|
let rootDir;
|
|
// Check to see if we're running in a compiled form for prod/dev or testing
|
|
if (__dirname.includes('/dist/')) {
|
|
// Compiled, move up 2 directories
|
|
rootDir = path.dirname(path.dirname(__dirname));
|
|
} else {
|
|
// Testing, move up one directory
|
|
rootDir = path.dirname(__dirname);
|
|
}
|
|
const templateDir = path.join(rootDir, 'lib', 'templates');
|
|
const pageTemplate = fs.readFileSync(path.join(templateDir, 'index.html'), {
|
|
encoding: 'UTF-8',
|
|
});
|
|
const template = handlebars.compile(pageTemplate);
|
|
|
|
const supportController = new SupportController(logger, config, template);
|
|
server.bind(supportController);
|
|
|
|
server.route([
|
|
{
|
|
handler: supportController.heartbeat,
|
|
method: 'GET',
|
|
path: '/__lbheartbeat__',
|
|
},
|
|
{
|
|
method: 'GET',
|
|
options: {
|
|
handler: supportController.displayUser,
|
|
validate: {
|
|
// @ts-ignore
|
|
query: queryValidator,
|
|
},
|
|
},
|
|
path: '/',
|
|
},
|
|
]);
|
|
}
|