packages/fxa-admin-server/src/gql/account/account.resolver.ts (564 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 { NotifierService } from '@fxa/shared/notifier'; import { Firestore } from '@google-cloud/firestore'; import { Inject, UseGuards } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Args, Mutation, Query, ResolveField, Resolver, Root, } from '@nestjs/graphql'; import { SentryTraced } from '@sentry/nestjs'; import AuthClient from 'fxa-auth-client'; import { ClientFormatter, ConnectedServicesFactory, SessionToken, } from 'fxa-shared/connected-services'; import { AttachedSession } from 'fxa-shared/connected-services/models/AttachedSession'; import { Account, getAccountCustomerByUid } from 'fxa-shared/db/models/auth'; import { SecurityEventNames } from 'fxa-shared/db/models/auth/security-event'; import { AdminPanelFeature } from 'fxa-shared/guards'; import { MozLoggerService } from '@fxa/shared/mozlog'; import { ReasonForDeletion } from '@fxa/shared/cloud-tasks'; import { CurrentUser } from '../../auth/auth-header.decorator'; import { GqlAuthHeaderGuard } from '../../auth/auth-header.guard'; import { Features } from '../../auth/user-group-header.decorator'; import { AuthClientService } from '../../backend/auth-client.service'; import { CloudTasks, CloudTasksService, } from '../../backend/cloud-tasks.service'; import { FirestoreService } from '../../backend/firestore.service'; import { ProfileClientService } from '../../backend/profile-client.service'; import { AppConfig } from '../../config'; import { DatabaseService } from '../../database/database.service'; import { uuidTransformer } from '../../database/transformers'; import { EventLoggingService, EventNames, } from '../../event-logging/event-logging.service'; import { AccountEvent as AccountEventType } from '../../gql/model/account-events.model'; import { Account as AccountType } from '../../gql/model/account.model'; import { AttachedClient } from '../../gql/model/attached-clients.model'; import { Email as EmailType } from '../../gql/model/emails.model'; import { BasketService } from '../../newsletters/basket.service'; import { SubscriptionsService } from '../../subscriptions/subscriptions.service'; import { AccountDeleteResponse, AccountDeleteStatus, AccountDeleteTaskStatus, } from '../model/account-delete-task.model'; import { CartManager } from '@fxa/payments/cart'; const ACCOUNT_COLUMNS = [ 'uid', 'email', 'emailVerified', 'createdAt', 'disabledAt', 'lockedAt', 'verifierSetAt', 'locale', 'clientSalt', ]; const EMAIL_COLUMNS = [ 'createdAt', 'email', 'id', 'isPrimary', 'isVerified', 'normalizedEmail', 'uid', ]; const SECURITY_EVENTS_COLUMNS = [ 'uid', 'verified', 'createdAt', 'ipAddr', 'additionalInfo', ]; const EMAIL_BOUNCE_COLUMNS = [ 'email', 'bounceType', 'bounceSubType', 'createdAt', 'diagnosticCode', ]; const TOTP_COLUMNS = ['uid', 'epoch', 'createdAt', 'verified', 'enabled']; const RECOVERYKEY_COLUMNS = [ 'uid', 'createdAt', 'verifiedAt', 'enabled', 'hint', ]; const RECOVERYPHONES_COLUMNS = ['phoneNumber']; const LINKEDACCOUNT_COLUMNS = ['uid', 'authAt', 'providerId', 'enabled']; @UseGuards(GqlAuthHeaderGuard) @Resolver((of: any) => AccountType) export class AccountResolver { private get clientFormatterConfig() { return this.configService.get( 'clientFormatter' ) as AppConfig['clientFormatter']; } private get ipHmacKey() { return this.configService.get('ipHmacKey') as AppConfig['ipHmacKey']; } constructor( private log: MozLoggerService, private db: DatabaseService, private cartManager: CartManager, private subscriptionsService: SubscriptionsService, private configService: ConfigService<AppConfig>, private eventLogging: EventLoggingService, private basketService: BasketService, private notifier: NotifierService, @Inject(AuthClientService) private authAPI: AuthClient, @Inject(FirestoreService) private firestore: Firestore, @Inject(CloudTasksService) private cloudTask: CloudTasks, @Inject(ProfileClientService) private profileClient: ProfileClientService ) {} @Features(AdminPanelFeature.AccountSearch) @Query((returns) => AccountType, { nullable: true }) public async accountByUid( @Args('uid', { nullable: false }) uid: string, @CurrentUser() user: string ) { this.eventLogging.onAccountSearch('uid', false); let uidBuffer; try { uidBuffer = uuidTransformer.to(uid); } catch (err) { return; } this.log.info('accountByUid', { uid, user }); return this.db.account .query() .select(ACCOUNT_COLUMNS) .findOne({ uid: uidBuffer }); } @Features(AdminPanelFeature.AccountSearch) @Query((returns) => AccountType, { nullable: true }) @SentryTraced('accountByEmail') public async accountByEmail( @Args('email', { nullable: false }) email: string, @Args('autoCompleted', { nullable: false }) autoCompleted: boolean, @CurrentUser() user: string ) { this.eventLogging.onAccountSearch('email', autoCompleted); this.log.info('accountByEmail', { email, user }); // Always prefer looks up using the known emails table let account = await this.db.account .query() .select(ACCOUNT_COLUMNS.map((c) => 'accounts.' + c)) .innerJoin('emails', 'emails.uid', 'accounts.uid') .where('emails.normalizedEmail', email.toLocaleLowerCase()) .first(); // fallback to the accounts.normalized email table if (!account) { account = await this.db.account .query() .select(ACCOUNT_COLUMNS.map((c) => 'accounts.' + c)) .where('accounts.normalizedEmail', email.toLocaleLowerCase()) .first(); } return account; } @Features(AdminPanelFeature.AccountSearch) @Query((returns) => [EmailType], { nullable: true }) public getEmailsLike(@Args('search', { nullable: false }) search: string) { return this.db.emails .query() .select(EMAIL_COLUMNS) .where('normalizedEmail', 'like', `${search.toLowerCase()}%`) .limit(10); } // unverifies the user's email. will have to verify again on next login @Features(AdminPanelFeature.UnverifyEmail) @Mutation((returns) => Boolean) public async unverifyEmail(@Args('email') email: string) { this.eventLogging.onEvent(EventNames.UnverifyEmail); const result = await this.db.emails .query() .where('normalizedEmail', 'like', `${email.toLowerCase()}%`) .update({ isVerified: false, verifiedAt: null as any, // same as null }); return !!result; } @Features(AdminPanelFeature.DisableAccount) @Mutation((returns) => Boolean) public async disableAccount(@Args('uid') uid: string) { this.eventLogging.onEvent(EventNames.DisableLogin); await this.profileClient.deleteCache(uid); await this.notifier.send({ event: 'profileDataChange', data: { ts: Date.now() / 1000, uid, }, }); const uidBuffer = uuidTransformer.to(uid); const result = await this.db.account .query() .update({ disabledAt: Date.now() }) .where('uid', uidBuffer); return !!result; } @Features(AdminPanelFeature.EditLocale) @Mutation((returns) => Boolean) public async editLocale( @Args('uid') uid: string, @Args('locale') locale: string ) { this.eventLogging.onEvent(EventNames.EditLocale); const uidBuffer = uuidTransformer.to(uid); const result = await this.db.account .query() .update({ locale: locale }) .where('uid', uidBuffer); await this.profileClient.deleteCache(uid); await this.notifier.send({ event: 'profileDataChange', data: { ts: Date.now() / 1000, uid, }, }); return !!result; } @Features(AdminPanelFeature.EnableAccount) @Mutation((returns) => Boolean) public async enableAccount(@Args('uid') uid: string) { const uidBuffer = uuidTransformer.to(uid); const result = await this.db.account .query() .update({ disabledAt: null } as any) .where('uid', uidBuffer); await this.profileClient.deleteCache(uid); await this.notifier.send({ event: 'profileDataChange', data: { ts: Date.now() / 1000, uid, }, }); return !!result; } @Features(AdminPanelFeature.SendPasswordResetEmail) @Mutation((returns) => Boolean) public async sendPasswordResetEmail(@Args('email') email: string) { const result = await this.authAPI.passwordForgotSendCode(email); return !!result; } @Features(AdminPanelFeature.AccountSearch) @Mutation((returns) => Boolean) public async recordAdminSecurityEvent( @Args('uid') uid: string, @Args('name', { type: () => String }) name: SecurityEventNames ) { // the ipAddr and ipHmacKey values here are required, but also have no bearing on this type of record. // the securityEvents table is being repurposed to store a broader variety of events, hence the dummy values. const result = await this.db.securityEvents.create({ uid, name, ipAddr: '', ipHmacKey: this.ipHmacKey, }); return !!result; } @ResolveField() public async emailBounces(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); // MySQL Query optimizer does weird things, use separate queries to force index use const emails = await this.db.emails .query() .select('emails.normalizedEmail') .where('emails.uid', uidBuffer); const result = await this.db.emailBounces .query() .select(...EMAIL_BOUNCE_COLUMNS, 'emailTypes.emailType as templateName') .join('emailTypes', 'emailTypes.id', 'emailBounces.emailTypeId') .where( 'emailBounces.email', 'in', emails.map((x) => x.normalizedEmail) ); return result; } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async emails(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); return await this.db.emails .query() .select(EMAIL_COLUMNS) .where('uid', uidBuffer); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async securityEvents(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); return await this.db.securityEvents .query() .select(...SECURITY_EVENTS_COLUMNS, 'securityEventNames.name as name') .join( 'securityEventNames', 'securityEvents.nameId', 'securityEventNames.id' ) .where('uid', uidBuffer) .limit(10) .orderBy('createdAt', 'DESC'); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async accountEvents(@Root() account: Account) { // Not sure the best way for admin panel to get this config from event broker config const eventsDbRef = this.firestore.collection('fxa-eb-users'); const queryResult = await eventsDbRef .doc(account.uid) .collection('events') .orderBy('createdAt', 'desc') .limit(100) .get(); return queryResult.docs.map((doc) => doc.data() as AccountEventType); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async totp(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); return await this.db.totp .query() .select(TOTP_COLUMNS) .where('uid', uidBuffer); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async recoveryKeys(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); return await this.db.recoveryKeys .query() .select(RECOVERYKEY_COLUMNS) .where('uid', uidBuffer); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async subscriptions(@Root() account: Account) { return await this.subscriptionsService.getSubscriptions(account.uid); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async carts(@Root() account: Account) { return await this.cartManager.fetchCartsByUid(account.uid); } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async backupCodes(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); const result = await this.db.recoveryCodes .query() .where('uid', uidBuffer) .resultSize(); return [ { hasBackupCodes: result > 0, count: result, }, ]; } @Features(AdminPanelFeature.AccountSearch) @ResolveField() public async recoveryPhone(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); const result = await this.db.recoveryPhones .query() .select(RECOVERYPHONES_COLUMNS) .where('uid', uidBuffer); return [ { exists: result.length > 0, lastFourDigits: result[0] && result[0].phoneNumber ? result[0].phoneNumber.slice(-4) : undefined, }, ]; } @ResolveField() public async linkedAccounts(@Root() account: Account) { const uidBuffer = uuidTransformer.to(account.uid); return await this.db.linkedAccounts .query() .select(LINKEDACCOUNT_COLUMNS) .where('uid', uidBuffer); } @Features(AdminPanelFeature.ConnectedServices) @ResolveField(() => [AttachedClient]) public async attachedClients(@Root() account: Account) { const clientFormatter = new ClientFormatter( this.clientFormatterConfig, () => this.log ); const factory = new ConnectedServicesFactory({ formatLocation: (...args) => { clientFormatter.formatLocation(...args); }, formatTimestamps: (...args) => { clientFormatter.formatTimestamps(...args); }, deviceList: async () => { return this.db.attachedDevices(account.uid); }, oauthClients: async () => { return await this.db.authorizedClients(account.uid); }, sessions: async () => { return (await this.db.attachedSessions(account.uid)).map( (x: SessionToken) => { const token = x; // Require id is defined if (!x.id) { x.id = 'Unknown'; } return token as AttachedSession; } ); }, }); return (await factory.build('', 'en')) .sort((a, b) => (b.lastAccessTime || 0) - (a.lastAccessTime || 0)) .map((x) => { if (x.sessionTokenId) x.sessionTokenId = '[REDACTED]'; if (x.refreshTokenId) x.refreshTokenId = '[REDACTED]'; return x; }); } @Features(AdminPanelFeature.UnlinkAccount) @Mutation((returns) => Boolean) public async unlinkAccount( @Args('uid') uid: string, @CurrentUser() user: string ) { const result = await this.db.linkedAccounts .query() .delete() .where({ uid: uuidTransformer.to(uid), providerId: 1, }); return !!result; } @Features(AdminPanelFeature.UnsubscribeFromMailingLists) @Mutation((returns) => Boolean) public async unsubscribeFromMailingLists(@Args('uid') uid: string) { // Look up email. This end point is protected, but using a uid would makes it harder // to abuse regardless. const account = await this.db.account .query() .select('email') .where({ uid: uuidTransformer.to(uid) }) .first(); if (!account) { return false; } // Look up user token const token = await this.basketService.getUserToken(account.email); if (!token) { return false; } // Request that user is unsubscribed from mailing list const success = await this.basketService.unsubscribeAll(token); // Record an event if action was successful if (success) { this.eventLogging.onEvent(EventNames.UnsubscribeFromMailingLists); } return success; } @Features(AdminPanelFeature.DeleteAccount) @Query((returns) => [AccountDeleteTaskStatus]) public async getDeleteStatus( @Args('taskNames', { type: () => [String] }) taskNames: string[] ) { const results = []; for (const taskName of taskNames) { try { const [result] = await this.cloudTask.accountTasks.getTaskStatus(taskName); if (result == null) { results.push({ taskName, status: 'Unknown task', }); } else { results.push({ taskName, status: result.lastAttempt?.responseStatus?.message || 'Pending', }); } } catch (error) { this.log.warn('getDeleteStatus', { errorCode: error.code }); if (error.code === 9) { results.push({ taskName, status: 'Task completed.', }); } else { results.push({ taskName, status: 'Task completed and no longer in queue.', }); } } } return results; } @Features(AdminPanelFeature.DeleteAccount) @Mutation((returns) => [AccountDeleteResponse]) public async deleteAccounts( @Args('locators', { type: () => [String] }) locators: string[], @CurrentUser() user: string ) { this.eventLogging.onEvent('deleteAccounts'); if (locators.length > 1000) { throw new Error('Provide less than 1000 account locators.'); } /** Helper function to query account and create cloud task */ const createTask = async ( locator: string ): Promise<AccountDeleteResponse> => { // Important! Log this action for historical record this.log.info('deleteAccounts', { locator, user }); let account: Account | undefined; if (/@/.test(locator)) { account = await this.db.account .query() .select(ACCOUNT_COLUMNS.map((c) => 'accounts.' + c)) .innerJoin('emails', 'emails.uid', 'accounts.uid') .where('emails.normalizedEmail', locator.toLocaleLowerCase()) .first(); } else if (/.*/.test(locator)) { let uidBuffer; try { uidBuffer = uuidTransformer.to(locator); } catch (err) { return { taskName: '', locator, status: AccountDeleteStatus.NoAccount, }; } account = await this.db.account .query() .select(ACCOUNT_COLUMNS) .findOne({ uid: uidBuffer }); } if (!account) { return { taskName: '', locator, status: AccountDeleteStatus.NoAccount, }; } // Locate stripe customer const { stripeCustomerId } = (await getAccountCustomerByUid(account.uid)) || {}; const taskName = await this.cloudTask.accountTasks.deleteAccount({ uid: account.uid, customerId: stripeCustomerId, reason: ReasonForDeletion.AdminRequested, }); if (taskName) { return { taskName, locator, status: AccountDeleteStatus.Success, }; } return { taskName: '', locator, status: AccountDeleteStatus.Failure, }; }; const promises = []; for (const locator of locators) { promises.push(createTask(locator)); } const result = await Promise.all(promises); return result; } }