From e87c942cb8c13e0c34c1bc1197f4fe14c8a6513c Mon Sep 17 00:00:00 2001 From: Matthias Frey Date: Mon, 14 Jun 2021 16:09:40 +0200 Subject: [PATCH] Add webauthn (#82) * Add webauthn * 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 * Fix after rebase * Fix tests * Disable "Add current device" button if current device is registered * Add option to "Stay signed in" * Remove device list feature, sign in with deviceId instead * Improve usability * Update changelog Co-authored-by: Matthias Frey Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/api/src/app/app.module.ts | 2 + .../app/auth-device/auth-device.controller.ts | 44 ++++ .../src/app/auth-device/auth-device.dto.ts | 4 + .../src/app/auth-device/auth-device.module.ts | 18 ++ .../app/auth-device/auth-device.service.ts | 65 +++++ apps/api/src/app/auth/auth.controller.ts | 50 +++- apps/api/src/app/auth/auth.module.ts | 6 +- .../api/src/app/auth/interfaces/interfaces.ts | 5 + .../src/app/auth/interfaces/simplewebauthn.ts | 226 ++++++++++++++++++ apps/api/src/app/auth/web-auth.service.ts | 215 +++++++++++++++++ apps/api/src/models/portfolio.spec.ts | 1 + .../api/src/services/configuration.service.ts | 5 +- .../interfaces/environment.interface.ts | 1 + .../app/components/header/header.component.ts | 13 +- ...ogin-with-access-token-dialog.component.ts | 11 +- .../login-with-access-token-dialog.html | 35 ++- .../login-with-access-token-dialog.module.ts | 4 + .../login-with-access-token-dialog.scss | 10 + .../src/app/core/http-response.interceptor.ts | 19 +- .../pages/account/account-page.component.ts | 74 +++++- .../src/app/pages/account/account-page.html | 10 + .../app/pages/account/account-page.module.ts | 6 + .../pages/landing/landing-page.component.ts | 4 +- .../app/services/settings-storage.service.ts | 4 + .../src/app/services/token-storage.service.ts | 24 +- .../src/app/services/web-authn.service.ts | 104 ++++++++ apps/client/src/app/util/rxjs.util.ts | 3 + libs/common/src/lib/permissions.ts | 6 + package.json | 3 + .../migration.sql | 18 ++ prisma/schema.prisma | 43 ++-- yarn.lock | 137 ++++++++++- 33 files changed, 1111 insertions(+), 60 deletions(-) create mode 100644 apps/api/src/app/auth-device/auth-device.controller.ts create mode 100644 apps/api/src/app/auth-device/auth-device.dto.ts create mode 100644 apps/api/src/app/auth-device/auth-device.module.ts create mode 100644 apps/api/src/app/auth-device/auth-device.service.ts create mode 100644 apps/api/src/app/auth/interfaces/simplewebauthn.ts create mode 100644 apps/api/src/app/auth/web-auth.service.ts create mode 100644 apps/client/src/app/services/web-authn.service.ts create mode 100644 apps/client/src/app/util/rxjs.util.ts create mode 100644 prisma/migrations/20210612110542_added_auth_device/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index f9dd60efd..4a25526d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a counter column to the transactions table - Added a label to indicate the default account in the accounts table - Added an option to limit the items in pie charts +- Added sign in with fingerprint ### Changed diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 20f0b3103..f9644b2a0 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,5 +1,6 @@ import { join } from 'path'; +import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; @@ -34,6 +35,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + AuthDeviceModule, AuthModule, CacheModule, ConfigModule.forRoot(), diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts new file mode 100644 index 000000000..4c932bc5a --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -0,0 +1,44 @@ +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { + getPermissions, + hasPermission, + permissions +} from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; +import { + Controller, + Delete, + HttpException, + Inject, + Param, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Controller('auth-device') +export class AuthDeviceController { + public constructor( + private readonly authDeviceService: AuthDeviceService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + public async deleteAuthDevice(@Param('id') id: string): Promise { + if ( + !hasPermission( + getPermissions(this.request.user.role), + permissions.deleteAuthDevice + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + await this.authDeviceService.deleteAuthDevice({ id }); + } +} diff --git a/apps/api/src/app/auth-device/auth-device.dto.ts b/apps/api/src/app/auth-device/auth-device.dto.ts new file mode 100644 index 000000000..3be7f4cac --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.dto.ts @@ -0,0 +1,4 @@ +export interface AuthDeviceDto { + createdAt: string; + id: string; +} diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts new file mode 100644 index 000000000..360930cf2 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.module.ts @@ -0,0 +1,18 @@ +import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; + +@Module({ + controllers: [AuthDeviceController], + imports: [ + JwtModule.register({ + secret: process.env.JWT_SECRET_KEY, + signOptions: { expiresIn: '180 days' } + }) + ], + providers: [AuthDeviceService, 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 new file mode 100644 index 000000000..6d26bfb86 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.service.ts @@ -0,0 +1,65 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { AuthDevice, Prisma } from '@prisma/client'; + +@Injectable() +export class AuthDeviceService { + public constructor( + private readonly configurationService: ConfigurationService, + private prisma: PrismaService + ) {} + + public async authDevice( + where: Prisma.AuthDeviceWhereUniqueInput + ): Promise { + return this.prisma.authDevice.findUnique({ + where + }); + } + + public async authDevices(params: { + skip?: number; + take?: number; + cursor?: Prisma.AuthDeviceWhereUniqueInput; + where?: Prisma.AuthDeviceWhereInput; + orderBy?: Prisma.AuthDeviceOrderByInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prisma.authDevice.findMany({ + skip, + take, + cursor, + where, + orderBy + }); + } + + public async createAuthDevice( + data: Prisma.AuthDeviceCreateInput + ): Promise { + return this.prisma.authDevice.create({ + data + }); + } + + public async updateAuthDevice(params: { + data: Prisma.AuthDeviceUpdateInput; + where: Prisma.AuthDeviceWhereUniqueInput; + }): Promise { + const { data, where } = params; + + return this.prisma.authDevice.update({ + data, + where + }); + } + + public async deleteAuthDevice( + where: Prisma.AuthDeviceWhereUniqueInput + ): Promise { + return this.prisma.authDevice.delete({ + where + }); + } +} diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index cfafa080e..8e6ff846d 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,9 +1,12 @@ +import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { + Body, Controller, Get, HttpException, Param, + Post, Req, Res, UseGuards @@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AuthService } from './auth.service'; +import { + AssertionCredentialJSON, + AttestationCredentialJSON +} from './interfaces/simplewebauthn'; @Controller('auth') export class AuthController { public constructor( private readonly authService: AuthService, - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly webAuthService: WebAuthService ) {} @Get('anonymous/:accessToken') @@ -53,4 +61,44 @@ export class AuthController { res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); } } + + @Get('webauthn/generate-attestation-options') + @UseGuards(AuthGuard('jwt')) + public async generateAttestationOptions() { + return this.webAuthService.generateAttestationOptions(); + } + + @Post('webauthn/verify-attestation') + @UseGuards(AuthGuard('jwt')) + public async verifyAttestation( + @Body() body: { deviceName: string; credential: AttestationCredentialJSON } + ) { + return this.webAuthService.verifyAttestation( + body.deviceName, + body.credential + ); + } + + @Post('webauthn/generate-assertion-options') + public async generateAssertionOptions(@Body() body: { deviceId: string }) { + return this.webAuthService.generateAssertionOptions(body.deviceId); + } + + @Post('webauthn/verify-assertion') + public async verifyAssertion( + @Body() body: { deviceId: string; credential: AssertionCredentialJSON } + ) { + try { + const authToken = await this.webAuthService.verifyAssertion( + body.deviceId, + 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 0519e2509..9b8ac4c39 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,3 +1,5 @@ +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { Module } from '@nestjs/common'; @@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy'; }) ], providers: [ + AuthDeviceService, AuthService, ConfigurationService, GoogleStrategy, JwtStrategy, PrismaService, - UserService + UserService, + WebAuthService ] }) 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..c3fbc9236 100644 --- a/apps/api/src/app/auth/interfaces/interfaces.ts +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -1,5 +1,10 @@ +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { Provider } from '@prisma/client'; +export interface AuthDeviceDialogParams { + authDevice: AuthDeviceDto; +} + export interface ValidateOAuthLoginParams { provider: Provider; thirdPartyId: string; 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..4b9058e2f --- /dev/null +++ b/apps/api/src/app/auth/interfaces/simplewebauthn.ts @@ -0,0 +1,226 @@ +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< + PublicKeyCredentialCreationOptions, + 'challenge' | 'user' | 'excludeCredentials' + > { + 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< + PublicKeyCredentialRequestOptions, + 'challenge' | 'allowCredentials' + > { + 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< + AttestationCredential, + 'response' | 'rawId' | 'getClientExtensionResults' + > { + 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< + AssertionCredential, + 'response' | 'rawId' | 'getClientExtensionResults' + > { + 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< + AuthenticatorAttestationResponseFuture, + 'clientDataJSON' | 'attestationObject' + > { + 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< + AuthenticatorAssertionResponse, + 'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle' + > { + 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 new file mode 100644 index 000000000..7b3fd813a --- /dev/null +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -0,0 +1,215 @@ +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { RequestWithUser } from '@ghostfolio/common/types'; +import { + Inject, + Injectable, + InternalServerErrorException +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { + GenerateAssertionOptionsOpts, + GenerateAttestationOptionsOpts, + VerifiedAssertion, + VerifiedAttestation, + VerifyAssertionResponseOpts, + VerifyAttestationResponseOpts, + generateAssertionOptions, + generateAttestationOptions, + verifyAssertionResponse, + verifyAttestationResponse +} from '@simplewebauthn/server'; + +import { UserService } from '../user/user.service'; +import { + AssertionCredentialJSON, + AttestationCredentialJSON +} from './interfaces/simplewebauthn'; + +@Injectable() +export class WebAuthService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly deviceService: AuthDeviceService, + private readonly jwtService: JwtService, + private readonly userService: UserService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + get rpID() { + return this.configurationService.get('WEB_AUTH_RP_ID'); + } + + get expectedOrigin() { + return this.configurationService.get('ROOT_URL'); + } + + public async generateAttestationOptions() { + const user = this.request.user; + + const opts: GenerateAttestationOptionsOpts = { + rpName: 'Ghostfolio', + rpID: this.rpID, + userID: user.id, + userName: user.alias, + timeout: 60000, + attestationType: 'indirect', + authenticatorSelection: { + userVerification: 'preferred', + requireResidentKey: false + } + }; + + const options = generateAttestationOptions(opts); + + await this.userService.updateUser({ + data: { + authChallenge: options.challenge + }, + where: { + id: user.id + } + }); + + return options; + } + + public async verifyAttestation( + deviceName: string, + credential: AttestationCredentialJSON + ): Promise { + const user = this.request.user; + const expectedChallenge = user.authChallenge; + + let verification: VerifiedAttestation; + try { + const opts: VerifyAttestationResponseOpts = { + credential, + expectedChallenge, + expectedOrigin: this.expectedOrigin, + expectedRPID: this.rpID + }; + verification = await verifyAttestationResponse(opts); + } catch (error) { + console.error(error); + throw new InternalServerErrorException(error.message); + } + + const { verified, attestationInfo } = verification; + + const devices = await this.deviceService.authDevices({ + where: { userId: user.id } + }); + if (verified && attestationInfo) { + const { credentialPublicKey, credentialID, counter } = attestationInfo; + + let existingDevice = devices.find( + (device) => device.credentialId === credentialID + ); + + if (!existingDevice) { + /** + * Add the returned device to the user's list of devices + */ + existingDevice = await this.deviceService.createAuthDevice({ + credentialPublicKey, + credentialId: credentialID, + counter, + User: { connect: { id: user.id } } + }); + } + + return { + createdAt: existingDevice.createdAt.toISOString(), + id: existingDevice.id + }; + } + + throw new InternalServerErrorException('An unknown error occurred'); + } + + public async generateAssertionOptions(deviceId: string) { + const device = await this.deviceService.authDevice({ id: deviceId }); + + if (!device) { + throw new Error('Device not found'); + } + + const opts: GenerateAssertionOptionsOpts = { + timeout: 60000, + allowCredentials: [ + { + id: device.credentialId, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + } + ], + userVerification: 'preferred', + rpID: this.rpID + }; + + const options = generateAssertionOptions(opts); + + await this.userService.updateUser({ + data: { + authChallenge: options.challenge + }, + where: { + id: device.userId + } + }); + + return options; + } + + public async verifyAssertion( + deviceId: string, + credential: AssertionCredentialJSON + ) { + const device = await this.deviceService.authDevice({ id: deviceId }); + + if (!device) { + throw new Error('Device not found'); + } + + const user = await this.userService.user({ id: device.userId }); + + let verification: VerifiedAssertion; + try { + const opts: VerifyAssertionResponseOpts = { + credential, + expectedChallenge: `${user.authChallenge}`, + expectedOrigin: this.expectedOrigin, + expectedRPID: this.rpID, + authenticator: { + credentialID: device.credentialId, + credentialPublicKey: device.credentialPublicKey, + counter: device.counter + } + }; + verification = verifyAssertionResponse(opts); + } catch (error) { + console.error(error); + throw new InternalServerErrorException({ error: error.message }); + } + + const { verified, assertionInfo } = verification; + + if (verified) { + device.counter = assertionInfo.newCounter; + + await this.deviceService.updateAuthDevice({ + data: device, + where: { id: device.id } + }); + + return this.jwtService.sign({ + id: user.id + }); + } + + throw new Error(); + } +} diff --git a/apps/api/src/models/portfolio.spec.ts b/apps/api/src/models/portfolio.spec.ts index 3741b0fc2..5cd50d8ff 100644 --- a/apps/api/src/models/portfolio.spec.ts +++ b/apps/api/src/models/portfolio.spec.ts @@ -120,6 +120,7 @@ describe('Portfolio', () => { } ], alias: 'Test', + authChallenge: null, createdAt: new Date(), id: USER_ID, provider: null, diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index 0ba57989c..9a7a294e1 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, json, num, port, str } from 'envalid'; +import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { Environment } from './interfaces/environment.interface'; @@ -26,7 +26,8 @@ export class ConfigurationService { RAKUTEN_RAPID_API_KEY: str({ default: '' }), REDIS_HOST: str({ default: 'localhost' }), REDIS_PORT: port({ default: 6379 }), - ROOT_URL: str({ default: 'http://localhost:4200' }) + ROOT_URL: str({ default: 'http://localhost:4200' }), + WEB_AUTH_RP_ID: host({ default: 'localhost' }) }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 89ff79d33..fd67d34d7 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -18,4 +18,5 @@ export interface Environment extends CleanedEnvAccessors { REDIS_HOST: string; REDIS_PORT: number; ROOT_URL: string; + WEB_AUTH_RP_ID: string; } diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 5ce1ca873..6be6706a6 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -12,6 +12,7 @@ import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login- import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { EMPTY, Subject } from 'rxjs'; @@ -42,7 +43,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() @@ -87,7 +89,8 @@ export class HeaderComponent implements OnChanges { autoFocus: false, data: { accessToken: '', - hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin + hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin, + title: 'Sign in' }, width: '30rem' }); @@ -105,14 +108,14 @@ export class HeaderComponent implements OnChanges { takeUntil(this.unsubscribeSubject) ) .subscribe(({ authToken }) => { - this.setToken(authToken); + this.setToken(authToken, data.staySignedIn); }); } }); } - public setToken(aToken: string) { - this.tokenStorageService.saveToken(aToken); + public setToken(aToken: string, staySignedIn: boolean) { + this.tokenStorageService.saveToken(aToken, staySignedIn); this.router.navigate(['/']); } diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts index 51dd143e3..0de670f92 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; @Component({ selector: 'gf-login-with-access-token-dialog', @@ -8,7 +8,14 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog'; templateUrl: 'login-with-access-token-dialog.html' }) export class LoginWithAccessTokenDialog { - public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} + public constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any + ) {} ngOnInit() {} + + public onClose(): void { + this.dialogRef.close(); + } } diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 56fe52173..3040d5e08 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -1,4 +1,9 @@ -

Sign in

+ +
@@ -21,15 +26,21 @@
-
- - +
+
+ Stay signed in +
+
+ +
diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts index 201c66ca5..e36c96cd0 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts @@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module'; import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component'; @NgModule({ @@ -15,7 +17,9 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com imports: [ CommonModule, FormsModule, + GfDialogHeaderModule, MatButtonModule, + MatCheckboxModule, MatDialogModule, MatFormFieldModule, MatInputModule, diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.scss b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.scss index 39842634f..ea9627fa2 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.scss +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.scss @@ -1,5 +1,15 @@ :host { + display: block; + textarea.mat-input-element.cdk-textarea-autosize { box-sizing: content-box; } + + .mat-checkbox { + ::ng-deep { + label { + margin-bottom: 0; + } + } + } } diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 3f49e7891..3d807bfd3 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -2,12 +2,10 @@ import { HTTP_INTERCEPTORS, HttpErrorResponse, HttpEvent, - HttpResponse -} from '@angular/common/http'; -import { HttpHandler, HttpInterceptor, - HttpRequest + HttpRequest, + HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { @@ -16,6 +14,7 @@ import { TextOnlySnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { StatusCodes } from 'http-status-codes'; import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; @@ -29,7 +28,8 @@ export class HttpResponseInterceptor implements HttpInterceptor { public constructor( private router: Router, private tokenStorageService: TokenStorageService, - private snackBar: MatSnackBar + private snackBar: MatSnackBar, + private webAuthnService: WebAuthnService ) {} public intercept( @@ -78,7 +78,14 @@ export class HttpResponseInterceptor implements HttpInterceptor { }); } } else if (error.status === StatusCodes.UNAUTHORIZED) { - this.tokenStorageService.signOut(); + if (this.webAuthnService.isEnabled()) { + this.webAuthnService.login().subscribe(({ authToken }) => { + this.tokenStorageService.saveToken(authToken, false); + window.location.reload(); + }); + } else { + this.tokenStorageService.signOut(); + } } return throwError(''); 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..85ca88611 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -1,12 +1,23 @@ -import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { + MatSlideToggle, + MatSlideToggleChange +} from '@angular/material/slide-toggle'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; 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 { EMPTY, Subject } from 'rxjs'; +import { catchError, takeUntil } from 'rxjs/operators'; @Component({ selector: 'gf-account-page', @@ -14,6 +25,9 @@ import { takeUntil } from 'rxjs/operators'; styleUrls: ['./account-page.scss'] }) export class AccountPageComponent implements OnDestroy, OnInit { + @ViewChild('toggleSignInWithFingerprintEnabledElement') + signInWithFingerprintElement: MatSlideToggle; + public accesses: Access[]; public baseCurrency: Currency; public currencies: Currency[] = []; @@ -29,7 +43,8 @@ export class AccountPageComponent implements OnDestroy, OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, - private userService: UserService + private userService: UserService, + public webAuthnService: WebAuthnService ) { this.dataService .fetchInfo() @@ -84,11 +99,57 @@ export class AccountPageComponent implements OnDestroy, OnInit { }); } + public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) { + if (aEvent.checked) { + this.registerDevice(); + } else { + const confirmation = confirm( + 'Do you really want to remove this sign in method?' + ); + + if (confirmation) { + this.deregisterDevice(); + } else { + this.update(); + } + } + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + private deregisterDevice() { + this.webAuthnService + .deregister() + .pipe( + catchError(() => { + this.update(); + + return EMPTY; + }) + ) + .subscribe(() => { + this.update(); + }); + } + + private registerDevice() { + this.webAuthnService + .register() + .pipe( + catchError(() => { + this.update(); + + return EMPTY; + }) + ) + .subscribe(() => { + this.update(); + }); + } + private update() { this.dataService .fetchAccesses() @@ -96,6 +157,11 @@ export class AccountPageComponent implements OnDestroy, OnInit { .subscribe((response) => { this.accesses = response; + if (this.signInWithFingerprintElement) { + this.signInWithFingerprintElement.checked = + this.webAuthnService.isEnabled() ?? false; + } + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 3271f9934..b8caf2c40 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -66,6 +66,16 @@
+
+
Sign in with fingerprint
+
+ +
+
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..8e7cddec5 100644 --- a/apps/client/src/app/pages/account/account-page.module.ts +++ b/apps/client/src/app/pages/account/account-page.module.ts @@ -3,8 +3,11 @@ import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { AccountPageRoutingModule } from './account-page-routing.module'; @@ -20,8 +23,11 @@ import { AccountPageComponent } from './account-page.component'; GfPortfolioAccessTableModule, MatButtonModule, MatCardModule, + MatDialogModule, MatFormFieldModule, + MatInputModule, MatSelectModule, + MatSlideToggleModule, ReactiveFormsModule ], providers: [] 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..a8b1e3c6d 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { format } from 'date-fns'; import { Subject } from 'rxjs'; @@ -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 ) {} /** 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..1fd409926 100644 --- a/apps/client/src/app/services/token-storage.service.ts +++ b/apps/client/src/app/services/token-storage.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { UserService } from './user/user.service'; @@ -8,21 +9,34 @@ const TOKEN_KEY = 'auth-token'; providedIn: 'root' }) export class TokenStorageService { - public constructor(private userService: UserService) {} + public constructor( + private userService: UserService, + private webAuthnService: WebAuthnService + ) {} public getToken(): string { - return window.localStorage.getItem(TOKEN_KEY); + return ( + window.sessionStorage.getItem(TOKEN_KEY) || + window.localStorage.getItem(TOKEN_KEY) + ); } - public saveToken(token: string): void { - window.localStorage.removeItem(TOKEN_KEY); - window.localStorage.setItem(TOKEN_KEY, token); + public saveToken(token: string, staySignedIn: boolean = false): void { + if (staySignedIn) { + window.localStorage.setItem(TOKEN_KEY, token); + } + window.sessionStorage.setItem(TOKEN_KEY, token); } public signOut(): void { const utmSource = window.localStorage.getItem('utm_source'); + if (this.webAuthnService.isEnabled()) { + this.webAuthnService.deregister().subscribe(); + } + window.localStorage.clear(); + window.sessionStorage.clear(); this.userService.remove(); 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..520202960 --- /dev/null +++ b/apps/client/src/app/services/web-authn.service.ts @@ -0,0 +1,104 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; +import { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON +} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn'; +import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; +import { startAssertion, startAttestation } from '@simplewebauthn/browser'; +import { of } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class WebAuthnService { + private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID'; + + public constructor( + private http: HttpClient, + private settingsStorageService: SettingsStorageService + ) {} + + public isSupported() { + return typeof PublicKeyCredential !== 'undefined'; + } + + public isEnabled() { + return !!this.getDeviceId(); + } + + public register() { + return this.http + .get( + `/api/auth/webauthn/generate-attestation-options`, + {} + ) + .pipe( + catchError((error) => { + console.warn('Could not register device', error); + return of(null); + }), + switchMap((attOps) => { + return startAttestation(attOps); + }), + switchMap((attResp) => { + return this.http.post( + `/api/auth/webauthn/verify-attestation`, + { + credential: attResp + } + ); + }), + tap((authDevice) => + this.settingsStorageService.setSetting( + WebAuthnService.WEB_AUTH_N_DEVICE_ID, + authDevice.id + ) + ) + ); + } + + public deregister() { + const deviceId = this.getDeviceId(); + return this.http.delete(`/api/auth-device/${deviceId}`).pipe( + catchError((error) => { + console.warn(`Could not deregister device ${deviceId}`, error); + return of(null); + }), + tap(() => + this.settingsStorageService.removeSetting( + WebAuthnService.WEB_AUTH_N_DEVICE_ID + ) + ) + ); + } + + public login() { + const deviceId = this.getDeviceId(); + return this.http + .post( + `/api/auth/webauthn/generate-assertion-options`, + { deviceId } + ) + .pipe( + switchMap(startAssertion), + switchMap((assertionResponse) => { + return this.http.post<{ authToken: string }>( + `/api/auth/webauthn/verify-assertion`, + { + credential: assertionResponse, + deviceId + } + ); + }) + ); + } + + private getDeviceId() { + return this.settingsStorageService.getSetting( + WebAuthnService.WEB_AUTH_N_DEVICE_ID + ); + } +} diff --git a/apps/client/src/app/util/rxjs.util.ts b/apps/client/src/app/util/rxjs.util.ts new file mode 100644 index 000000000..09fe1cac6 --- /dev/null +++ b/apps/client/src/app/util/rxjs.util.ts @@ -0,0 +1,3 @@ +export function isNonNull(value: T): value is NonNullable { + return value != null; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index dc14cbb5a..bbe0806a7 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -11,12 +11,14 @@ export const permissions = { createOrder: 'createOrder', createUserAccount: 'createUserAccount', deleteAccount: 'deleteAcccount', + deleteAuthDevice: 'deleteAuthDevice', deleteOrder: 'deleteOrder', deleteUser: 'deleteUser', enableSocialLogin: 'enableSocialLogin', enableSubscription: 'enableSubscription', readForeignPortfolio: 'readForeignPortfolio', updateAccount: 'updateAccount', + updateAuthDevice: 'updateAuthDevice', updateOrder: 'updateOrder', updateUserSettings: 'updateUserSettings' }; @@ -36,10 +38,12 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createOrder, permissions.deleteAccount, + permissions.deleteAuthDevice, permissions.deleteOrder, permissions.deleteUser, permissions.readForeignPortfolio, permissions.updateAccount, + permissions.updateAuthDevice, permissions.updateOrder, permissions.updateUserSettings ]; @@ -52,8 +56,10 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createOrder, permissions.deleteAccount, + permissions.deleteAuthDevice, permissions.deleteOrder, permissions.updateAccount, + permissions.updateAuthDevice, permissions.updateOrder, permissions.updateUserSettings ]; diff --git a/package.json b/package.json index 50615c842..b9b2c4699 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "@nestjs/serve-static": "2.1.4", "@nrwl/angular": "12.0.0", "@prisma/client": "2.24.1", + "@simplewebauthn/browser": "3.0.0", + "@simplewebauthn/server": "3.0.0", + "@simplewebauthn/typescript-types": "3.0.0", "@types/lodash": "4.14.168", "alphavantage": "2.2.0", "angular-material-css-vars": "1.1.2", diff --git a/prisma/migrations/20210612110542_added_auth_device/migration.sql b/prisma/migrations/20210612110542_added_auth_device/migration.sql new file mode 100644 index 000000000..28d8d7c25 --- /dev/null +++ b/prisma/migrations/20210612110542_added_auth_device/migration.sql @@ -0,0 +1,18 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "authChallenge" TEXT; + +-- CreateTable +CREATE TABLE "AuthDevice" ( + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "credentialId" BYTEA NOT NULL, + "credentialPublicKey" BYTEA NOT NULL, + "counter" INTEGER NOT NULL, + "id" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "AuthDevice" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7cf7c2fa6..f7ed0bf47 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,17 @@ model Analytics { userId String @id } +model AuthDevice { + createdAt DateTime @default(now()) + credentialId Bytes + credentialPublicKey Bytes + counter Int + id String @id @default(uuid()) + updatedAt DateTime @updatedAt + User User @relation(fields: [userId], references: [id]) + userId String +} + model MarketData { createdAt DateTime @default(now()) date DateTime @@ -126,21 +137,23 @@ model Subscription { } model User { - Access Access[] @relation("accessGet") - AccessGive Access[] @relation(name: "accessGive") - accessToken String? - Account Account[] - alias String? - Analytics Analytics? - createdAt DateTime @default(now()) - id String @id @default(uuid()) - Order Order[] - provider Provider? - role Role @default(USER) - Settings Settings? - Subscription Subscription[] - thirdPartyId String? - updatedAt DateTime @updatedAt + Access Access[] @relation("accessGet") + AccessGive Access[] @relation(name: "accessGive") + accessToken String? + Account Account[] + alias String? + Analytics Analytics? + authChallenge String? + AuthDevice AuthDevice[] + createdAt DateTime @default(now()) + id String @id @default(uuid()) + Order Order[] + provider Provider? + role Role @default(USER) + Settings Settings? + Subscription Subscription[] + thirdPartyId String? + updatedAt DateTime @updatedAt } enum AccountType { diff --git a/yarn.lock b/yarn.lock index 8ae8527a9..e599d41ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2081,6 +2081,36 @@ consola "^2.15.0" node-fetch "^2.6.1" +"@peculiar/asn1-android@^2.0.26": + version "2.0.36" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.36.tgz#8c7f9025b04850620afcee8fdcd418295730cd48" + integrity sha512-8Ul9zVgqDR2H2DoWPeJYFqbDQBTceZVaVqy06fo/PB7YiDdXcPQJL1b29fs6n4wKj70PVz6JhlwKgHJby1EWTQ== + dependencies: + "@peculiar/asn1-schema" "^2.0.36" + asn1js "^2.1.1" + tslib "^2.2.0" + +"@peculiar/asn1-schema@^2.0.26", "@peculiar/asn1-schema@^2.0.36": + version "2.0.36" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.36.tgz#ca7978f43ffa4f35fbb74436c3f983c10a69ac27" + integrity sha512-x7fdMR6bzOBct2a0PLukrmVrrehHX5uisKRDWN2Bs1HojXd5nCi7MAQeV+umRxPK1oSJDstTBhGq3sLzDbL8Vw== + dependencies: + "@types/asn1js" "^2.0.0" + asn1js "^2.1.1" + pvtsutils "^1.1.7" + tslib "^2.2.0" + +"@peculiar/asn1-x509@^2.0.26": + version "2.0.36" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.36.tgz#fc1ba09d359e3fbf8062e29fc659393a14086ada" + integrity sha512-E7+7Y3tp524/tzODhELIxme636wx2JNrtHqPOTJb1a67gSYjNQrO4MXWaHccFxgCgkTa8eWoVnOkhX4lPT4j2w== + dependencies: + "@peculiar/asn1-schema" "^2.0.36" + asn1js "^2.1.1" + ipaddr.js "^2.0.0" + pvtsutils "^1.1.7" + tslib "^2.2.0" + "@prisma/client@2.24.1": version "2.24.1" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.24.1.tgz#c4f26fb4d768dd52dd20a17e626f10e69cc0b85c" @@ -2137,6 +2167,33 @@ semver "7.3.4" semver-intersect "1.4.0" +"@simplewebauthn/browser@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-3.0.0.tgz#3d76b199c9f474408a7ed75d86004423dd6ae38a" + integrity sha512-P661gZX/QW0Rg2NRAMtW84Q3u4nhXkPef9LLU4btLJFYoXO8RBFfxcmyqwyf2QEb4B7+lFdp5EWfZV5T7FvuHw== + +"@simplewebauthn/server@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-3.0.0.tgz#eb1a5bbe2ecdda54363b178f4bb3e134f25641f0" + integrity sha512-ymGX2obBrhY9R3OxrpCYaNGAovFHmMlQrGoNdVOe2R2JUBXC1Rg5JEUl1lGyaRykN1SyZqLgz86wAjDVuRITTA== + dependencies: + "@peculiar/asn1-android" "^2.0.26" + "@peculiar/asn1-schema" "^2.0.26" + "@peculiar/asn1-x509" "^2.0.26" + "@simplewebauthn/typescript-types" "^3.0.0" + base64url "^3.0.1" + cbor "^5.1.0" + elliptic "^6.5.3" + jsrsasign "^10.2.0" + jwk-to-pem "^2.0.4" + node-fetch "^2.6.0" + node-rsa "^1.1.1" + +"@simplewebauthn/typescript-types@3.0.0", "@simplewebauthn/typescript-types@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-3.0.0.tgz#6712e9619d860f54f571cd27dbe167b2d9e5ab87" + integrity sha512-bsk3EQWzPOZwP9C+ETVhcFDpZywY5sTqmNuGkNm3aNpc9Xh/mqZjy8nL0Sm7xwrlhY0zWAlOaIWQ3LvN5SoFhg== + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -2161,6 +2218,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/asn1js@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/asn1js/-/asn1js-2.0.0.tgz#10ca75692575744d0117098148a8dc84cbee6682" + integrity sha512-Jjzp5EqU0hNpADctc/UqhiFbY1y2MqIxBVa2S4dBlbnZHTLPMuggoL5q43X63LpsOIINRDirBjP56DUUKIUWIA== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.14" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402" @@ -3254,7 +3316,7 @@ array.prototype.flat@^1.2.3: define-properties "^1.1.3" es-abstract "^1.18.0-next.1" -asn1.js@^5.2.0: +asn1.js@^5.2.0, asn1.js@^5.3.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== @@ -3264,13 +3326,20 @@ asn1.js@^5.2.0: minimalistic-assert "^1.0.0" safer-buffer "^2.1.0" -asn1@~0.2.3: +asn1@^0.2.4, asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== dependencies: safer-buffer "~2.1.0" +asn1js@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-2.1.1.tgz#bb3896191ebb5fb1caeda73436a6c6e20a2eedff" + integrity sha512-t9u0dU0rJN4ML+uxgN6VM2Z4H5jWIYm0w8LsZLzMJaQsgL3IJNbxHgmbWDvJAwspyHpDFuzUaUFh4c05UB4+6g== + dependencies: + pvutils latest + assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" @@ -3503,7 +3572,7 @@ base64-js@^1.0.2, base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64url@3.x.x: +base64url@3.x.x, base64url@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== @@ -3547,6 +3616,11 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bignumber.js@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" + integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA== + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -4025,6 +4099,14 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +cbor@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/cbor/-/cbor-5.2.0.tgz#4cca67783ccd6de7b50ab4ed62636712f287a67c" + integrity sha512-5IMhi9e1QU76ppa5/ajP1BmMWZ2FHkhAhjeVKQ/EFCgYSEaeVaoGtL7cxJskf9oCCk+XjzaIdc3IuU/dbA/o2A== + dependencies: + bignumber.js "^9.0.1" + nofilter "^1.0.4" + chalk@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -5505,7 +5587,7 @@ elegant-spinner@^1.0.1: resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= -elliptic@^6.5.3: +elliptic@^6.5.3, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== @@ -7389,6 +7471,11 @@ ipaddr.js@1.9.1, ipaddr.js@^1.9.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +ipaddr.js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.0.tgz#77ccccc8063ae71ab65c55f21b090698e763fc6e" + integrity sha512-S54H9mIj0rbxRIyrDMEuuER86LdlgUg9FSeZ8duQb6CUG2iRrA36MYVQBSprTF/ZeAwvyQ5mDGuNvIPM0BIl3w== + is-absolute-url@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" @@ -8406,6 +8493,11 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jsrsasign@^10.2.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.3.0.tgz#540d7c6937da1d5b01699d5091e56378a33e246e" + integrity sha512-irDIKKFW++EAELgP3fjFi5/Fn0XEyfuQTTgpbeFwCGkV6tRIYZl3uraRea2HTXWCstcSZuDaCbdAhU1n+075Bg== + jwa@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -8415,6 +8507,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwk-to-pem@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906" + integrity sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A== + dependencies: + asn1.js "^5.3.0" + elliptic "^6.5.4" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -9358,7 +9459,7 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-fetch@2.6.1, node-fetch@^2.6.1: +node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== @@ -9440,6 +9541,18 @@ node-releases@^1.1.71: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb" integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg== +node-rsa@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.1.1.tgz#efd9ad382097782f506153398496f79e4464434d" + integrity sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw== + dependencies: + asn1 "^0.2.4" + +nofilter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-1.0.4.tgz#78d6f4b6a613e7ced8b015cec534625f7667006e" + integrity sha512-N8lidFp+fCz+TD51+haYdbDGrcBWwuHX40F5+z0qkUjMJ5Tp+rdSuAkMJ9N9eoolDlEVTf6u5icM+cNKkKW2mA== + nopt@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" @@ -10830,6 +10943,18 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pvtsutils@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.1.7.tgz#39a65ccb3b7448c974f6a6141ce2aad037b3f13c" + integrity sha512-faOiD/XpB/cIebRzYwzYjCmYgiDd53YEBni+Mt1+8/HlrARHYBpsU2OHOt3EZ1ZhfRNxPL0dH3K/vKaMgNWVGA== + dependencies: + tslib "^2.2.0" + +pvutils@latest: + version "1.0.17" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.0.17.tgz#ade3c74dfe7178944fe44806626bd2e249d996bf" + integrity sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ== + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -12781,7 +12906,7 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.1.0: +tslib@^2.0.0, tslib@^2.1.0, tslib@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==