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
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 { |
:host { |
||||
|
display: block; |
||||
|
|
||||
textarea.mat-input-element.cdk-textarea-autosize { |
textarea.mat-input-element.cdk-textarea-autosize { |
||||
box-sizing: content-box; |
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