1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-20 21:01:29 +01:00

[EC-598] feat: fully refactored user interface

Now uses sessions instead of single request-response style communcation
This commit is contained in:
Andreas Coroiu 2023-04-05 16:17:40 +02:00
parent 11d340bc97
commit cd70b17b9a
No known key found for this signature in database
GPG Key ID: E70B5FFC81DFEC1A
4 changed files with 254 additions and 279 deletions

View File

@ -1,6 +1,17 @@
import { filter, first, lastValueFrom, Observable, Subject, takeUntil } from "rxjs";
import {
BehaviorSubject,
EmptyError,
filter,
firstValueFrom,
fromEvent,
Observable,
Subject,
take,
takeUntil,
} from "rxjs";
import { Utils } from "@bitwarden/common/misc/utils";
import { UserRequestedFallbackAbortReason } from "@bitwarden/common/webauthn/abstractions/fido2-client.service.abstraction";
import {
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
Fido2UserInterfaceSession,
@ -12,19 +23,26 @@ import { PopupUtilsService } from "../../popup/services/popup-utils.service";
const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
export class Fido2Error extends Error {
constructor(message: string, readonly fallbackRequested = false) {
super(message);
export class SessionClosedError extends Error {
constructor() {
super("Fido2UserInterfaceSession was closed");
}
}
export class RequestAbortedError extends Fido2Error {
constructor(fallbackRequested = false) {
super("Fido2 request was aborted", fallbackRequested);
}
}
export type BrowserFido2Message = { requestId: string } & (
export type BrowserFido2Message = { sessionId: string } & (
| /**
* This message is used by popouts to announce that they are ready
* to recieve messages.
**/ {
type: "ConnectResponse";
}
/**
* This message is used to announce the creation of a new session.
* It iss used by popouts to know when to close.
**/
| {
type: "NewSessionCreatedRequest";
}
| {
type: "PickCredentialRequest";
cipherIds: string[];
@ -66,228 +84,77 @@ export type BrowserFido2Message = { requestId: string } & (
}
);
export interface BrowserFido2UserInterfaceRequestData {
requestId: string;
}
export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
static sendMessage(msg: BrowserFido2Message) {
BrowserApi.sendMessage(BrowserFido2MessageName, msg);
}
static onAbort$(requestId: string): Observable<BrowserFido2Message> {
const messages$ = BrowserApi.messageListener$() as Observable<BrowserFido2Message>;
return messages$.pipe(
filter((message) => message.type === "AbortRequest" && message.requestId === requestId),
first()
);
}
private messages$ = BrowserApi.messageListener$() as Observable<BrowserFido2Message>;
private destroy$ = new Subject<void>();
constructor(private popupUtilsService: PopupUtilsService) {}
async newSession(abortController?: AbortController): Promise<Fido2UserInterfaceSession> {
return await BrowserFido2UserInterfaceSession.create(this, abortController);
}
async confirmCredential(
cipherId: string,
abortController = new AbortController()
): Promise<boolean> {
const requestId = Utils.newGuid();
const data: BrowserFido2Message = { type: "ConfirmCredentialRequest", cipherId, requestId };
const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString();
const abortHandler = () =>
BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId });
abortController.signal.addEventListener("abort", abortHandler);
this.popupUtilsService.popOut(
null,
`popup/index.html?uilocation=popout#/fido2?${queryParams}`,
{ center: true }
);
const response = await lastValueFrom(
this.messages$.pipe(
filter((msg) => msg.requestId === requestId),
first(),
takeUntil(this.destroy$)
)
);
if (response.type === "ConfirmCredentialResponse") {
return true;
}
if (response.type === "AbortResponse") {
throw new RequestAbortedError(response.fallbackRequested);
}
abortController.signal.removeEventListener("abort", abortHandler);
return false;
}
async pickCredential(
cipherIds: string[],
abortController = new AbortController()
): Promise<string> {
const requestId = Utils.newGuid();
const data: BrowserFido2Message = { type: "PickCredentialRequest", cipherIds, requestId };
const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString();
const abortHandler = () =>
BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId });
abortController.signal.addEventListener("abort", abortHandler);
this.popupUtilsService.popOut(
null,
`popup/index.html?uilocation=popout#/fido2?${queryParams}`,
{ center: true }
);
const response = await lastValueFrom(
this.messages$.pipe(
filter((msg) => msg.requestId === requestId),
first(),
takeUntil(this.destroy$)
)
);
if (response.type === "AbortResponse") {
throw new RequestAbortedError(response.fallbackRequested);
}
if (response.type !== "PickCredentialResponse") {
throw new RequestAbortedError();
}
abortController.signal.removeEventListener("abort", abortHandler);
return response.cipherId;
}
async confirmNewCredential(
{ credentialName, userName }: NewCredentialParams,
abortController = new AbortController()
): Promise<boolean> {
const requestId = Utils.newGuid();
const data: BrowserFido2Message = {
type: "ConfirmNewCredentialRequest",
requestId,
credentialName,
userName,
};
const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString();
const abortHandler = () =>
BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId });
abortController.signal.addEventListener("abort", abortHandler);
this.popupUtilsService.popOut(
null,
`popup/index.html?uilocation=popout#/fido2?${queryParams}`,
{ center: true }
);
const response = await lastValueFrom(
this.messages$.pipe(
filter((msg) => msg.requestId === requestId),
first(),
takeUntil(this.destroy$)
)
);
if (response.type === "ConfirmNewCredentialResponse") {
return true;
}
if (response.type === "AbortResponse") {
throw new RequestAbortedError(response.fallbackRequested);
}
abortController.signal.removeEventListener("abort", abortHandler);
return false;
}
async confirmNewNonDiscoverableCredential(
{ credentialName, userName }: NewCredentialParams,
abortController?: AbortController
): Promise<string> {
const requestId = Utils.newGuid();
const data: BrowserFido2Message = {
type: "ConfirmNewNonDiscoverableCredentialRequest",
requestId,
credentialName,
userName,
};
const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString();
const abortHandler = () =>
BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId });
abortController.signal.addEventListener("abort", abortHandler);
this.popupUtilsService.popOut(
null,
`popup/index.html?uilocation=popout#/fido2?${queryParams}`,
{ center: true }
);
const response = await lastValueFrom(
this.messages$.pipe(
filter((msg) => msg.requestId === requestId),
first(),
takeUntil(this.destroy$)
)
);
if (response.type === "ConfirmNewNonDiscoverableCredentialResponse") {
return response.cipherId;
}
if (response.type === "AbortResponse") {
throw new RequestAbortedError(response.fallbackRequested);
}
abortController.signal.removeEventListener("abort", abortHandler);
return undefined;
}
async informExcludedCredential(
existingCipherIds: string[],
newCredential: NewCredentialParams,
abortController?: AbortController
): Promise<void> {
// Not Implemented
}
private setAbortTimeout(abortController: AbortController) {
return setTimeout(() => abortController.abort());
return await BrowserFido2UserInterfaceSession.create(this.popupUtilsService, abortController);
}
}
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
static async create(
parentService: BrowserFido2UserInterfaceService,
popupUtilsService: PopupUtilsService,
abortController?: AbortController
): Promise<BrowserFido2UserInterfaceSession> {
return new BrowserFido2UserInterfaceSession(parentService, abortController);
return new BrowserFido2UserInterfaceSession(popupUtilsService, abortController);
}
readonly abortListener: () => void;
static sendMessage(msg: BrowserFido2Message) {
BrowserApi.sendMessage(BrowserFido2MessageName, msg);
}
private closed = false;
private messages$ = (BrowserApi.messageListener$() as Observable<BrowserFido2Message>).pipe(
filter((msg) => msg.sessionId === this.sessionId)
);
private connected$ = new BehaviorSubject(false);
private destroy$ = new Subject<void>();
private constructor(
private readonly parentService: BrowserFido2UserInterfaceService,
private readonly popupUtilsService: PopupUtilsService,
readonly abortController = new AbortController(),
readonly sessionId = Utils.newGuid()
) {
this.abortListener = () => this.abort();
abortController.signal.addEventListener("abort", this.abortListener);
this.messages$
.pipe(
filter((msg) => msg.type === "ConnectResponse"),
take(1),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.connected$.next(true);
});
// Handle session aborted by RP
fromEvent(abortController.signal, "abort")
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.close();
BrowserFido2UserInterfaceSession.sendMessage({
type: "AbortRequest",
sessionId: this.sessionId,
});
});
// Handle session aborted by user
this.messages$
.pipe(
filter((msg) => msg.type === "AbortResponse"),
take(1),
takeUntil(this.destroy$)
)
.subscribe((msg) => {
if (msg.type === "AbortResponse") {
this.close();
this.abortController.abort(UserRequestedFallbackAbortReason);
}
});
BrowserFido2UserInterfaceSession.sendMessage({
type: "NewSessionCreatedRequest",
sessionId,
});
}
fallbackRequested = false;
@ -296,26 +163,61 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
return this.abortController.signal.aborted;
}
confirmCredential(cipherId: string, abortController?: AbortController): Promise<boolean> {
return this.parentService.confirmCredential(cipherId, this.abortController);
async confirmCredential(cipherId: string): Promise<boolean> {
const data: BrowserFido2Message = {
type: "ConfirmCredentialRequest",
cipherId,
sessionId: this.sessionId,
};
await this.send(data);
await this.receive("ConfirmCredentialResponse");
return true;
}
pickCredential(cipherIds: string[], abortController?: AbortController): Promise<string> {
return this.parentService.pickCredential(cipherIds, this.abortController);
async pickCredential(cipherIds: string[]): Promise<string> {
const data: BrowserFido2Message = {
type: "PickCredentialRequest",
cipherIds,
sessionId: this.sessionId,
};
await this.send(data);
const response = await this.receive("PickCredentialResponse");
return response.cipherId;
}
confirmNewCredential(
params: NewCredentialParams,
abortController?: AbortController
): Promise<boolean> {
return this.parentService.confirmNewCredential(params, this.abortController);
async confirmNewCredential({ credentialName, userName }: NewCredentialParams): Promise<boolean> {
const data: BrowserFido2Message = {
type: "ConfirmNewCredentialRequest",
sessionId: this.sessionId,
credentialName,
userName,
};
await this.send(data);
await this.receive("ConfirmNewCredentialResponse");
return true;
}
confirmNewNonDiscoverableCredential(
params: NewCredentialParams,
abortController?: AbortController
): Promise<string> {
return this.parentService.confirmNewNonDiscoverableCredential(params, this.abortController);
async confirmNewNonDiscoverableCredential({
credentialName,
userName,
}: NewCredentialParams): Promise<string> {
const data: BrowserFido2Message = {
type: "ConfirmNewNonDiscoverableCredentialRequest",
sessionId: this.sessionId,
credentialName,
userName,
};
await this.send(data);
const response = await this.receive("ConfirmNewNonDiscoverableCredentialResponse");
return response.cipherId;
}
informExcludedCredential(
@ -323,18 +225,52 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
newCredential: NewCredentialParams,
abortController?: AbortController
): Promise<void> {
return this.parentService.informExcludedCredential(
existingCipherIds,
newCredential,
this.abortController
);
return null;
}
private abort() {
this.close();
private async send(msg: BrowserFido2Message): Promise<void> {
if (!this.connected$.value) {
await this.connect();
}
BrowserFido2UserInterfaceSession.sendMessage(msg);
}
private async receive<T extends BrowserFido2Message["type"]>(
type: T
): Promise<BrowserFido2Message & { type: T }> {
try {
const response = await firstValueFrom(
this.messages$.pipe(
filter((msg) => msg.sessionId === this.sessionId && msg.type === type),
takeUntil(this.destroy$)
)
);
return response as BrowserFido2Message & { type: T };
} catch (error) {
if (error instanceof EmptyError) {
throw new SessionClosedError();
}
throw error;
}
}
private async connect(): Promise<void> {
if (this.closed) {
throw new Error("Cannot re-open closed session");
}
const queryParams = new URLSearchParams({ sessionId: this.sessionId }).toString();
this.popupUtilsService.popOut(
null,
`popup/index.html?uilocation=popout#/fido2?${queryParams}`,
{ center: true }
);
await firstValueFrom(this.connected$.pipe(filter((connected) => connected === true)));
}
private close() {
this.abortController.signal.removeEventListener("abort", this.abortListener);
this.closed = true;
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -1,4 +1,4 @@
<ng-container *ngIf="data">
<ng-container *ngIf="data$ | async as data">
<div class="auth-wrapper">
<ng-container *ngIf="data.type == 'ConfirmCredentialRequest'">
A site is asking for authentication using the following credential:
@ -37,9 +37,9 @@
</div>
<button type="button" class="btn btn-outline-secondary" (click)="confirmNew()">Create</button>
</ng-container>
<button type="button" class="btn btn-outline-secondary" (click)="cancel(true)">
<button type="button" class="btn btn-outline-secondary" (click)="abort(true)">
Use browser built-in
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel(false)">Abort</button>
<button type="button" class="btn btn-outline-secondary" (click)="abort(false)">Abort</button>
</div>
</ng-container>

View File

@ -1,15 +1,25 @@
import { Component, HostListener, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { concatMap, Subject, switchMap, takeUntil } from "rxjs";
import {
BehaviorSubject,
combineLatest,
concatMap,
map,
Observable,
Subject,
take,
takeUntil,
} from "rxjs";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Fido2KeyView } from "@bitwarden/common/webauthn/models/view/fido2-key.view";
import { BrowserApi } from "../../../browser/browserApi";
import {
BrowserFido2Message,
BrowserFido2UserInterfaceService,
BrowserFido2UserInterfaceSession,
} from "../../../services/fido2/browser-fido2-user-interface.service";
@Component({
@ -20,35 +30,58 @@ import {
export class Fido2Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected data?: BrowserFido2Message;
protected data$ = new BehaviorSubject<BrowserFido2Message>(null);
protected sessionId?: string;
protected ciphers?: CipherView[] = [];
constructor(private activatedRoute: ActivatedRoute, private cipherService: CipherService) {}
ngOnInit(): void {
this.activatedRoute.queryParamMap
.pipe(
concatMap(async (queryParamMap) => {
this.data = JSON.parse(queryParamMap.get("data"));
const sessionId$ = this.activatedRoute.queryParamMap.pipe(
take(1),
map((queryParamMap) => queryParamMap.get("sessionId"))
);
if (this.data?.type === "ConfirmNewCredentialRequest") {
combineLatest([sessionId$, BrowserApi.messageListener$() as Observable<BrowserFido2Message>])
.pipe(takeUntil(this.destroy$))
.subscribe(([sessionId, message]) => {
this.sessionId = sessionId;
if (message.type === "NewSessionCreatedRequest" && message.sessionId !== sessionId) {
return this.abort(false);
}
if (message.sessionId !== sessionId) {
return;
}
if (message.type === "AbortRequest") {
return this.abort(false);
}
this.data$.next(message);
});
this.data$
.pipe(
concatMap(async (data) => {
if (data?.type === "ConfirmNewCredentialRequest") {
const cipher = new CipherView();
cipher.name = this.data.credentialName;
cipher.name = data.credentialName;
cipher.type = CipherType.Fido2Key;
cipher.fido2Key = new Fido2KeyView();
cipher.fido2Key.userName = this.data.userName;
cipher.fido2Key.userName = data.userName;
this.ciphers = [cipher];
} else if (this.data?.type === "ConfirmCredentialRequest") {
const cipher = await this.cipherService.get(this.data.cipherId);
} else if (data?.type === "ConfirmCredentialRequest") {
const cipher = await this.cipherService.get(data.cipherId);
this.ciphers = [await cipher.decrypt()];
} else if (this.data?.type === "PickCredentialRequest") {
} else if (data?.type === "PickCredentialRequest") {
this.ciphers = await Promise.all(
this.data.cipherIds.map(async (cipherId) => {
data.cipherIds.map(async (cipherId) => {
const cipher = await this.cipherService.get(cipherId);
return cipher.decrypt();
})
);
} else if (this.data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
} else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
);
@ -58,27 +91,25 @@ export class Fido2Component implements OnInit, OnDestroy {
)
.subscribe();
this.activatedRoute.queryParamMap
.pipe(
switchMap((queryParamMap) => {
const data = JSON.parse(queryParamMap.get("data"));
return BrowserFido2UserInterfaceService.onAbort$(data.requestId);
}),
takeUntil(this.destroy$)
)
.subscribe(() => this.cancel(false));
sessionId$.pipe(takeUntil(this.destroy$)).subscribe((sessionId) => {
this.send({
sessionId: sessionId,
type: "ConnectResponse",
});
});
}
async pick(cipher: CipherView) {
if (this.data?.type === "PickCredentialRequest") {
BrowserFido2UserInterfaceService.sendMessage({
requestId: this.data.requestId,
const data = this.data$.value;
if (data?.type === "PickCredentialRequest") {
this.send({
sessionId: this.sessionId,
cipherId: cipher.id,
type: "PickCredentialResponse",
});
} else if (this.data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
BrowserFido2UserInterfaceService.sendMessage({
requestId: this.data.requestId,
} else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
this.send({
sessionId: this.sessionId,
cipherId: cipher.id,
type: "ConfirmNewNonDiscoverableCredentialResponse",
});
@ -88,31 +119,30 @@ export class Fido2Component implements OnInit, OnDestroy {
}
confirm() {
BrowserFido2UserInterfaceService.sendMessage({
requestId: this.data.requestId,
this.send({
sessionId: this.sessionId,
type: "ConfirmCredentialResponse",
});
window.close();
}
confirmNew() {
BrowserFido2UserInterfaceService.sendMessage({
requestId: this.data.requestId,
this.send({
sessionId: this.sessionId,
type: "ConfirmNewCredentialResponse",
});
window.close();
}
cancel(fallback: boolean) {
abort(fallback: boolean) {
this.unload(fallback);
window.close();
}
@HostListener("window:unload")
unload(fallback = true) {
const data = this.data;
BrowserFido2UserInterfaceService.sendMessage({
requestId: data.requestId,
unload(fallback = false) {
this.send({
sessionId: this.sessionId,
type: "AbortResponse",
fallbackRequested: fallback,
});
@ -122,4 +152,11 @@ export class Fido2Component implements OnInit, OnDestroy {
this.destroy$.next();
this.destroy$.complete();
}
private send(msg: BrowserFido2Message) {
BrowserFido2UserInterfaceSession.sendMessage({
sessionId: this.sessionId,
...msg,
});
}
}

View File

@ -1,3 +1,5 @@
export const UserRequestedFallbackAbortReason = "UserRequestedFallback";
export type UserVerification = "discouraged" | "preferred" | "required";
export abstract class Fido2ClientService {