From 3604d3a57dbb17a90af0bc11a7a6033479e57520 Mon Sep 17 00:00:00 2001 From: Matthias Frey Date: Sun, 6 Jun 2021 14:18:12 +0200 Subject: [PATCH] Complete WebAuthn device sign up and login * Move device registration to account page * Replace the token login with a WebAuthn prompt if the current device has been registered * Mark the current device in the list of registered auth devices --- .../app/auth-device/auth-device.controller.ts | 12 +- .../src/app/auth-device/auth-device.module.ts | 2 +- .../app/auth-device/auth-device.service.ts | 5 +- apps/api/src/app/auth/auth.controller.ts | 43 ++-- apps/api/src/app/auth/auth.module.ts | 2 +- .../api/src/app/auth/interfaces/interfaces.ts | 5 + .../src/app/auth/interfaces/simplewebauthn.ts | 187 ++++++++++++++++++ apps/api/src/app/auth/web-auth.service.ts | 62 +++--- .../api/src/services/configuration.service.ts | 1 - .../interfaces/environment.interface.ts | 1 - .../auth-device-settings.component.html | 2 +- ...ss => auth-device-settings.component.scss} | 0 .../auth-device-settings.component.ts | 7 +- .../auth-device-settings.module.ts | 2 - .../app/components/header/header.component.ts | 12 +- .../pages/account/account-page.component.ts | 71 ++++++- .../src/app/pages/account/account-page.html | 32 +++ .../app/pages/account/account-page.module.ts | 16 +- .../auth-device-dialog.component.css | 0 .../auth-device-dialog.component.html | 0 .../auth-device-dialog.component.ts | 8 +- .../auth-devices-page-routing.module.ts | 14 -- .../auth-devices-page.component.html | 46 ----- .../auth-devices-page.component.scss | 0 .../auth-devices-page.component.ts | 115 ----------- .../auth-devices/auth-devices-page.module.ts | 37 ---- .../pages/landing/landing-page.component.ts | 37 +++- apps/client/src/app/services/data.service.ts | 12 ++ .../app/services/settings-storage.service.ts | 4 + .../src/app/services/token-storage.service.ts | 12 +- .../src/app/services/web-authn.service.ts | 90 +++++++++ prisma/schema.prisma | 6 +- 32 files changed, 536 insertions(+), 307 deletions(-) create mode 100644 apps/api/src/app/auth/interfaces/simplewebauthn.ts rename apps/client/src/app/components/auth-device-settings/{auth-device-settings.component.css => auth-device-settings.component.scss} (100%) rename apps/client/src/app/pages/{auth-devices => account}/auth-device-dialog/auth-device-dialog.component.css (100%) rename apps/client/src/app/pages/{auth-devices => account}/auth-device-dialog/auth-device-dialog.component.html (100%) rename apps/client/src/app/pages/{auth-devices => account}/auth-device-dialog/auth-device-dialog.component.ts (74%) delete mode 100644 apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts delete mode 100644 apps/client/src/app/pages/auth-devices/auth-devices-page.component.html delete mode 100644 apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss delete mode 100644 apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts delete mode 100644 apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts create mode 100644 apps/client/src/app/services/web-authn.service.ts diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts index 25a8759c2..1ee92a660 100644 --- a/apps/api/src/app/auth-device/auth-device.controller.ts +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -17,7 +17,7 @@ export class AuthDeviceController { @Delete(':id') @UseGuards(AuthGuard('jwt')) - public async deleteAuthDevice(@Param('id') id: string): Promise { + public async deleteAuthDevice(@Param('id') id: string): Promise { if ( !hasPermission( getPermissions(this.request.user.role), @@ -30,7 +30,7 @@ export class AuthDeviceController { ); } - const deletedAuthDevice = await this.authDeviceService.deleteAuthDevice( + await this.authDeviceService.deleteAuthDevice( { id_userId: { id, @@ -38,11 +38,6 @@ export class AuthDeviceController { } } ); - return { - id: deletedAuthDevice.id, - createdAt: deletedAuthDevice.createdAt.toISOString(), - name: deletedAuthDevice.name - }; } @Put(':id') @@ -92,15 +87,14 @@ export class AuthDeviceController { @Get() @UseGuards(AuthGuard('jwt')) public async getAllAuthDevices(): Promise { - const authDevices = await this.authDeviceService.authDevices({ orderBy: { createdAt: 'desc' }, where: { userId: this.request.user.id } }); return authDevices.map(authDevice => ({ - id: authDevice.id, createdAt: authDevice.createdAt.toISOString(), + id: authDevice.id, name: authDevice.name })); } diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts index bca5de38d..079cf3ecd 100644 --- a/apps/api/src/app/auth-device/auth-device.module.ts +++ b/apps/api/src/app/auth-device/auth-device.module.ts @@ -15,8 +15,8 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser ], providers: [ AuthDeviceService, - PrismaService, ConfigurationService, + PrismaService, ] }) export class AuthDeviceModule {} diff --git a/apps/api/src/app/auth-device/auth-device.service.ts b/apps/api/src/app/auth-device/auth-device.service.ts index 8d087bfb4..8c2f28c36 100644 --- a/apps/api/src/app/auth-device/auth-device.service.ts +++ b/apps/api/src/app/auth-device/auth-device.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { AuthDevice, Order, Prisma } from '@prisma/client'; +import { AuthDevice, Prisma } from '@prisma/client'; @Injectable() export class AuthDeviceService { @@ -40,7 +40,6 @@ export class AuthDeviceService { public async createAuthDevice( data: Prisma.AuthDeviceCreateInput ): Promise { - return this.prisma.authDevice.create({ data }); @@ -48,8 +47,8 @@ export class AuthDeviceService { public async updateAuthDevice( params: { - where: Prisma.AuthDeviceWhereUniqueInput; data: Prisma.AuthDeviceUpdateInput; + where: Prisma.AuthDeviceWhereUniqueInput; }, ): Promise { const { data, where } = params; diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index f74b3c77c..dc6a54acc 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,28 +1,17 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { - Body, - Controller, - Get, - HttpException, - Param, Post, - Req, - Res, - UseGuards -} from '@nestjs/common'; +import { Body, Controller, Get, HttpException, Param, Post, Req, Res, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { StatusCodes, getReasonPhrase } from 'http-status-codes'; - +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { AuthService } from './auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; -// TODO fix type compilation error -// import { AttestationCredentialJSON } from '@simplewebauthn/typescript-types'; +import { AssertionCredentialJSON, AttestationCredentialJSON } from './interfaces/simplewebauthn'; @Controller('auth') export class AuthController { public constructor( private readonly authService: AuthService, + private readonly configurationService: ConfigurationService, private readonly webAuthService: WebAuthService, - private readonly configurationService: ConfigurationService ) {} @Get('anonymous/:accessToken') @@ -67,19 +56,25 @@ export class AuthController { @Post('webauthn/verify-attestation') @UseGuards(AuthGuard('jwt')) - public async verifyAttestation(@Body() body: any) { - return this.webAuthService.verifyAttestation(body); + public async verifyAttestation(@Body() body: { deviceName: string, credential: AttestationCredentialJSON }) { + return this.webAuthService.verifyAttestation(body.deviceName, body.credential); } - @Get('webauthn/generate-assertion-options') - @UseGuards(AuthGuard('jwt')) - public async generateAssertionOptions() { - return this.webAuthService.generateAssertionOptions(); + @Post('webauthn/generate-assertion-options') + public async generateAssertionOptions(@Body() body: { userId: string }) { + return this.webAuthService.generateAssertionOptions(body.userId); } @Post('webauthn/verify-assertion') - @UseGuards(AuthGuard('jwt')) - public async verifyAssertion(@Body() body: any) { - return this.webAuthService.verifyAssertion(body); + public async verifyAssertion(@Body() body: { userId: string, credential: AssertionCredentialJSON }) { + try { + const authToken = await this.webAuthService.verifyAssertion(body.userId, body.credential); + return { authToken }; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } } } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index ccea5a687..7ddd8b58c 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -20,6 +20,7 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; }) ], providers: [ + AuthDeviceService, AuthService, ConfigurationService, GoogleStrategy, @@ -27,7 +28,6 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; PrismaService, UserService, WebAuthService, - AuthDeviceService, ] }) export class AuthModule {} diff --git a/apps/api/src/app/auth/interfaces/interfaces.ts b/apps/api/src/app/auth/interfaces/interfaces.ts index c45291e08..425f8be74 100644 --- a/apps/api/src/app/auth/interfaces/interfaces.ts +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -1,4 +1,9 @@ import { Provider } from '@prisma/client'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; + +export interface AuthDeviceDialogParams { + authDevice: AuthDeviceDto, +} export interface ValidateOAuthLoginParams { provider: Provider; diff --git a/apps/api/src/app/auth/interfaces/simplewebauthn.ts b/apps/api/src/app/auth/interfaces/simplewebauthn.ts new file mode 100644 index 000000000..1f5aecfad --- /dev/null +++ b/apps/api/src/app/auth/interfaces/simplewebauthn.ts @@ -0,0 +1,187 @@ +export interface AuthenticatorAssertionResponse extends AuthenticatorResponse { + readonly authenticatorData: ArrayBuffer; + readonly signature: ArrayBuffer; + readonly userHandle: ArrayBuffer | null; +} +export interface AuthenticatorAttestationResponse extends AuthenticatorResponse { + readonly attestationObject: ArrayBuffer; +} +export interface AuthenticationExtensionsClientInputs { + appid?: string; + appidExclude?: string; + credProps?: boolean; + uvm?: boolean; +} +export interface AuthenticationExtensionsClientOutputs { + appid?: boolean; + credProps?: CredentialPropertiesOutput; + uvm?: UvmEntries; +} +export interface AuthenticatorSelectionCriteria { + authenticatorAttachment?: AuthenticatorAttachment; + requireResidentKey?: boolean; + residentKey?: ResidentKeyRequirement; + userVerification?: UserVerificationRequirement; +} +export interface PublicKeyCredential extends Credential { + readonly rawId: ArrayBuffer; + readonly response: AuthenticatorResponse; + getClientExtensionResults(): AuthenticationExtensionsClientOutputs; +} +export interface PublicKeyCredentialCreationOptions { + attestation?: AttestationConveyancePreference; + authenticatorSelection?: AuthenticatorSelectionCriteria; + challenge: BufferSource; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + extensions?: AuthenticationExtensionsClientInputs; + pubKeyCredParams: PublicKeyCredentialParameters[]; + rp: PublicKeyCredentialRpEntity; + timeout?: number; + user: PublicKeyCredentialUserEntity; +} +export interface PublicKeyCredentialDescriptor { + id: BufferSource; + transports?: AuthenticatorTransport[]; + type: PublicKeyCredentialType; +} +export interface PublicKeyCredentialParameters { + alg: COSEAlgorithmIdentifier; + type: PublicKeyCredentialType; +} +export interface PublicKeyCredentialRequestOptions { + allowCredentials?: PublicKeyCredentialDescriptor[]; + challenge: BufferSource; + extensions?: AuthenticationExtensionsClientInputs; + rpId?: string; + timeout?: number; + userVerification?: UserVerificationRequirement; +} +export interface PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity { + displayName: string; + id: BufferSource; +} +export interface AuthenticatorResponse { + readonly clientDataJSON: ArrayBuffer; +} +export interface CredentialPropertiesOutput { + rk?: boolean; +} +export interface Credential { + readonly id: string; + readonly type: string; +} +export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity { + id?: string; +} +export interface PublicKeyCredentialEntity { + name: string; +} +export declare type AttestationConveyancePreference = "direct" | "enterprise" | "indirect" | "none"; +export declare type AuthenticatorTransport = "ble" | "internal" | "nfc" | "usb"; +export declare type COSEAlgorithmIdentifier = number; +export declare type UserVerificationRequirement = "discouraged" | "preferred" | "required"; +export declare type UvmEntries = UvmEntry[]; +export declare type AuthenticatorAttachment = "cross-platform" | "platform"; +export declare type ResidentKeyRequirement = "discouraged" | "preferred" | "required"; +export declare type BufferSource = ArrayBufferView | ArrayBuffer; +export declare type PublicKeyCredentialType = "public-key"; +export declare type UvmEntry = number[]; + +export interface PublicKeyCredentialCreationOptionsJSON extends Omit { + user: PublicKeyCredentialUserEntityJSON; + challenge: Base64URLString; + excludeCredentials: PublicKeyCredentialDescriptorJSON[]; + extensions?: AuthenticationExtensionsClientInputs; +} +/** + * A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to + * (eventually) get passed into navigator.credentials.get(...) in the browser. + */ +export interface PublicKeyCredentialRequestOptionsJSON extends Omit { + challenge: Base64URLString; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + extensions?: AuthenticationExtensionsClientInputs; +} +export interface PublicKeyCredentialDescriptorJSON extends Omit { + id: Base64URLString; +} +export interface PublicKeyCredentialUserEntityJSON extends Omit { + id: string; +} +/** + * The value returned from navigator.credentials.create() + */ +export interface AttestationCredential extends PublicKeyCredential { + response: AuthenticatorAttestationResponseFuture; +} +/** + * A slightly-modified AttestationCredential to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AttestationCredentialJSON extends Omit { + rawId: Base64URLString; + response: AuthenticatorAttestationResponseJSON; + clientExtensionResults: AuthenticationExtensionsClientOutputs; + transports?: AuthenticatorTransport[]; +} +/** + * The value returned from navigator.credentials.get() + */ +export interface AssertionCredential extends PublicKeyCredential { + response: AuthenticatorAssertionResponse; +} +/** + * A slightly-modified AssertionCredential to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AssertionCredentialJSON extends Omit { + rawId: Base64URLString; + response: AuthenticatorAssertionResponseJSON; + clientExtensionResults: AuthenticationExtensionsClientOutputs; +} +/** + * A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AuthenticatorAttestationResponseJSON extends Omit { + clientDataJSON: Base64URLString; + attestationObject: Base64URLString; +} +/** + * A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AuthenticatorAssertionResponseJSON extends Omit { + authenticatorData: Base64URLString; + clientDataJSON: Base64URLString; + signature: Base64URLString; + userHandle?: string; +} +/** + * A WebAuthn-compatible device and the information needed to verify assertions by it + */ +export declare type AuthenticatorDevice = { + credentialPublicKey: Buffer; + credentialID: Buffer; + counter: number; + transports?: AuthenticatorTransport[]; +}; +/** + * An attempt to communicate that this isn't just any string, but a Base64URL-encoded string + */ +export declare type Base64URLString = string; +/** + * AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7). + * Maintain an augmented version here so we can implement additional properties as the WebAuthn + * spec evolves. + * + * See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse + * + * Properties marked optional are not supported in all browsers. + */ +export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse { + getTransports?: () => AuthenticatorTransport[]; + getAuthenticatorData?: () => ArrayBuffer; + getPublicKey?: () => ArrayBuffer; + getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[]; +} diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index ce05ab997..6affd4072 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -1,6 +1,5 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; - import { UserService } from '../user/user.service'; import { generateAssertionOptions, @@ -16,24 +15,22 @@ import { } from '@simplewebauthn/server'; import { REQUEST } from '@nestjs/core'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; -// TODO fix type compilation error -// import { AttestationCredentialJSON } from '@simplewebauthn/typescript-types'; +import { AssertionCredentialJSON, AttestationCredentialJSON } from './interfaces/simplewebauthn'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import base64url from 'base64url'; +import { JwtService } from '@nestjs/jwt'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; @Injectable() export class WebAuthService { public constructor( private readonly configurationService: ConfigurationService, - private readonly userService: UserService, private readonly deviceService: AuthDeviceService, + private readonly jwtService: JwtService, + private readonly userService: UserService, @Inject(REQUEST) private readonly request: RequestWithUser, ) {} - get rpName() { - return this.configurationService.get('WEB_AUTH_RP_NAME'); - } - get rpID() { return this.configurationService.get('WEB_AUTH_RP_ID'); } @@ -47,7 +44,7 @@ export class WebAuthService { const devices = await this.deviceService.authDevices({where: {userId: user.id}}); const opts: GenerateAttestationOptionsOpts = { - rpName: this.rpName, + rpName: 'Ghostfolio', rpID: this.rpID, userID: user.id, userName: user.alias, @@ -92,7 +89,7 @@ export class WebAuthService { return options; } - public async verifyAttestation(body: any){ + public async verifyAttestation(deviceName: string, credential: AttestationCredentialJSON): Promise { const user = this.request.user; const expectedChallenge = user.authChallenge; @@ -100,7 +97,7 @@ export class WebAuthService { let verification: VerifiedAttestation; try { const opts: VerifyAttestationResponseOpts = { - credential: body, + credential, expectedChallenge, expectedOrigin: this.expectedOrigin, expectedRPID: this.rpID, @@ -108,7 +105,7 @@ export class WebAuthService { verification = await verifyAttestationResponse(opts); } catch (error) { console.error(error); - return new InternalServerErrorException(error.message); + throw new InternalServerErrorException(error.message); } const { verified, attestationInfo } = verification; @@ -117,28 +114,37 @@ export class WebAuthService { if (verified && attestationInfo) { const { credentialPublicKey, credentialID, counter } = attestationInfo; - const existingDevice = devices.find(device => device.credentialId === credentialID); + let existingDevice = devices.find(device => device.credentialId === credentialID); if (!existingDevice) { /** * Add the returned device to the user's list of devices */ - await this.deviceService.createAuthDevice({ + existingDevice = await this.deviceService.createAuthDevice({ credentialPublicKey, credentialId: credentialID, counter, - name: body.deviceName, + name: deviceName, User: { connect: { id: user.id } } }) } + + return { + createdAt: existingDevice.createdAt.toISOString(), + id: existingDevice.id, + name: existingDevice.name + }; } - return { verified }; + throw new InternalServerErrorException('An unknown error occurred'); } - public async generateAssertionOptions(){ - const user = this.request.user; - const devices = await this.deviceService.authDevices({where: {userId: user.id}}); + public async generateAssertionOptions(userId: string){ + const devices = await this.deviceService.authDevices({where: {userId: userId}}); + + if(devices.length === 0){ + throw new Error('No registered auth devices found.') + } const opts: GenerateAssertionOptionsOpts = { timeout: 60000, @@ -166,29 +172,29 @@ export class WebAuthService { authChallenge: options.challenge, }, where: { - id: user.id, + id: userId, } }) return options; } - public async verifyAssertion(body: any){ + public async verifyAssertion(userId: string, credential: AssertionCredentialJSON){ - const user = this.request.user; + const user = await this.userService.user({ id: userId }); - const bodyCredIDBuffer = base64url.toBuffer(body.rawId); + const bodyCredIDBuffer = base64url.toBuffer(credential.rawId); const devices = await this.deviceService.authDevices({where: {credentialId: bodyCredIDBuffer}}); if (devices.length !== 1) { - throw new InternalServerErrorException(`Could not find authenticator matching ${body.id}`); + throw new InternalServerErrorException(`Could not find authenticator matching ${credential.id}`); } const authenticator = devices[0]; let verification: VerifiedAssertion; try { const opts: VerifyAssertionResponseOpts = { - credential: body, + credential, expectedChallenge: `${user.authChallenge}`, expectedOrigin: this.expectedOrigin, expectedRPID: this.rpID, @@ -214,8 +220,12 @@ export class WebAuthService { data: authenticator, where: {id_userId: { id: authenticator.id, userId: user.id}} }) + + return this.jwtService.sign({ + id: user.id + }); } - return { verified }; + throw new Error(); } } diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index 27f6be8e5..596e9d3ef 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -28,7 +28,6 @@ export class ConfigurationService { REDIS_PORT: port({ default: 6379 }), ROOT_URL: str({ default: 'http://localhost:4200' }), WEB_AUTH_RP_ID: host({ default: 'localhost' }), - WEB_AUTH_RP_NAME: str({ default: 'Ghostfolio' }), }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index bbd2fec28..fd67d34d7 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -19,5 +19,4 @@ export interface Environment extends CleanedEnvAccessors { REDIS_PORT: number; ROOT_URL: string; WEB_AUTH_RP_ID: string; - WEB_AUTH_RP_NAME: string; } diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html index 66a5d4550..f4ef43aaf 100644 --- a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html +++ b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html @@ -8,7 +8,7 @@ > Name - {{element.name}} + {{element.name}}{{element.id === currentDeviceId ? ' (current)' : ''}} diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss similarity index 100% rename from apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css rename to apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts index c7ce02dd3..fe8513643 100644 --- a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts +++ b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts @@ -8,11 +8,12 @@ import { MatSort } from '@angular/material/sort'; selector: 'gf-auth-device-settings', changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './auth-device-settings.component.html', - styleUrls: ['./auth-device-settings.component.css'] + styleUrls: ['./auth-device-settings.component.scss'] }) export class AuthDeviceSettingsComponent implements OnInit, OnChanges { @Input() authDevices: AuthDeviceDto[]; + @Input() currentDeviceId: string; @Output() authDeviceDeleted = new EventEmitter(); @Output() authDeviceToUpdate = new EventEmitter(); @@ -25,9 +26,9 @@ export class AuthDeviceSettingsComponent implements OnInit, OnChanges { public isLoading = true; public pageSize = 7; - constructor() { } + public constructor() { } - ngOnInit(): void { + public ngOnInit(): void { } public ngOnChanges() { diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts b/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts index 40708d6e6..55e5fa230 100644 --- a/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts +++ b/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts @@ -1,7 +1,6 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatSortModule } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; @@ -16,7 +15,6 @@ import { MatPaginatorModule } from '@angular/material/paginator'; imports: [ CommonModule, MatButtonModule, - MatInputModule, MatMenuModule, MatSortModule, MatTableModule, diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 5ce1ca873..9392317c4 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -1,3 +1,4 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; import { ChangeDetectionStrategy, Component, @@ -16,6 +17,7 @@ import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { EMPTY, Subject } from 'rxjs'; import { catchError, takeUntil } from 'rxjs/operators'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; @Component({ selector: 'gf-header', @@ -42,7 +44,8 @@ export class HeaderComponent implements OnChanges { private dialog: MatDialog, private impersonationStorageService: ImpersonationStorageService, private router: Router, - private tokenStorageService: TokenStorageService + private tokenStorageService: TokenStorageService, + private webAuthnService: WebAuthnService, ) { this.impersonationStorageService .onChangeHasImpersonation() @@ -83,6 +86,13 @@ export class HeaderComponent implements OnChanges { } public openLoginDialog(): void { + if(this.webAuthnService.isEnabled()){ + this.webAuthnService.verifyWebAuthn().subscribe(({ authToken }) => { + this.setToken(authToken); + }); + return; + } + const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, { autoFocus: false, data: { diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 14264f246..288966e69 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -5,8 +5,13 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Currency } from '@prisma/client'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { ReplaySubject, Subject } from 'rxjs'; +import { filter, switchMap, takeUntil } from 'rxjs/operators'; +import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; +import { isNonNull } from '@ghostfolio/client/util/rxjs.util'; +import { MatDialog } from '@angular/material/dialog'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; @Component({ selector: 'gf-account-page', @@ -20,6 +25,7 @@ export class AccountPageComponent implements OnDestroy, OnInit { public defaultDateFormat = DEFAULT_DATE_FORMAT; public hasPermissionToUpdateUserSettings: boolean; public user: User; + public authDevices$: ReplaySubject = new ReplaySubject(1); private unsubscribeSubject = new Subject(); @@ -28,8 +34,10 @@ export class AccountPageComponent implements OnDestroy, OnInit { */ public constructor( private changeDetectorRef: ChangeDetectorRef, + private dialog: MatDialog, private dataService: DataService, - private userService: UserService + private userService: UserService, + public webAuthnService: WebAuthnService, ) { this.dataService .fetchInfo() @@ -52,6 +60,8 @@ export class AccountPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); } }); + + this.fetchAuthDevices(); } /** @@ -89,6 +99,61 @@ export class AccountPageComponent implements OnDestroy, OnInit { this.unsubscribeSubject.complete(); } + public startWebAuthn() { + this.webAuthnService.startWebAuthn() + .pipe( + switchMap(attResp => { + const dialogRef = this.dialog.open(AuthDeviceDialog, { + data: { + authDevice: {} + } + }); + return dialogRef.afterClosed().pipe(switchMap(data => { + return this.webAuthnService.verifyAttestation(attResp, data.authDevice.name) + })); + }) + ) + .subscribe(() => { + this.fetchAuthDevices(); + }); + } + + public deleteAuthDevice(aId: string) { + this.webAuthnService.deleteAuthDevice(aId).subscribe({ + next: () => { + this.fetchAuthDevices(); + } + }); + } + + public updateAuthDevice(aAuthDevice: AuthDeviceDto) { + const dialogRef = this.dialog.open(AuthDeviceDialog, { + data: { + authDevice: aAuthDevice + } + }); + + dialogRef.afterClosed() + .pipe( + filter(isNonNull), + switchMap(data => this.webAuthnService.updateAuthDevice(data.authDevice)) + ) + .subscribe({ + next: () => { + this.fetchAuthDevices(); + } + }); + } + + private fetchAuthDevices() { + this.webAuthnService + .fetchAuthDevices() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(authDevices => { + this.authDevices$.next(authDevices); + }); + } + private update() { this.dataService .fetchAccesses() diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 3271f9934..24d65e647 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -76,4 +76,36 @@ +
+
+

WebAuthn devices

+ + +
+
+ +
+
+
+
+ +
+ +
+
+
+
+
diff --git a/apps/client/src/app/pages/account/account-page.module.ts b/apps/client/src/app/pages/account/account-page.module.ts index 324be3654..fbb1e26dc 100644 --- a/apps/client/src/app/pages/account/account-page.module.ts +++ b/apps/client/src/app/pages/account/account-page.module.ts @@ -6,23 +6,33 @@ import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; - import { AccountPageRoutingModule } from './account-page-routing.module'; import { AccountPageComponent } from './account-page.component'; +import { GfAuthDeviceSettingsModule } from '@ghostfolio/client/components/auth-device-settings/auth-device-settings.module'; +import { MatInputModule } from '@angular/material/input'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component'; @NgModule({ - declarations: [AccountPageComponent], + declarations: [ + AuthDeviceDialog, + AccountPageComponent, + ], exports: [], imports: [ AccountPageRoutingModule, CommonModule, FormsModule, + GfAuthDeviceSettingsModule, GfPortfolioAccessTableModule, MatButtonModule, MatCardModule, + MatDialogModule, MatFormFieldModule, + MatInputModule, MatSelectModule, - ReactiveFormsModule + ReactiveFormsModule, ], providers: [] }) diff --git a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css similarity index 100% rename from apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css rename to apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css diff --git a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html similarity index 100% rename from apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html rename to apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html diff --git a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts similarity index 74% rename from apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts rename to apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts index b2afacf93..d0362ed45 100644 --- a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts +++ b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts @@ -1,10 +1,6 @@ import { Component, Inject, OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; - -export interface AuthDeviceDialogParams { - authDevice: AuthDeviceDto, -} +import { AuthDeviceDialogParams } from '@ghostfolio/api/app/auth/interfaces/interfaces'; @Component({ selector: 'gf-auth-device-dialog', @@ -19,7 +15,7 @@ export class AuthDeviceDialog implements OnInit { ) { } - ngOnInit(): void { + public ngOnInit(): void { } } diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts b/apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts deleted file mode 100644 index 3afbe2ba1..000000000 --- a/apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; -import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component'; - -const routes: Routes = [ - { path: '', component: AuthDevicesPageComponent, canActivate: [AuthGuard] } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class AuthDevicesPageRoutingModule {} diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.html b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.html deleted file mode 100644 index f2065da00..000000000 --- a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
-
-

WebAuthn

- - -
-
- -
-
-
-
- -
- -
-
-
- -
-
-
-
-
-
-
diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts deleted file mode 100644 index bdcb5b468..000000000 --- a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { startAssertion, startAttestation } from '@simplewebauthn/browser'; -import { filter, switchMap, takeUntil } from 'rxjs/operators'; -import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; -import { HttpClient } from '@angular/common/http'; -import { MatDialog } from '@angular/material/dialog'; -import { ReplaySubject, Subject } from 'rxjs'; -import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component'; -import { isNonNull } from '@ghostfolio/client/util/rxjs.util'; - -@Component({ - selector: 'gf-auth-devices-page', - templateUrl: './auth-devices-page.component.html', - styleUrls: ['./auth-devices-page.component.scss'] -}) -export class AuthDevicesPageComponent implements OnDestroy, OnInit { - - public authDevices$: ReplaySubject = new ReplaySubject(1); - - private unsubscribeSubject = new Subject(); - - - constructor( - private dataService: DataService, - private tokenStorageService: TokenStorageService, - private http: HttpClient, - private dialog: MatDialog - ) { - this.fetchAuthDevices(); - } - - public ngOnInit() { - } - - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - - public startWebAuthn() { - this.http.get(`/api/auth/webauthn/generate-attestation-options`, {}) - .pipe( - switchMap(attOps => { - return startAttestation(attOps); - }), - switchMap(attResp => { - const dialogRef = this.dialog.open(AuthDeviceDialog, { - data: { - authDevice: {} - } - }); - return dialogRef.afterClosed().pipe(switchMap(data => { - const reqBody = { - ...attResp, - deviceName: data.authDevice.name - }; - return this.http.post(`/api/auth/webauthn/verify-attestation`, reqBody); - })); - }) - ) - .subscribe(() => { - this.fetchAuthDevices(); - }); - } - - public verifyWebAuthn() { - this.http.get(`/api/auth/webauthn/generate-assertion-options`, {}) - .pipe( - switchMap(startAssertion), - switchMap(assertionResponse => this.http.post(`/api/auth/webauthn/verify-assertion`, assertionResponse)) - ) - .subscribe(res => { - if (res?.verified) alert('success'); - else alert('fail'); - }); - } - - public deleteAuthDevice(aId: string) { - this.dataService.deleteAuthDevice(aId).subscribe({ - next: () => { - this.fetchAuthDevices(); - } - }); - } - - public updateAuthDevice(aAuthDevice: AuthDeviceDto) { - const dialogRef = this.dialog.open(AuthDeviceDialog, { - data: { - authDevice: aAuthDevice - } - }); - - dialogRef.afterClosed() - .pipe( - filter(isNonNull), - switchMap(data => this.dataService.updateAuthDevice(data.authDevice)) - ) - .subscribe({ - next: () => { - this.fetchAuthDevices(); - } - }); - } - - private fetchAuthDevices() { - this.dataService - .fetchAuthDevices() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(authDevices => { - this.authDevices$.next(authDevices); - }); - } -} diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts b/apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts deleted file mode 100644 index a6ff7f7f5..000000000 --- a/apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { AuthDevicesPageRoutingModule } from '@ghostfolio/client/pages/auth-devices/auth-devices-page-routing.module'; -import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { GfAuthDeviceSettingsModule } from '@ghostfolio/client/components/auth-device-settings/auth-device-settings.module'; -import { MatCardModule } from '@angular/material/card'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; -import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component'; - - - -@NgModule({ - declarations: [ - AuthDevicesPageComponent, - AuthDeviceDialog, - ], - imports: [ - CommonModule, - AuthDevicesPageRoutingModule, - FormsModule, - GfAuthDeviceSettingsModule, - MatCardModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatDialogModule, - ReactiveFormsModule, - MatButtonModule - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class AuthDevicesPageModule { } diff --git a/apps/client/src/app/pages/landing/landing-page.component.ts b/apps/client/src/app/pages/landing/landing-page.component.ts index 05e515300..93bb7aea8 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -5,6 +5,7 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { format } from 'date-fns'; import { Subject } from 'rxjs'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; @Component({ selector: 'gf-landing-page', @@ -25,7 +26,8 @@ export class LandingPageComponent implements OnDestroy, OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private router: Router, - private tokenStorageService: TokenStorageService + private tokenStorageService: TokenStorageService, + private webAuthnService: WebAuthnService, ) {} /** @@ -254,6 +256,39 @@ export class LandingPageComponent implements OnDestroy, OnInit { ]; } + public openShowAccessTokenDialog( + accessToken: string, + authToken: string + ): void { + if(this.webAuthnService.isEnabled()){ + this.webAuthnService.verifyWebAuthn().subscribe((data) => { + if (data?.authToken) { + this.tokenStorageService.saveToken(authToken); + + this.router.navigate(['/']); + } + }); + return; + } + + const dialogRef = this.dialog.open(ShowAccessTokenDialog, { + data: { + accessToken, + authToken + }, + disableClose: true, + width: '30rem' + }); + + dialogRef.afterClosed().subscribe((data) => { + if (data?.authToken) { + this.tokenStorageService.saveToken(authToken); + + this.router.navigate(['/']); + } + }); + } + public setToken(aToken: string) { this.tokenStorageService.saveToken(aToken); diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index bf41116dd..180512e25 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -174,4 +174,16 @@ export class DataService { public putUserSettings(aData: UpdateUserSettingsDto) { return this.http.put(`/api/user/settings`, aData); } + + public fetchAuthDevices() { + return this.http.get('/api/auth-device'); + } + + public updateAuthDevice(aAuthDevice: AuthDeviceDto) { + return this.http.put(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice); + } + + public deleteAuthDevice(aId: string) { + return this.http.delete(`/api/auth-device/${aId}`); + } } diff --git a/apps/client/src/app/services/settings-storage.service.ts b/apps/client/src/app/services/settings-storage.service.ts index c658b5fd9..f195ee38a 100644 --- a/apps/client/src/app/services/settings-storage.service.ts +++ b/apps/client/src/app/services/settings-storage.service.ts @@ -15,4 +15,8 @@ export class SettingsStorageService { public setSetting(aKey: string, aValue: string) { window.localStorage.setItem(aKey, aValue); } + + public removeSetting(aKey: string): void { + return window.localStorage.removeItem(aKey); + } } diff --git a/apps/client/src/app/services/token-storage.service.ts b/apps/client/src/app/services/token-storage.service.ts index fc16b49d0..fa3866ab0 100644 --- a/apps/client/src/app/services/token-storage.service.ts +++ b/apps/client/src/app/services/token-storage.service.ts @@ -11,23 +11,23 @@ export class TokenStorageService { public constructor(private userService: UserService) {} public getToken(): string { - return window.localStorage.getItem(TOKEN_KEY); + return window.sessionStorage.getItem(TOKEN_KEY); } public saveToken(token: string): void { - window.localStorage.removeItem(TOKEN_KEY); - window.localStorage.setItem(TOKEN_KEY, token); + window.sessionStorage.removeItem(TOKEN_KEY); + window.sessionStorage.setItem(TOKEN_KEY, token); } public signOut(): void { - const utmSource = window.localStorage.getItem('utm_source'); + const utmSource = window.sessionStorage.getItem('utm_source'); - window.localStorage.clear(); + window.sessionStorage.clear(); this.userService.remove(); if (utmSource) { - window.localStorage.setItem('utm_source', utmSource); + window.sessionStorage.setItem('utm_source', utmSource); } } } diff --git a/apps/client/src/app/services/web-authn.service.ts b/apps/client/src/app/services/web-authn.service.ts new file mode 100644 index 000000000..7cacc2ec9 --- /dev/null +++ b/apps/client/src/app/services/web-authn.service.ts @@ -0,0 +1,90 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { switchMap, tap } from 'rxjs/operators'; +import { startAssertion, startAttestation } from '@simplewebauthn/browser'; +import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; +import { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON +} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; + +@Injectable({ + providedIn: 'root' +}) +export class WebAuthnService { + + private static readonly WEB_AUTH_N_USER_ID = 'WEB_AUTH_N_USER_ID'; + private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID'; + + public constructor( + private dataService: DataService, + private settingsStorageService: SettingsStorageService, + private http: HttpClient, + ) { + } + + public startWebAuthn() { + return this.http.get(`/api/auth/webauthn/generate-attestation-options`, {}) + .pipe( + switchMap(attOps => { + return startAttestation(attOps); + }) + ); + } + + public verifyAttestation(attResp, deviceName) { + return this.http.post(`/api/auth/webauthn/verify-attestation`, { + credential: attResp, + deviceName: deviceName, + }).pipe(tap(authDevice => + this.dataService.fetchUser().subscribe((user) => { + this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID, authDevice.id); + this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_USER_ID, user.id); + }) + )); + } + + public verifyWebAuthn() { + const userId = this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_USER_ID); + return this.http.post(`/api/auth/webauthn/generate-assertion-options`, {userId}) + .pipe( + switchMap(startAssertion), + switchMap(assertionResponse => { + return this.http.post<{ authToken: string }>(`/api/auth/webauthn/verify-assertion`, { + credential: assertionResponse, + userId + }) + }) + ); + } + + public getCurrentDeviceId() { + return this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); + } + + public isEnabled() { + return !!this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); + } + + public fetchAuthDevices() { + return this.http.get('/api/auth-device'); + } + + public updateAuthDevice(aAuthDevice: AuthDeviceDto) { + return this.http.put(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice); + } + + public deleteAuthDevice(aId: string) { + return this.http.delete(`/api/auth-device/${aId}`) + .pipe( + tap(() => { + if (aId === this.getCurrentDeviceId()) { + this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); + this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_USER_ID); + } + }) + ); + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13162662c..c8a19a309 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,8 +52,8 @@ model AuthDevice { credentialId Bytes credentialPublicKey Bytes counter Int - name String id String @default(uuid()) + name String updatedAt DateTime @updatedAt User User @relation(fields: [userId], references: [id]) userId String @@ -146,8 +146,9 @@ model User { Account Account[] alias String? Analytics Analytics? - createdAt DateTime @default(now()) + authChallenge String? AuthDevice AuthDevice[] + createdAt DateTime @default(now()) id String @id @default(uuid()) Order Order[] provider Provider? @@ -156,7 +157,6 @@ model User { Subscription Subscription[] thirdPartyId String? updatedAt DateTime @updatedAt - authChallenge String? } enum AccountType {