зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
e18d2bd8dc
Коммит
17edcd473d
|
@ -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);
|
||||
|
|
|
@ -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 }>;
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче