Skip to content

Commit

Permalink
feat(auth): Add route and gql resolver to retrieve number of remainin…
Browse files Browse the repository at this point in the history
…g backup codes

Because:

* We want to hook up the BackupCodeManager to the front end

This commit:

* Add new /recoveryCodes/exists route
* Add new handler method that uses the BackupCodeManager to retrieve the count of remaining codes
* Add backup codes to account resolver
* Update fxa-settings account model to include backup codes count

Closes #FXA-10231
  • Loading branch information
vpomerleau committed Nov 12, 2024
1 parent 4fef7bb commit f911110
Show file tree
Hide file tree
Showing 15 changed files with 152 additions and 2 deletions.
1 change: 1 addition & 0 deletions libs/accounts/two-factor/src/lib/backup-code.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AccountDatabase } from '@fxa/shared/db/mysql/account';

export async function getRecoveryCodes(db: AccountDatabase, uid: Buffer) {
return await db
// TODO in FXA-10231 fix TypeError: db.selectFrom is not a function when called from auth-client
.selectFrom('recoveryCodes')
.where('uid', '=', uid)
.selectAll()
Expand Down
7 changes: 7 additions & 0 deletions packages/fxa-auth-client/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,13 @@ export default class AuthClient {
);
}

async getRecoveryCodesExist(
sessionToken: hexstring,
headers?: Headers
): Promise<{ hasBackupCodes: boolean; count: number }> {
return this.sessionGet('/recoveryCodes/exists', sessionToken, headers);
}

async consumeRecoveryCode(
sessionToken: hexstring,
code: string,
Expand Down
50 changes: 50 additions & 0 deletions packages/fxa-auth-server/lib/routes/recovery-codes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,32 @@

'use strict';

import { AppConfig } from '../types';
const errors = require('../error');
const isA = require('joi');
const validators = require('./validators');
const { Container } = require('typedi');
const RECOVERY_CODES_DOCS =
require('../../docs/swagger/recovery-codes-api').default;
const { BackupCodeManager } = require('@fxa/accounts/two-factor');
import { setupAccountDatabase } from '@fxa/shared/db/mysql/account';

const RECOVERY_CODE_SANE_MAX_LENGTH = 20;

module.exports = (log, db, config, customs, mailer, glean) => {
const codeConfig = config.recoveryCodes;
const RECOVERY_CODE_COUNT = (codeConfig && codeConfig.count) || 8;

let backupCodeManager;
(async () => {
const appConfig = Container.get(AppConfig);
const kyselyDb = await setupAccountDatabase(appConfig.database.mysql.auth);
if (!Container.has(BackupCodeManager)) {
Container.set(BackupCodeManager, new BackupCodeManager(kyselyDb));
}
backupCodeManager = Container.get(BackupCodeManager);
})();

// Validate backup authentication codes
const recoveryCodesSchema = validators.recoveryCodes(
RECOVERY_CODE_COUNT,
Expand Down Expand Up @@ -122,6 +136,42 @@ module.exports = (log, db, config, customs, mailer, glean) => {
return { success: true };
},
},
{
method: 'GET',
path: '/recoveryCodes/exists',
options: {
auth: {
strategy: 'sessionToken',
payload: 'required',
},
response: {
schema: isA.object({
hasBackupCodes: isA.boolean(),
count: isA.number(),
}),
},
},
async handler(request) {
log.begin('checkRecoveryCodesExist', request);

const { email, uid } = request.auth.credentials;

await customs.check(
request,
email,
'checkRecoveryCodesExist'
);

const { hasBackupCodes, count } =
await backupCodeManager.getCountForUserId(uid);
log.info('account.recoveryCode.existsChecked', {
uid,
hasBackupCodes,
count,
});
return { hasBackupCodes, count };
},
},
{
method: 'POST',
path: '/session/verify/recoveryCode',
Expand Down
10 changes: 10 additions & 0 deletions packages/fxa-auth-server/test/client/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,16 @@ module.exports = (config) => {
});
};

ClientApi.prototype.getRecoveryCodesExist = function (sessionTokenHex) {
return tokens.SessionToken.fromHex(sessionTokenHex).then((token) => {
return this.doRequest(
'GET',
`${this.baseURL}/recoveryCodes/exists`,
token
);
});
};

ClientApi.prototype.consumeRecoveryCode = function (
sessionTokenHex,
code,
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-auth-server/test/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,10 @@ module.exports = (config) => {
return this.api.replaceRecoveryCodes(this.sessionToken, options);
};

Client.prototype.getRecoveryCodesExist = function () {
return this.api.getRecoveryCodesExist(this.sessionToken);
};

Client.prototype.consumeRecoveryCode = function (code, options = {}) {
return this.api.consumeRecoveryCode(this.sessionToken, code, options);
};
Expand Down
14 changes: 14 additions & 0 deletions packages/fxa-auth-server/test/local/routes/recovery-codes.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,20 @@ describe('backup authentication codes', () => {
});
});

describe('GET /recoveryCodes/exists', () => {
it('should return true if backup codes exist', () => {
// TODO in FXA-10231
});

it('should return false if backup codes do not exist', () => {
// TODO in FXA-10231
});

it('should return the remaining count of backup codes', () => {
// TODO in FXA-10231
});
});

