mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[PM-11941] Migrate TOTP Generator to use SDK (#12987)
* Refactored totp service to use sdk Fixed strict typescript issues * Fixed dependency issues * Returned object that contains code and period, removed get interval function * removed dependencies * Updated to use refactored totp service * removed sdk service undefined check * removed undefined as an input from the getCode function * Made getcode$ an observable * refactored to use getcodee$ * Filter out emmissions * updated sdk version * Fixed readability nit * log error on overlay if totp response does not return a code * fix(totpGeneration): [PM-11941] Totp countdown not working on clients * Used optional chaining if totpresponse returns null or undefined
This commit is contained in:
@@ -190,7 +190,9 @@ describe("OverlayBackground", () => {
|
||||
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
||||
themeStateService = mock<ThemeStateService>();
|
||||
themeStateService.selectedTheme$ = selectedThemeMock$;
|
||||
totpService = mock<TotpService>();
|
||||
totpService = mock<TotpService>({
|
||||
getCode$: jest.fn().mockReturnValue(of(undefined)),
|
||||
});
|
||||
overlayBackground = new OverlayBackground(
|
||||
logService,
|
||||
cipherService,
|
||||
|
||||
@@ -707,13 +707,15 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
};
|
||||
|
||||
if (cipher.type === CipherType.Login) {
|
||||
const totpCode = await this.totpService.getCode(cipher.login?.totp);
|
||||
const totpCodeTimeInterval = this.totpService.getTimeInterval(cipher.login?.totp);
|
||||
const totpResponse = cipher.login?.totp
|
||||
? await firstValueFrom(this.totpService.getCode$(cipher.login.totp))
|
||||
: undefined;
|
||||
|
||||
inlineMenuData.login = {
|
||||
username: cipher.login.username,
|
||||
totp: totpCode,
|
||||
totp: totpResponse?.code,
|
||||
totpField: this.isTotpFieldForCurrentField(),
|
||||
totpCodeTimeInterval: totpCodeTimeInterval,
|
||||
totpCodeTimeInterval: totpResponse?.period,
|
||||
passkey: hasPasskey
|
||||
? {
|
||||
rpName: cipher.login.fido2Credentials[0].rpName,
|
||||
@@ -1131,9 +1133,13 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher);
|
||||
|
||||
if (cipher.login?.totp) {
|
||||
this.platformUtilsService.copyToClipboard(
|
||||
await this.totpService.getCode(cipher.login.totp),
|
||||
);
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
|
||||
|
||||
if (totpResponse?.code) {
|
||||
this.platformUtilsService.copyToClipboard(totpResponse.code);
|
||||
} else {
|
||||
this.logService.error("Failed to get TOTP code for inline menu cipher");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
@@ -159,19 +160,25 @@ describe("ContextMenuClickedHandler", () => {
|
||||
it("copies totp code to clipboard", async () => {
|
||||
cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]);
|
||||
|
||||
totpService.getCode.mockImplementation((seed) => {
|
||||
jest.spyOn(totpService, "getCode$").mockImplementation((seed: string) => {
|
||||
if (seed === "TEST_TOTP_SEED") {
|
||||
return Promise.resolve("123456");
|
||||
return of({
|
||||
code: "123456",
|
||||
period: 30,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve("654321");
|
||||
return of({
|
||||
code: "654321",
|
||||
period: 30,
|
||||
});
|
||||
});
|
||||
|
||||
await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), {
|
||||
url: "https://test.com",
|
||||
} as any);
|
||||
|
||||
expect(totpService.getCode).toHaveBeenCalledTimes(1);
|
||||
expect(totpService.getCode$).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith({
|
||||
text: "123456",
|
||||
|
||||
@@ -205,8 +205,9 @@ export class ContextMenuClickedHandler {
|
||||
action: COPY_VERIFICATION_CODE_ID,
|
||||
});
|
||||
} else {
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
|
||||
this.copyToClipboard({
|
||||
text: await this.totpService.getCode(cipher.login.totp),
|
||||
text: totpResponse.code,
|
||||
tab: tab,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -921,12 +921,12 @@ describe("AutofillService", () => {
|
||||
.spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$")
|
||||
.mockImplementation(() => of(true));
|
||||
jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true);
|
||||
jest.spyOn(totpService, "getCode").mockResolvedValue(totpCode);
|
||||
totpService.getCode$.mockReturnValue(of({ code: totpCode, period: 30 }));
|
||||
|
||||
const autofillResult = await autofillService.doAutoFill(autofillOptions);
|
||||
|
||||
expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled();
|
||||
expect(totpService.getCode).toHaveBeenCalledWith(autofillOptions.cipher.login.totp);
|
||||
expect(totpService.getCode$).toHaveBeenCalledWith(autofillOptions.cipher.login.totp);
|
||||
expect(autofillResult).toBe(totpCode);
|
||||
});
|
||||
|
||||
@@ -940,7 +940,7 @@ describe("AutofillService", () => {
|
||||
const autofillResult = await autofillService.doAutoFill(autofillOptions);
|
||||
|
||||
expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled();
|
||||
expect(totpService.getCode).not.toHaveBeenCalled();
|
||||
expect(totpService.getCode$).not.toHaveBeenCalled();
|
||||
expect(autofillResult).toBeNull();
|
||||
});
|
||||
|
||||
@@ -956,12 +956,12 @@ describe("AutofillService", () => {
|
||||
it("returns a null value if the login does not contain a TOTP value", async () => {
|
||||
autofillOptions.cipher.login.totp = undefined;
|
||||
jest.spyOn(autofillService, "getShouldAutoCopyTotp");
|
||||
jest.spyOn(totpService, "getCode");
|
||||
jest.spyOn(totpService, "getCode$");
|
||||
|
||||
const autofillResult = await autofillService.doAutoFill(autofillOptions);
|
||||
|
||||
expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled();
|
||||
expect(totpService.getCode).not.toHaveBeenCalled();
|
||||
expect(totpService.getCode$).not.toHaveBeenCalled();
|
||||
expect(autofillResult).toBeNull();
|
||||
});
|
||||
|
||||
@@ -984,12 +984,12 @@ describe("AutofillService", () => {
|
||||
.spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$")
|
||||
.mockImplementation(() => of(true));
|
||||
jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false);
|
||||
jest.spyOn(totpService, "getCode");
|
||||
jest.spyOn(totpService, "getCode$");
|
||||
|
||||
const autofillResult = await autofillService.doAutoFill(autofillOptions);
|
||||
|
||||
expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled();
|
||||
expect(totpService.getCode).not.toHaveBeenCalled();
|
||||
expect(totpService.getCode$).not.toHaveBeenCalled();
|
||||
expect(autofillResult).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -494,7 +494,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
const shouldAutoCopyTotp = await this.getShouldAutoCopyTotp();
|
||||
|
||||
totp = shouldAutoCopyTotp
|
||||
? await this.totpService.getCode(options.cipher.login.totp)
|
||||
? (await firstValueFrom(this.totpService.getCode$(options.cipher.login.totp))).code
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
@@ -992,7 +992,10 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
|
||||
filledFields[t.opid] = t;
|
||||
let totpValue = await this.totpService.getCode(login.totp);
|
||||
const totpResponse = await firstValueFrom(
|
||||
this.totpService.getCode$(options.cipher.login.totp),
|
||||
);
|
||||
let totpValue = totpResponse.code;
|
||||
if (totpValue.length == totps.length) {
|
||||
totpValue = totpValue.charAt(i);
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@ export default class MainBackground {
|
||||
this.authService,
|
||||
this.accountService,
|
||||
);
|
||||
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
|
||||
this.totpService = new TotpService(this.sdkService);
|
||||
|
||||
this.scriptInjectorService = new BrowserScriptInjectorService(
|
||||
this.domainSettingsService,
|
||||
|
||||
@@ -84,6 +84,7 @@ import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/comm
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
@@ -285,7 +286,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: TotpServiceAbstraction,
|
||||
useClass: TotpService,
|
||||
deps: [CryptoFunctionService, LogService],
|
||||
deps: [SdkService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OffscreenDocumentService,
|
||||
|
||||
@@ -254,8 +254,8 @@ export class GetCommand extends DownloadCommand {
|
||||
return Response.error("No TOTP available for this login.");
|
||||
}
|
||||
|
||||
const totp = await this.totpService.getCode(cipher.login.totp);
|
||||
if (totp == null) {
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
|
||||
if (!totpResponse.code) {
|
||||
return Response.error("Couldn't generate TOTP code.");
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ export class GetCommand extends DownloadCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const res = new StringResponse(totp);
|
||||
const res = new StringResponse(totpResponse.code);
|
||||
return Response.success(res);
|
||||
}
|
||||
|
||||
|
||||
@@ -766,7 +766,7 @@ export class ServiceContainer {
|
||||
this.stateProvider,
|
||||
);
|
||||
|
||||
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
|
||||
this.totpService = new TotpService(this.sdkService);
|
||||
|
||||
this.importApiService = new ImportApiService(this.apiService);
|
||||
|
||||
|
||||
@@ -208,8 +208,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
tCipher.login.hasTotp &&
|
||||
this.userHasPremiumAccess
|
||||
) {
|
||||
const value = await this.totpService.getCode(tCipher.login.totp);
|
||||
this.copyValue(tCipher, value, "verificationCodeTotp", "TOTP");
|
||||
const value = await firstValueFrom(this.totpService.getCode$(tCipher.login.totp));
|
||||
this.copyValue(tCipher, value.code, "verificationCodeTotp", "TOTP");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -382,8 +382,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyVerificationCodeTotp"),
|
||||
click: async () => {
|
||||
const value = await this.totpService.getCode(cipher.login.totp);
|
||||
this.copyValue(cipher, value, "verificationCodeTotp", "TOTP");
|
||||
const value = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
|
||||
this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,54 +128,57 @@
|
||||
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
||||
{{ fido2CredentialCreationDateValue }}
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-flex totp"
|
||||
[ngClass]="{ low: totpLow }"
|
||||
*ngIf="cipher.login.totp && totpCode"
|
||||
>
|
||||
<div class="row-main">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, totpCode)"
|
||||
>{{ "verificationCodeTotp" | i18n }}</span
|
||||
>
|
||||
<span class="totp-code">{{ totpCodeFormatted }}</span>
|
||||
</div>
|
||||
<span class="totp-countdown" aria-hidden="true">
|
||||
<span class="totp-sec">{{ totpSec }}</span>
|
||||
<svg>
|
||||
<g>
|
||||
<circle
|
||||
class="totp-circle inner"
|
||||
r="12.6"
|
||||
cy="16"
|
||||
cx="16"
|
||||
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }"
|
||||
></circle>
|
||||
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
title="{{ 'copyValue' | i18n }}"
|
||||
(click)="copy(totpCode, 'verificationCodeTotp', 'TOTP')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{ "copyValue" | i18n }}</span>
|
||||
|
||||
<ng-container *ngIf="cipher.login.totp && totpInfo$ | async as totpInfo">
|
||||
<div
|
||||
class="box-content-row box-content-row-flex totp"
|
||||
[ngClass]="{ low: totpInfo.totpLow }"
|
||||
>
|
||||
<div class="row-main">
|
||||
<span
|
||||
class="sr-only exists-only-on-parent-focus"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>{{ totpSec }}</span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, totpInfo.totpCode)"
|
||||
>{{ "verificationCodeTotp" | i18n }}</span
|
||||
>
|
||||
</button>
|
||||
<span class="totp-code">{{ totpInfo.totpCodeFormatted }}</span>
|
||||
</div>
|
||||
<span class="totp-countdown" aria-hidden="true">
|
||||
<span class="totp-sec">{{ totpInfo.totpSec }}</span>
|
||||
<svg>
|
||||
<g>
|
||||
<circle
|
||||
class="totp-circle inner"
|
||||
r="12.6"
|
||||
cy="16"
|
||||
cx="16"
|
||||
[ngStyle]="{ 'stroke-dashoffset.px': totpInfo.totpDash }"
|
||||
></circle>
|
||||
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
title="{{ 'copyValue' | i18n }}"
|
||||
(click)="copy(totpInfo.totpCode, 'verificationCodeTotp', 'TOTP')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{ "copyValue" | i18n }}</span>
|
||||
<span
|
||||
class="sr-only exists-only-on-parent-focus"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>{{ totpInfo.totpSec }}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="box-content-row box-content-row-flex totp" *ngIf="showPremiumRequiredTotp">
|
||||
<div class="row-main">
|
||||
<span class="row-label">{{ "verificationCodeTotp" | i18n }}</span>
|
||||
|
||||
@@ -1177,7 +1177,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
typeI18nKey = "password";
|
||||
} else if (field === "totp") {
|
||||
aType = "TOTP";
|
||||
value = await this.totpService.getCode(cipher.login.totp);
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
|
||||
value = totpResponse?.code;
|
||||
typeI18nKey = "verificationCodeTotp";
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -135,12 +135,15 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
|
||||
if (this.showTotp()) {
|
||||
await this.totpUpdateCode();
|
||||
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
|
||||
await this.totpTick(interval);
|
||||
|
||||
this.totpInterval = window.setInterval(async () => {
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp));
|
||||
if (totpResponse) {
|
||||
const interval = totpResponse.period;
|
||||
await this.totpTick(interval);
|
||||
}, 1000);
|
||||
|
||||
this.totpInterval = window.setInterval(async () => {
|
||||
await this.totpTick(interval);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
this.cardIsExpired = isCardExpired(this.cipher.card);
|
||||
@@ -273,7 +276,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
return;
|
||||
}
|
||||
|
||||
this.totpCode = await this.totpService.getCode(this.cipher.login.totp);
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp));
|
||||
this.totpCode = totpResponse?.code;
|
||||
if (this.totpCode != null) {
|
||||
if (this.totpCode.length > 4) {
|
||||
const half = Math.floor(this.totpCode.length / 2);
|
||||
|
||||
@@ -1146,7 +1146,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
typeI18nKey = "password";
|
||||
} else if (field === "totp") {
|
||||
aType = "TOTP";
|
||||
value = await this.totpService.getCode(cipher.login.totp);
|
||||
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
|
||||
value = totpResponse.code;
|
||||
typeI18nKey = "verificationCodeTotp";
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
|
||||
Reference in New Issue
Block a user