feat(gql-api): Add totp/recovery code mutations.

Because:

* The gql-api needs the ability to issue new recovery codes and
  update the users two-factor auth settings.

This commit:

* Add's two-factor auth and recovery code mutations.

Closes #5395
This commit is contained in:
Ben Bangert 2020-06-08 14:09:49 -07:00
Родитель e18d2bd8dc
Коммит 17edcd473d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 340D6D716D25CCA6
14 изменённых файлов: 418 добавлений и 2 удалений

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

@ -2,7 +2,7 @@
* 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 { DataSource, DataSourceConfig } from 'apollo-datasource';
import { Client } from 'fxa-js-client';
import { Client, MetricContext } from 'fxa-js-client';
import { Container } from 'typedi';
import { fxAccountClientToken } from '../constants';
@ -49,6 +49,16 @@ export class AuthServerSource extends DataSource {
return this.authClient.attachedClientDestroy(this.token, clientInfo);
}
public createTotpToken(
options: MetricContext
): ReturnType<Client['createTotpToken']> {
return this.authClient.createTotpToken(this.token, options);
}
public destroyTotpToken(): Promise<any> {
return this.authClient.deleteTotpToken(this.token);
}
public totp(): Promise<any> {
return this.authClient.checkTotpTokenExists(this.token);
}
@ -73,4 +83,12 @@ export class AuthServerSource extends DataSource {
public recoveryEmailSecondaryResendCode(email: string): Promise<any> {
return this.authClient.recoveryEmailSecondaryResendCode(this.token, email);
}
public replaceRecoveryCodes() {
return this.authClient.replaceRecoveryCodes(this.token);
}
public verifyTotp(code: string, options?: { service: string }) {
return this.authClient.verifyTotpCode(this.token, code, options);
}
}

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