describe('POST /session/verify/recoveryCode', () => {
it('sends email if backup authentication codes are low', async () => {
db.consumeRecoveryCode = sinon.spy((code) => {
Expand Down
1 change: 1 addition & 0 deletions packages/fxa-auth-server/test/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const DB_METHOD_NAMES = [
'emailBounces',
'emailRecord',
'forgotPasswordVerified',
'getRecoveryCodesExist',
'getRecoveryKey',
'getRecoveryKeyRecordWithHint',
'getSecondaryEmail',
Expand Down
8 changes: 8 additions & 0 deletions packages/fxa-graphql-api/src/gql/account.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,14 @@ export class AccountResolver {
return this.authAPI.checkTotpTokenExists(token, headers);
}

@ResolveField()
public backupCodes(
@GqlSessionToken() token: string,
@GqlXHeaders() headers: Headers
) {
return this.authAPI.getRecoveryCodesExist(token, headers);
}

@ResolveField()
public attachedClients(
@GqlSessionToken() token: string,
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-graphql-api/src/gql/model/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Totp } from './totp';
import { LinkedAccount } from './linkedAccount';
import { SecurityEvent } from './securityEvent';
import { RecoveryKey } from './recoveryKey';
import { BackupCodes } from './backupCodes';

@ObjectType({
description: "The current authenticated user's Firefox Account record.",
Expand Down Expand Up @@ -42,6 +43,9 @@ export class Account {
@Field((type) => Totp)
public totp!: Totp;

@Field((type) => BackupCodes)
public backupCodes!: BackupCodes;

@Field((type) => RecoveryKey, {
description: 'Whether the user has had an account recovery key issued.',
})
Expand Down
15 changes: 15 additions & 0 deletions packages/fxa-graphql-api/src/gql/model/backupCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* 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 '@nestjs/graphql';

@ObjectType({ description: 'Two-factor authentication backup codes.' })
export class BackupCodes {
@Field({ description: 'Whether backup codes exists for the user.' })
public hasBackupCodes!: boolean;

@Field({
description: 'The number of remaining backup codes the user has available.',
})
public count!: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import LinkExternal from 'fxa-react/components/LinkExternal';
import { useBooleanState } from 'fxa-react/lib/hooks';
import Modal from '../Modal';
Expand All @@ -23,6 +23,7 @@ export const UnitRowTwoStepAuth = () => {
const {
totp: { exists, verified },
} = account;
const count = account.backupCodes?.count;
const [modalRevealed, revealModal, hideModal] = useBooleanState();
const [secondaryModalRevealed, revealSecondaryModal, hideSecondaryModal] =
useBooleanState();
Expand Down Expand Up @@ -51,6 +52,15 @@ export const UnitRowTwoStepAuth = () => {
}
}, [account, hideModal, alertBar, l10n]);

console.log('Number of backup codes remaining: ', count);

// TODO in FXA-10206, remove this console log and use the count data for the backup codes subrow
useEffect(() => {
if (exists && verified) {
console.log('Number of backup codes remaining: ', count);
}
}, [count, exists, verified]);

const conditionalUnitRowProps =
exists && verified
? {
Expand Down
5 changes: 5 additions & 0 deletions packages/fxa-settings/src/lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ export interface AccountTotp {
exists: boolean;
verified: boolean;
}

export interface AccountBackupCodes {
hasBackupCodes: boolean;
count: number;
}
15 changes: 14 additions & 1 deletion packages/fxa-settings/src/models/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
GET_LOCAL_SIGNED_IN_STATUS,
GET_TOTP_STATUS,
} from '../components/App/gql';
import { AccountAvatar, AccountTotp } from '../lib/interfaces';
import {
AccountAvatar,
AccountBackupCodes,
AccountTotp,
} from '../lib/interfaces';
import { createSaltV2 } from 'fxa-auth-client/lib/salt';
import { getHandledError } from '../lib/error-utils';

Expand Down Expand Up @@ -108,6 +112,7 @@ export interface AccountData {
attachedClients: AttachedClient[];
linkedAccounts: LinkedAccount[];
totp: AccountTotp;
backupCodes: AccountBackupCodes;
subscriptions: Subscription[];
securityEvents: SecurityEvent[];
}
Expand Down Expand Up @@ -187,6 +192,10 @@ export const GET_ACCOUNT = gql`
exists
verified
}
backupCodes {
hasBackupCodes
count
}
subscriptions {
created
productName
Expand Down Expand Up @@ -390,6 +399,10 @@ export class Account implements AccountData {
return this.totp.exists && this.totp.verified;
}

get backupCodes() {
return this.data.backupCodes;
}

get attachedClients() {
return this.data.attachedClients;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-settings/src/models/contexts/AppContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export function defaultAppContext(context?: AppContextValue) {
exists: true,
verified: true,
},
backupCodes: {
hasBackupCodes: false,
count: 0,
},
linkedAccounts: [],
securityEvents: [],
};
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-settings/src/models/contexts/SettingsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export const INITIAL_SETTINGS_QUERY = gql`
exists
verified
}
backupCodes {
hasBackupCodes
count
}
subscriptions {
created
productName
Expand Down

0 comments on commit f911110

Please sign in to comment.