packages/fxa-auth-server/lib/account-events.ts (131 lines of code) (raw):

/* 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 { Firestore } from '@google-cloud/firestore'; import { Container } from 'typedi'; import { AuthFirestore, AppConfig } from '../lib/types'; import { StatsD } from 'hot-shots'; import { SecurityEventNames } from 'fxa-shared/db/models/auth/security-event'; type BaseEvent = { // General metric properties deviceId?: string; flowId?: string; service?: string; }; type EmailEvent = BaseEvent & { createdAt: number; name: string; eventType: 'emailEvent'; template: string; }; interface SecurityEventAdditionalInfo { userAgent?: string; location?: { city?: string; country?: string; latitude?: number; longitude?: number; }; recoveryPhone?: { phoneNumber?: string; }; } type SecurityEvent = BaseEvent & { uid: string; name: SecurityEventNames; ipAddr: string; tokenId?: string; additionalInfo?: SecurityEventAdditionalInfo; }; type AuthDatabase = { securityEvent: (arg: SecurityEvent) => void; }; type EmailEventName = | 'emailSent' | 'emailDelivered' | 'emailBounced' | 'emailComplaint'; export class AccountEventsManager { private firestore?: Firestore; private usersDbRef?; private statsd; readonly prefix: string; readonly name: string; readonly ipHmacKey: string; constructor() { // Users are already stored in the event broker Firebase collection, so we // need to grab that prefix. const { authFirestore, securityHistory } = Container.get(AppConfig); this.ipHmacKey = securityHistory.ipHmacKey; this.prefix = authFirestore.ebPrefix; this.name = `${this.prefix}users`; // Firestore is only need for email events if (Container.has(AuthFirestore)) { this.firestore = Container.get(AuthFirestore); this.usersDbRef = this.firestore.collection(this.name); } this.statsd = Container.get(StatsD); } /** * This function removes null or undefined values from an object. This ideally * saves of database storage space. This function could be optimized but instead * leaned on the simple approach of using JSON.stringify and JSON.parse because * security events are not blocking events and recorded aysnc. * * @param obj * @private */ private cleanObject(obj: any): any { return JSON.parse( JSON.stringify(obj, (key, value) => { if (value === null || value === undefined) return undefined; return value; }) ); } /** * Records a new email event for the user. */ public async recordEmailEvent( uid: string, message: EmailEvent, name: EmailEventName ) { try { const { template, deviceId, flowId, service } = message; const emailEvent = { name, createdAt: Date.now(), eventType: 'emailEvent', template, deviceId, flowId, service, }; // Firestore can be configured to ignore undefined keys, but we do it here // since it is a global config for (const [key, value] of Object.entries(emailEvent)) { if (!value) delete emailEvent[key as keyof typeof emailEvent]; } await this.usersDbRef?.doc(uid).collection('events').add(emailEvent); this.statsd.increment('accountEvents.recordEmailEvent.write'); } catch (err) { // Failing to write to events shouldn't break anything this.statsd.increment('accountEvents.recordEmailEvent.error'); } } public async findEmailEvents( uid: string, eventName: EmailEventName, template: string, startDate: number, endDate: number ) { const query = this.usersDbRef ?.doc(uid) .collection('events') .where('eventType', '==', 'emailEvent') .where('name', '==', eventName) .where('template', '==', template) .where('createdAt', '>=', startDate) .where('createdAt', '<=', endDate); const snapshot = await query?.get(); return snapshot?.docs.map((doc) => doc.data()); } /** * Record a security event for the user. This is based on our security events * that are stored in MySQL. * * @param db - auth db * @param message - message */ public recordSecurityEvent(db: AuthDatabase, message: SecurityEvent) { const { uid, name, ipAddr, tokenId, additionalInfo } = message; const eventData: SecurityEvent = { name, uid, ipAddr, tokenId, ...(additionalInfo && { // Remove all undefined or null values from additionalInfo, we don't want to // store them in the database additionalInfo: this.cleanObject(additionalInfo), }), }; try { db.securityEvent(eventData); this.statsd.increment(`accountEvents.recordSecurityEvent.write.${name}`); } catch (err) { // Failing to write to events shouldn't break anything this.statsd.increment(`accountEvents.recordSecurityEvent.error.${name}`); } } }