mirror of https://github.com/ghostfolio/ghostfolio
				
				
			
			
			
				Browse Source
			
			
			
			
				
		* 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 <mfrey43@gmail.com> Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>pull/160/head
							committed by
							
								 GitHub
								GitHub
							
						
					
				
				 33 changed files with 1111 additions and 60 deletions
			
			
		| @ -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<void> { | |||
|     if ( | |||
|       !hasPermission( | |||
|         getPermissions(this.request.user.role), | |||
|         permissions.deleteAuthDevice | |||
|       ) | |||
|     ) { | |||
|       throw new HttpException( | |||
|         getReasonPhrase(StatusCodes.FORBIDDEN), | |||
|         StatusCodes.FORBIDDEN | |||
|       ); | |||
|     } | |||
| 
 | |||
|     await this.authDeviceService.deleteAuthDevice({ id }); | |||
|   } | |||
| } | |||
| @ -0,0 +1,4 @@ | |||
| export interface AuthDeviceDto { | |||
|   createdAt: string; | |||
|   id: string; | |||
| } | |||
| @ -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 {} | |||
| @ -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<AuthDevice | null> { | |||
|     return this.prisma.authDevice.findUnique({ | |||
|       where | |||
|     }); | |||
|   } | |||
| 
 | |||
|   public async authDevices(params: { | |||
|     skip?: number; | |||
|     take?: number; | |||
|     cursor?: Prisma.AuthDeviceWhereUniqueInput; | |||
|     where?: Prisma.AuthDeviceWhereInput; | |||
|     orderBy?: Prisma.AuthDeviceOrderByInput; | |||
|   }): Promise<AuthDevice[]> { | |||
|     const { skip, take, cursor, where, orderBy } = params; | |||
|     return this.prisma.authDevice.findMany({ | |||
|       skip, | |||
|       take, | |||
|       cursor, | |||
|       where, | |||
|       orderBy | |||
|     }); | |||
|   } | |||
| 
 | |||
|   public async createAuthDevice( | |||
|     data: Prisma.AuthDeviceCreateInput | |||
|   ): Promise<AuthDevice> { | |||
|     return this.prisma.authDevice.create({ | |||
|       data | |||
|     }); | |||
|   } | |||
| 
 | |||
|   public async updateAuthDevice(params: { | |||
|     data: Prisma.AuthDeviceUpdateInput; | |||
|     where: Prisma.AuthDeviceWhereUniqueInput; | |||
|   }): Promise<AuthDevice> { | |||
|     const { data, where } = params; | |||
| 
 | |||
|     return this.prisma.authDevice.update({ | |||
|       data, | |||
|       where | |||
|     }); | |||
|   } | |||
| 
 | |||
|   public async deleteAuthDevice( | |||
|     where: Prisma.AuthDeviceWhereUniqueInput | |||
|   ): Promise<AuthDevice> { | |||
|     return this.prisma.authDevice.delete({ | |||
|       where | |||
|     }); | |||
|   } | |||
| } | |||
| @ -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<PublicKeyCredentialDescriptor, 'id'> { | |||
|   id: Base64URLString; | |||
| } | |||
| export interface PublicKeyCredentialUserEntityJSON | |||
|   extends Omit<PublicKeyCredentialUserEntity, 'id'> { | |||
|   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[]; | |||
| } | |||
| @ -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<AuthDeviceDto> { | |||
|     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(); | |||
|   } | |||
| } | |||
| @ -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; | |||
|       } | |||
|     } | |||
|   } | |||
| } | |||
|  | |||
| @ -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<PublicKeyCredentialCreationOptionsJSON>( | |||
|         `/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<AuthDeviceDto>( | |||
|             `/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<AuthDeviceDto>(`/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<PublicKeyCredentialRequestOptionsJSON>( | |||
|         `/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 | |||
|     ); | |||
|   } | |||
| } | |||
| @ -0,0 +1,3 @@ | |||
| export function isNonNull<T>(value: T): value is NonNullable<T> { | |||
|   return value != null; | |||
| } | |||
| @ -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; | |||
					Loading…
					
					
				
		Reference in new issue