@ -24,19 +24,96 @@ import { CatchGatewayError } from '../error';
import { Context } from '../server';
import { Account as AccountType } from './types/account';
import {
AttachedClientDisconnectInput,
ChangeRecoveryCodesInput,
CreateTotpInput,
DeleteTotpInput,
EmailInput,
UpdateAvatarInput,
UpdateDisplayNameInput,
VerifyTotpInput,
} from './types/input';
import {
BasicPayload,
ChangeRecoveryCodesPayload,
CreateTotpPayload,
UpdateAvatarPayload,
UpdateDisplayNamePayload,
VerifyTotpPayload,
} from './types/payload';
import { AttachedClientDisconnectInput } from './types/input/attached-client-disconnect';
@Resolver((of) => AccountType)
export class AccountResolver {
@Mutation((returns) => CreateTotpPayload, {
description:
'Create a new randomly generated TOTP token for a user if they do not currently have one.',
})
@CatchGatewayError
public async createTotp(
@Ctx() context: Context,
@Arg('input', (type) => CreateTotpInput)
input: CreateTotpInput
): Promise<CreateTotpPayload> {
const result = await context.dataSources.authAPI.createTotpToken(
input.metricsContext
);
return {
clientMutationId: input.clientMutationId,
...result,
};
}
@Mutation((returns) => VerifyTotpPayload, {
description:
'Verifies the current session if the passed TOTP code is valid.',
})
@CatchGatewayError
public async verifyTotp(
@Ctx() context: Context,
@Arg('input', (type) => VerifyTotpInput)
input: VerifyTotpInput
): Promise<VerifyTotpPayload> {
const result = await context.dataSources.authAPI.verifyTotp(
input.code,
input.service ? { service: input.service } : undefined
);
return {
clientMutationId: input.clientMutationId,
...result,
};
}
@Mutation((returns) => BasicPayload, {
description: 'Deletes the current TOTP token for the user.',
})
@CatchGatewayError
public async deleteTotp(
@Ctx() context: Context,
@Arg('input', (type) => DeleteTotpInput)
input: DeleteTotpInput
): Promise<BasicPayload> {
await context.dataSources.authAPI.destroyTotpToken();
return {
clientMutationId: input.clientMutationId,
};
}
@Mutation((returns) => ChangeRecoveryCodesPayload, {
description: 'Return new recovery codes while removing old ones.',
})
@CatchGatewayError
public async changeRecoveryCodes(
@Ctx() context: Context,
@Arg('input', (type) => ChangeRecoveryCodesInput)
input: ChangeRecoveryCodesInput
): Promise<ChangeRecoveryCodesPayload> {
const result = await context.dataSources.authAPI.replaceRecoveryCodes();
return {
clientMutationId: input.clientMutationId,
recoveryCodes: result.recoveryCodes,
};
}
@Mutation((returns) => UpdateDisplayNamePayload, {
description: 'Update the display name.',
})

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

@ -0,0 +1,13 @@
/* 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 { Field, InputType } from 'type-graphql';
@InputType()
export class ChangeRecoveryCodesInput {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
}

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

@ -0,0 +1,18 @@
/* 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 { Field, InputType } from 'type-graphql';
import { MetricsContext } from './metrics-context';
@InputType()
export class CreateTotpInput {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
@Field((type) => MetricsContext, { nullable: true })
public metricsContext!: MetricsContext;
}

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

@ -0,0 +1,13 @@
/* 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 { Field, InputType } from 'type-graphql';
@InputType()
export class DeleteTotpInput {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
}

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

@ -4,3 +4,8 @@
export { EmailInput } from './email';
export { UpdateAvatarInput } from './update-avatar';
export { UpdateDisplayNameInput } from './update-display-name';
export { CreateTotpInput } from './create-totp';
export { AttachedClientDisconnectInput } from './attached-client-disconnect';
export { ChangeRecoveryCodesInput } from './change-recovery-codes';
export { DeleteTotpInput } from './delete-totp';
export { VerifyTotpInput } from './verify-totp';

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

@ -0,0 +1,50 @@
/* 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 { Field, InputType } from 'type-graphql';
@InputType({ description: 'Metrics context.' })
export class MetricsContext {
@Field({
description:
"The id of the client's device record, if it has registered one.",
nullable: true,
})
public deviceId?: string;
@Field({ nullable: true })
public entrypoint?: string;
@Field({ nullable: true })
public entrypointExperiment?: string;
@Field({ nullable: true })
public entrypointVariation?: string;
@Field({ nullable: true })
public flowId?: string;
@Field({ nullable: true })
public flowBeginTime?: number;
@Field({ nullable: true })
public productId?: string;
@Field({ nullable: true })
public planId?: string;
@Field({ nullable: true })
public utmCampaign?: number;
@Field({ nullable: true })
public utmContent?: number;
@Field({ nullable: true })
public utmMedium?: number;
@Field({ nullable: true })
public utmSource?: number;
@Field({ nullable: true })
public utmTerm?: number;
}

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

@ -0,0 +1,19 @@
/* 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 { Field, InputType } from 'type-graphql';
@InputType()
export class VerifyTotpInput {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
@Field({ description: 'The TOTP code to check' })
public code!: string;
@Field({ nullable: true })
public service?: string;
}

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

@ -0,0 +1,16 @@
/* 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 { Field, ObjectType } from 'type-graphql';
@ObjectType()
export class ChangeRecoveryCodesPayload {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
@Field((returns) => [String])
public recoveryCodes!: string[];
}

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

@ -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/. */
import { Field, ObjectType } from 'type-graphql';
@ObjectType()
export class CreateTotpPayload {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
@Field()
public qrCodeUrl!: string;
@Field()
public secret!: string;
@Field((returns) => [String])
public recoveryCodes!: string[];
}

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

@ -4,3 +4,6 @@
export { BasicPayload } from './basic';
export { UpdateAvatarPayload } from './update-avatar';
export { UpdateDisplayNamePayload } from './update-display-name';
export { ChangeRecoveryCodesPayload } from './change-recovery-codes';
export { CreateTotpPayload } from './create-totp';
export { VerifyTotpPayload } from './verify-totp';

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

@ -0,0 +1,19 @@
/* 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 { Field, ObjectType } from 'type-graphql';
@ObjectType()
export class VerifyTotpPayload {
@Field({
description: 'A unique identifier for the client performing the mutation.',
nullable: true,
})
public clientMutationId?: string;
@Field()
public success!: boolean;
@Field((returns) => [String], { nullable: true })
public recoveryCodes?: string[];
}

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

@ -170,6 +170,119 @@ describe('accountResolver', () => {
});
});
describe('createTotp', async () => {
it('succeeds', async () => {
context.dataSources.authAPI.createTotpToken.resolves({
qrCodeUrl: 'testurl',
recoveryCodes: ['test1', 'test2'],
secret: 'secretData',
});
const query = `mutation {
createTotp(input: {clientMutationId: "testid", metricsContext: {
deviceId: "device1",
flowBeginTime: 4238248
}}) {
clientMutationId
qrCodeUrl
secret
recoveryCodes
}
}`;
context.authUser = USER_1.uid;
const result = (await graphql(
schema,
query,
undefined,
context
)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.createTotp);
assert.deepEqual(result.data.createTotp, {
clientMutationId: 'testid',
qrCodeUrl: 'testurl',
recoveryCodes: ['test1', 'test2'],
secret: 'secretData',
});
});
});
describe('changeRecoveryCodes', async () => {
it('succeeds', async () => {
context.dataSources.authAPI.replaceRecoveryCodes.resolves({
recoveryCodes: ['test1', 'test2'],
});
const query = `mutation {
changeRecoveryCodes(input: {clientMutationId: "testid"}) {
clientMutationId
recoveryCodes
}
}`;
context.authUser = USER_1.uid;
const result = (await graphql(
schema,
query,
undefined,
context
)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.changeRecoveryCodes);
assert.deepEqual(result.data.changeRecoveryCodes, {
clientMutationId: 'testid',
recoveryCodes: ['test1', 'test2'],
});
});
});
describe('verifyTotp', async () => {
it('succeeds', async () => {
context.dataSources.authAPI.verifyTotp.resolves({
success: true,
});
const query = `mutation {
verifyTotp(input: {clientMutationId: "testid", code: "code1234"}) {
clientMutationId
success
}
}`;
context.authUser = USER_1.uid;
const result = (await graphql(
schema,
query,
undefined,
context
)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.verifyTotp);
assert.deepEqual(result.data.verifyTotp, {
clientMutationId: 'testid',
success: true,
});
});
});
describe('deleteTotp', async () => {
it('succeeds', async () => {
context.dataSources.authAPI.destroyTotpToken.resolves(true);
const query = `mutation {
deleteTotp(input: {clientMutationId: "testid"}) {
clientMutationId
}
}`;
context.authUser = USER_1.uid;
const result = (await graphql(
schema,
query,
undefined,
context
)) as any;
assert.isDefined(result.data);
assert.isDefined(result.data.deleteTotp);
assert.deepEqual(result.data.deleteTotp, {
clientMutationId: 'testid',
});
});
});
describe('deleteSecondaryEmail', async () => {
it('succeeds', async () => {
context.dataSources.authAPI.recoveryEmailDestroy.resolves(true);

29
types/fxa-js-client/index.d.ts поставляемый
Просмотреть файл

@ -8,6 +8,22 @@ declare function FxAccountClient(
): FxAccountClient.Client;
declare namespace FxAccountClient {
export type MetricContext = {
deviceId?: string;
entrypoint?: string;
entrypointExperiment?: string;
entrypointVariation?: string;
flowId?: string;
flowBeginTime?: number;
productId?: string;
planId?: string;
utmCampaign?: number;
utmContent?: number;
utmMedium?: number;
utmSource?: number;
utmTerm?: number;
};
export interface Client {
sessionStatus(
sessionToken: string
@ -36,6 +52,11 @@ declare namespace FxAccountClient {
token_type: string;
expires_in: number;
}>;
createTotpToken(
sessionToken: string,
metricOptions: MetricContext
): Promise<{ qrCodeUrl: string; secret: string; recoveryCodes: string[] }>;
deleteTotpToken(sessionToken: string): Promise<any>;
recoveryEmailCreate(sessionToken: string, email: string): Promise<any>;
recoveryEmailDestroy(sessionToken: string, email: string): Promise<any>;
recoveryEmailSetPrimaryEmail(
@ -46,5 +67,13 @@ declare namespace FxAccountClient {
sessionToken: string,
email: string
): Promise<any>;
replaceRecoveryCodes(
sessionToken: string
): Promise<{ recoveryCodes: string[] }>;
verifyTotpCode(
sessionToken: string,
code: string,
options?: { service: string }
): Promise<{ success: boolean }>;
}
}