Browse Source

Format code

pull/82/head
Matthias Frey 4 years ago
committed by Thomas
parent
commit
d3662888a2
  1. 40
      apps/api/src/app/auth-device/auth-device.controller.ts
  2. 6
      apps/api/src/app/auth-device/auth-device.module.ts
  3. 12
      apps/api/src/app/auth-device/auth-device.service.ts
  4. 37
      apps/api/src/app/auth/auth.controller.ts
  5. 2
      apps/api/src/app/auth/auth.module.ts
  6. 2
      apps/api/src/app/auth/interfaces/interfaces.ts
  7. 73
      apps/api/src/app/auth/interfaces/simplewebauthn.ts
  8. 93
      apps/api/src/app/auth/web-auth.service.ts
  9. 2
      apps/api/src/services/configuration.service.ts
  10. 11
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html
  11. 27
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts
  12. 4
      apps/client/src/app/components/header/header.component.ts
  13. 2
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts
  14. 27
      apps/client/src/app/pages/account/account-page.component.ts
  15. 30
      apps/client/src/app/pages/account/account-page.html
  16. 7
      apps/client/src/app/pages/account/account-page.module.ts
  17. 13
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html
  18. 8
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts
  19. 2
      apps/client/src/app/pages/landing/landing-page.component.ts
  20. 5
      apps/client/src/app/services/token-storage.service.ts
  21. 79
      apps/client/src/app/services/web-authn.service.ts

40
apps/api/src/app/auth-device/auth-device.controller.ts

@ -1,19 +1,32 @@
import { Body, Controller, Delete, Get, HttpException, Inject, Param, Put, UseGuards } from '@nestjs/common'; import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/common/permissions'; import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor( public constructor(
private readonly authDeviceService: AuthDeviceService, private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) { ) {}
}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ -30,19 +43,20 @@ export class AuthDeviceController {
); );
} }
await this.authDeviceService.deleteAuthDevice( await this.authDeviceService.deleteAuthDevice({
{
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
} });
);
} }
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateAuthDevice(@Param('id') id: string, @Body() data: AuthDeviceDto) { public async updateAuthDevice(
@Param('id') id: string,
@Body() data: AuthDeviceDto
) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), getPermissions(this.request.user.role),
@ -69,8 +83,7 @@ export class AuthDeviceController {
); );
} }
return this.authDeviceService.updateAuthDevice( return this.authDeviceService.updateAuthDevice({
{
data: { data: {
name: data.name name: data.name
}, },
@ -80,8 +93,7 @@ export class AuthDeviceController {
userId: this.request.user.id userId: this.request.user.id
} }
} }
} });
);
} }
@Get() @Get()
@ -92,7 +104,7 @@ export class AuthDeviceController {
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return authDevices.map(authDevice => ({ return authDevices.map((authDevice) => ({
createdAt: authDevice.createdAt.toISOString(), createdAt: authDevice.createdAt.toISOString(),
id: authDevice.id, id: authDevice.id,
name: authDevice.name name: authDevice.name

6
apps/api/src/app/auth-device/auth-device.module.ts

@ -13,10 +13,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}) })
], ],
providers: [ providers: [AuthDeviceService, ConfigurationService, PrismaService]
AuthDeviceService,
ConfigurationService,
PrismaService,
]
}) })
export class AuthDeviceModule {} export class AuthDeviceModule {}

12
apps/api/src/app/auth-device/auth-device.service.ts

@ -5,12 +5,10 @@ import { AuthDevice, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AuthDeviceService { export class AuthDeviceService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private prisma: PrismaService
) { ) {}
}
public async authDevice( public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput
@ -45,12 +43,10 @@ export class AuthDeviceService {
}); });
} }
public async updateAuthDevice( public async updateAuthDevice(params: {
params: {
data: Prisma.AuthDeviceUpdateInput; data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput; where: Prisma.AuthDeviceWhereUniqueInput;
}, }): Promise<AuthDevice> {
): Promise<AuthDevice> {
const { data, where } = params; const { data, where } = params;
return this.prisma.authDevice.update({ return this.prisma.authDevice.update({
@ -60,7 +56,7 @@ export class AuthDeviceService {
} }
public async deleteAuthDevice( public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput, where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> { ): Promise<AuthDevice> {
return this.prisma.authDevice.delete({ return this.prisma.authDevice.delete({
where where

37
apps/api/src/app/auth/auth.controller.ts

@ -1,17 +1,30 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Body, Controller, Get, HttpException, Param, Post, Req, Res, UseGuards } from '@nestjs/common'; import {
Body,
Controller,
Get,
HttpException,
Param,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { AssertionCredentialJSON, AttestationCredentialJSON } from './interfaces/simplewebauthn'; import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly webAuthService: WebAuthService, private readonly webAuthService: WebAuthService
) {} ) {}
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@ -56,8 +69,13 @@ export class AuthController {
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async verifyAttestation(@Body() body: { deviceName: string, credential: AttestationCredentialJSON }) { public async verifyAttestation(
return this.webAuthService.verifyAttestation(body.deviceName, body.credential); @Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {
return this.webAuthService.verifyAttestation(
body.deviceName,
body.credential
);
} }
@Post('webauthn/generate-assertion-options') @Post('webauthn/generate-assertion-options')
@ -66,9 +84,14 @@ export class AuthController {
} }
@Post('webauthn/verify-assertion') @Post('webauthn/verify-assertion')
public async verifyAssertion(@Body() body: { userId: string, credential: AssertionCredentialJSON }) { public async verifyAssertion(
@Body() body: { userId: string; credential: AssertionCredentialJSON }
) {
try { try {
const authToken = await this.webAuthService.verifyAssertion(body.userId, body.credential); const authToken = await this.webAuthService.verifyAssertion(
body.userId,
body.credential
);
return { authToken }; return { authToken };
} catch { } catch {
throw new HttpException( throw new HttpException(

2
apps/api/src/app/auth/auth.module.ts

@ -27,7 +27,7 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService, UserService,
WebAuthService, WebAuthService
] ]
}) })
export class AuthModule {} export class AuthModule {}

2
apps/api/src/app/auth/interfaces/interfaces.ts

@ -2,7 +2,7 @@ import { Provider } from '@prisma/client';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
export interface AuthDeviceDialogParams { export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto, authDevice: AuthDeviceDto;
} }
export interface ValidateOAuthLoginParams { export interface ValidateOAuthLoginParams {

73
apps/api/src/app/auth/interfaces/simplewebauthn.ts

@ -3,7 +3,8 @@ export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly signature: ArrayBuffer; readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null; readonly userHandle: ArrayBuffer | null;
} }
export interface AuthenticatorAttestationResponse extends AuthenticatorResponse { export interface AuthenticatorAttestationResponse
extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer; readonly attestationObject: ArrayBuffer;
} }
export interface AuthenticationExtensionsClientInputs { export interface AuthenticationExtensionsClientInputs {
@ -56,7 +57,8 @@ export interface PublicKeyCredentialRequestOptions {
timeout?: number; timeout?: number;
userVerification?: UserVerificationRequirement; userVerification?: UserVerificationRequirement;
} }
export interface PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity { export interface PublicKeyCredentialUserEntity
extends PublicKeyCredentialEntity {
displayName: string; displayName: string;
id: BufferSource; id: BufferSource;
} }
@ -76,18 +78,32 @@ export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
export interface PublicKeyCredentialEntity { export interface PublicKeyCredentialEntity {
name: string; name: string;
} }
export declare type AttestationConveyancePreference = "direct" | "enterprise" | "indirect" | "none"; export declare type AttestationConveyancePreference =
export declare type AuthenticatorTransport = "ble" | "internal" | "nfc" | "usb"; | 'direct'
| 'enterprise'
| 'indirect'
| 'none';
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
export declare type COSEAlgorithmIdentifier = number; export declare type COSEAlgorithmIdentifier = number;
export declare type UserVerificationRequirement = "discouraged" | "preferred" | "required"; export declare type UserVerificationRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type UvmEntries = UvmEntry[]; export declare type UvmEntries = UvmEntry[];
export declare type AuthenticatorAttachment = "cross-platform" | "platform"; export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
export declare type ResidentKeyRequirement = "discouraged" | "preferred" | "required"; export declare type ResidentKeyRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type BufferSource = ArrayBufferView | ArrayBuffer; export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = "public-key"; export declare type PublicKeyCredentialType = 'public-key';
export declare type UvmEntry = number[]; export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON extends Omit<PublicKeyCredentialCreationOptions, 'challenge' | 'user' | 'excludeCredentials'> { export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
user: PublicKeyCredentialUserEntityJSON; user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString; challenge: Base64URLString;
excludeCredentials: PublicKeyCredentialDescriptorJSON[]; excludeCredentials: PublicKeyCredentialDescriptorJSON[];
@ -97,15 +113,21 @@ export interface PublicKeyCredentialCreationOptionsJSON extends Omit<PublicKeyCr
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to * A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
* (eventually) get passed into navigator.credentials.get(...) in the browser. * (eventually) get passed into navigator.credentials.get(...) in the browser.
*/ */
export interface PublicKeyCredentialRequestOptionsJSON extends Omit<PublicKeyCredentialRequestOptions, 'challenge' | 'allowCredentials'> { export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
challenge: Base64URLString; challenge: Base64URLString;
allowCredentials?: PublicKeyCredentialDescriptorJSON[]; allowCredentials?: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs; extensions?: AuthenticationExtensionsClientInputs;
} }
export interface PublicKeyCredentialDescriptorJSON extends Omit<PublicKeyCredentialDescriptor, 'id'> { export interface PublicKeyCredentialDescriptorJSON
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
id: Base64URLString; id: Base64URLString;
} }
export interface PublicKeyCredentialUserEntityJSON extends Omit<PublicKeyCredentialUserEntity, 'id'> { export interface PublicKeyCredentialUserEntityJSON
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
id: string; id: string;
} }
/** /**
@ -118,7 +140,11 @@ export interface AttestationCredential extends PublicKeyCredential {
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that * 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. * 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'> { export interface AttestationCredentialJSON
extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString; rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON; response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs; clientExtensionResults: AuthenticationExtensionsClientOutputs;
@ -134,7 +160,11 @@ export interface AssertionCredential extends PublicKeyCredential {
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that * 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. * 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'> { export interface AssertionCredentialJSON
extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString; rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON; response: AuthenticatorAssertionResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs; clientExtensionResults: AuthenticationExtensionsClientOutputs;
@ -143,7 +173,11 @@ export interface AssertionCredentialJSON extends Omit<AssertionCredential, 'resp
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that * 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. * 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'> { export interface AuthenticatorAttestationResponseJSON
extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
clientDataJSON: Base64URLString; clientDataJSON: Base64URLString;
attestationObject: Base64URLString; attestationObject: Base64URLString;
} }
@ -151,7 +185,11 @@ export interface AuthenticatorAttestationResponseJSON extends Omit<Authenticator
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that * 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. * 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'> { export interface AuthenticatorAssertionResponseJSON
extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
authenticatorData: Base64URLString; authenticatorData: Base64URLString;
clientDataJSON: Base64URLString; clientDataJSON: Base64URLString;
signature: Base64URLString; signature: Base64URLString;
@ -179,7 +217,8 @@ export declare type Base64URLString = string;
* *
* Properties marked optional are not supported in all browsers. * Properties marked optional are not supported in all browsers.
*/ */
export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse { export interface AuthenticatorAttestationResponseFuture
extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[]; getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer; getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer; getPublicKey?: () => ArrayBuffer;

93
apps/api/src/app/auth/web-auth.service.ts

@ -1,5 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; import {
Inject,
Injectable,
InternalServerErrorException
} from '@nestjs/common';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { import {
generateAssertionOptions, generateAssertionOptions,
@ -14,7 +18,10 @@ import {
VerifyAttestationResponseOpts VerifyAttestationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AssertionCredentialJSON, AttestationCredentialJSON } from './interfaces/simplewebauthn'; import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import base64url from 'base64url'; import base64url from 'base64url';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -28,7 +35,7 @@ export class WebAuthService {
private readonly deviceService: AuthDeviceService, private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly userService: UserService, private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
get rpID() { get rpID() {
@ -41,7 +48,9 @@ export class WebAuthService {
public async generateAttestationOptions() { public async generateAttestationOptions() {
const user = this.request.user; const user = this.request.user;
const devices = await this.deviceService.authDevices({where: {userId: user.id}}); const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
const opts: GenerateAttestationOptionsOpts = { const opts: GenerateAttestationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
@ -56,10 +65,10 @@ export class WebAuthService {
* the browser if it's asked to perform an attestation when one of these ID's already resides * the browser if it's asked to perform an attestation when one of these ID's already resides
* on it. * on it.
*/ */
excludeCredentials: devices.map(device => ({ excludeCredentials: devices.map((device) => ({
id: device.credentialId, id: device.credentialId,
type: 'public-key', type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal'], transports: ['usb', 'ble', 'nfc', 'internal']
})), })),
/** /**
* The optional authenticatorSelection property allows for specifying more constraints around * The optional authenticatorSelection property allows for specifying more constraints around
@ -67,8 +76,8 @@ export class WebAuthService {
*/ */
authenticatorSelection: { authenticatorSelection: {
userVerification: 'preferred', userVerification: 'preferred',
requireResidentKey: false, requireResidentKey: false
}, }
}; };
const options = generateAttestationOptions(opts); const options = generateAttestationOptions(opts);
@ -79,18 +88,20 @@ export class WebAuthService {
*/ */
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
authChallenge: options.challenge, authChallenge: options.challenge
}, },
where: { where: {
id: user.id, id: user.id
} }
}) });
return options; return options;
} }
public async verifyAttestation(deviceName: string, credential: AttestationCredentialJSON): Promise<AuthDeviceDto> { public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
@ -100,7 +111,7 @@ export class WebAuthService {
credential, credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID
}; };
verification = await verifyAttestationResponse(opts); verification = await verifyAttestationResponse(opts);
} catch (error) { } catch (error) {
@ -110,11 +121,15 @@ export class WebAuthService {
const { verified, attestationInfo } = verification; const { verified, attestationInfo } = verification;
const devices = await this.deviceService.authDevices({where: {userId: user.id}}); const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
if (verified && attestationInfo) { if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo; const { credentialPublicKey, credentialID, counter } = attestationInfo;
let existingDevice = devices.find(device => device.credentialId === credentialID); let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
if (!existingDevice) { if (!existingDevice) {
/** /**
@ -126,7 +141,7 @@ export class WebAuthService {
counter, counter,
name: deviceName, name: deviceName,
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}) });
} }
return { return {
@ -139,26 +154,28 @@ export class WebAuthService {
throw new InternalServerErrorException('An unknown error occurred'); throw new InternalServerErrorException('An unknown error occurred');
} }
public async generateAssertionOptions(userId: string){ public async generateAssertionOptions(userId: string) {
const devices = await this.deviceService.authDevices({where: {userId: userId}}); const devices = await this.deviceService.authDevices({
where: { userId: userId }
});
if(devices.length === 0){ if (devices.length === 0) {
throw new Error('No registered auth devices found.') throw new Error('No registered auth devices found.');
} }
const opts: GenerateAssertionOptionsOpts = { const opts: GenerateAssertionOptionsOpts = {
timeout: 60000, timeout: 60000,
allowCredentials: devices.map(dev => ({ allowCredentials: devices.map((dev) => ({
id: dev.credentialId, id: dev.credentialId,
type: 'public-key', type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal'], transports: ['usb', 'ble', 'nfc', 'internal']
})), })),
/** /**
* This optional value controls whether or not the authenticator needs be able to uniquely * This optional value controls whether or not the authenticator needs be able to uniquely
* identify the user interacting with it (via built-in PIN pad, fingerprint scanner, etc...) * identify the user interacting with it (via built-in PIN pad, fingerprint scanner, etc...)
*/ */
userVerification: 'preferred', userVerification: 'preferred',
rpID: this.rpID, rpID: this.rpID
}; };
const options = generateAssertionOptions(opts); const options = generateAssertionOptions(opts);
@ -169,25 +186,31 @@ export class WebAuthService {
*/ */
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
authChallenge: options.challenge, authChallenge: options.challenge
}, },
where: { where: {
id: userId, id: userId
} }
}) });
return options; return options;
} }
public async verifyAssertion(userId: string, credential: AssertionCredentialJSON){ public async verifyAssertion(
userId: string,
credential: AssertionCredentialJSON
) {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const bodyCredIDBuffer = base64url.toBuffer(credential.rawId); const bodyCredIDBuffer = base64url.toBuffer(credential.rawId);
const devices = await this.deviceService.authDevices({where: {credentialId: bodyCredIDBuffer}}); const devices = await this.deviceService.authDevices({
where: { credentialId: bodyCredIDBuffer }
});
if (devices.length !== 1) { if (devices.length !== 1) {
throw new InternalServerErrorException(`Could not find authenticator matching ${credential.id}`); throw new InternalServerErrorException(
`Could not find authenticator matching ${credential.id}`
);
} }
const authenticator = devices[0]; const authenticator = devices[0];
@ -201,8 +224,8 @@ export class WebAuthService {
authenticator: { authenticator: {
credentialID: authenticator.credentialId, credentialID: authenticator.credentialId,
credentialPublicKey: authenticator.credentialPublicKey, credentialPublicKey: authenticator.credentialPublicKey,
counter: authenticator.counter, counter: authenticator.counter
}, }
}; };
verification = verifyAssertionResponse(opts); verification = verifyAssertionResponse(opts);
} catch (error) { } catch (error) {
@ -218,8 +241,8 @@ export class WebAuthService {
await this.deviceService.updateAuthDevice({ await this.deviceService.updateAuthDevice({
data: authenticator, data: authenticator,
where: {id_userId: { id: authenticator.id, userId: user.id}} where: { id_userId: { id: authenticator.id, userId: user.id } }
}) });
return this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user.id

2
apps/api/src/services/configuration.service.ts

@ -27,7 +27,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }), 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' }), WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

11
apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html

@ -8,12 +8,14 @@
> >
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Name</th> <th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Name</th>
<td mat-cell *matCellDef="let element">{{element.name}}{{element.id === currentDeviceId ? ' (current)' : ''}}</td> <td mat-cell *matCellDef="let element">
{{ element.name }}{{ element.id === currentDeviceId ? ' (current)' : '' }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="createdAt"> <ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Created at</th> <th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Created at</th>
<td mat-cell *matCellDef="let element">{{element.createdAt | date}}</td> <td mat-cell *matCellDef="let element">{{ element.createdAt | date }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
@ -39,10 +41,7 @@
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
*matRowDef="let row; columns: displayedColumns"
mat-row
></tr>
</table> </table>
<ngx-skeleton-loader <ngx-skeleton-loader

27
apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts

@ -1,4 +1,13 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
@ -11,7 +20,6 @@ import { MatSort } from '@angular/material/sort';
styleUrls: ['./auth-device-settings.component.scss'] styleUrls: ['./auth-device-settings.component.scss']
}) })
export class AuthDeviceSettingsComponent implements OnInit, OnChanges { export class AuthDeviceSettingsComponent implements OnInit, OnChanges {
@Input() authDevices: AuthDeviceDto[]; @Input() authDevices: AuthDeviceDto[];
@Input() currentDeviceId: string; @Input() currentDeviceId: string;
@ -26,17 +34,12 @@ export class AuthDeviceSettingsComponent implements OnInit, OnChanges {
public isLoading = true; public isLoading = true;
public pageSize = 7; public pageSize = 7;
public constructor() { } public constructor() {}
public ngOnInit(): void { public ngOnInit(): void {}
}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = ['name', 'createdAt', 'actions'];
'name',
'createdAt',
'actions',
];
this.isLoading = true; this.isLoading = true;
@ -50,7 +53,9 @@ export class AuthDeviceSettingsComponent implements OnInit, OnChanges {
} }
public onDeleteAuthDevice(aId: string) { public onDeleteAuthDevice(aId: string) {
const confirmation = confirm('Do you really want to remove this authenticator?'); const confirmation = confirm(
'Do you really want to remove this authenticator?'
);
if (confirmation) { if (confirmation) {
this.authDeviceDeleted.emit(aId); this.authDeviceDeleted.emit(aId);

4
apps/client/src/app/components/header/header.component.ts

@ -44,7 +44,7 @@ export class HeaderComponent implements OnChanges {
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService, private webAuthnService: WebAuthnService
) { ) {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
@ -85,7 +85,7 @@ export class HeaderComponent implements OnChanges {
} }
public openLoginDialog(): void { public openLoginDialog(): void {
if(this.webAuthnService.isEnabled()){ if (this.webAuthnService.isEnabled()) {
this.webAuthnService.verifyWebAuthn().subscribe(({ authToken }) => { this.webAuthnService.verifyWebAuthn().subscribe(({ authToken }) => {
this.setToken(authToken, false); this.setToken(authToken, false);
}); });

2
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts

@ -6,7 +6,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule} from "@angular/material/checkbox"; import { MatCheckboxModule } from '@angular/material/checkbox';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';

27
apps/client/src/app/pages/account/account-page.component.ts

@ -37,7 +37,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private dialog: MatDialog, private dialog: MatDialog,
private dataService: DataService, private dataService: DataService,
private userService: UserService, private userService: UserService,
public webAuthnService: WebAuthnService, public webAuthnService: WebAuthnService
) { ) {
this.dataService this.dataService
.fetchInfo() .fetchInfo()
@ -100,17 +100,23 @@ export class AccountPageComponent implements OnDestroy, OnInit {
} }
public startWebAuthn() { public startWebAuthn() {
this.webAuthnService.startWebAuthn() this.webAuthnService
.startWebAuthn()
.pipe( .pipe(
switchMap(attResp => { switchMap((attResp) => {
const dialogRef = this.dialog.open(AuthDeviceDialog, { const dialogRef = this.dialog.open(AuthDeviceDialog, {
data: { data: {
authDevice: {} authDevice: {}
} }
}); });
return dialogRef.afterClosed().pipe(switchMap(data => { return dialogRef.afterClosed().pipe(
return this.webAuthnService.verifyAttestation(attResp, data.authDevice.name) switchMap((data) => {
})); return this.webAuthnService.verifyAttestation(
attResp,
data.authDevice.name
);
})
);
}) })
) )
.subscribe(() => { .subscribe(() => {
@ -133,10 +139,13 @@ export class AccountPageComponent implements OnDestroy, OnInit {
} }
}); });
dialogRef.afterClosed() dialogRef
.afterClosed()
.pipe( .pipe(
filter(isNonNull), filter(isNonNull),
switchMap(data => this.webAuthnService.updateAuthDevice(data.authDevice)) switchMap((data) =>
this.webAuthnService.updateAuthDevice(data.authDevice)
)
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@ -149,7 +158,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.webAuthnService this.webAuthnService
.fetchAuthDevices() .fetchAuthDevices()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(authDevices => { .subscribe((authDevices) => {
this.authDevices$.next(authDevices); this.authDevices$.next(authDevices);
}); });
} }

30
apps/client/src/app/pages/account/account-page.html

@ -76,34 +76,34 @@
<gf-access-table [accesses]="accesses"></gf-access-table> <gf-access-table [accesses]="accesses"></gf-access-table>
</div> </div>
</div> </div>
<div class='row'> <div class="row">
<div class='col'> <div class="col">
<h3 class='mb-3 text-center' i18n>WebAuthn devices</h3> <h3 class="mb-3 text-center" i18n>WebAuthn devices</h3>
<mat-card class='mb-3'> <mat-card class="mb-3">
<mat-card-content> <mat-card-content>
<div class='row mb-3'> <div class="row mb-3">
<div class='col'> <div class="col">
<gf-auth-device-settings [authDevices]='authDevices$ | async' <gf-auth-device-settings
[authDevices]="authDevices$ | async"
[currentDeviceId]="webAuthnService.getCurrentDeviceId()" [currentDeviceId]="webAuthnService.getCurrentDeviceId()"
(authDeviceDeleted)='deleteAuthDevice($event)' (authDeviceDeleted)="deleteAuthDevice($event)"
(authDeviceToUpdate)='updateAuthDevice($event)' (authDeviceToUpdate)="updateAuthDevice($event)"
></gf-auth-device-settings> ></gf-auth-device-settings>
</div> </div>
</div> </div>
<div class='row mb-3'> <div class="row mb-3">
<div class='col'> <div class="col">
<button <button
class='d-inline-block' class="d-inline-block"
color='primary' color="primary"
i18n i18n
mat-flat-button mat-flat-button
(click)='startWebAuthn()' (click)="startWebAuthn()"
[disabled]="webAuthnService.isEnabled()" [disabled]="webAuthnService.isEnabled()"
> >
Add current device Add current device
</button> </button>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

7
apps/client/src/app/pages/account/account-page.module.ts

@ -14,10 +14,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component'; import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component';
@NgModule({ @NgModule({
declarations: [ declarations: [AuthDeviceDialog, AccountPageComponent],
AuthDeviceDialog,
AccountPageComponent,
],
exports: [], exports: [],
imports: [ imports: [
AccountPageRoutingModule, AccountPageRoutingModule,
@ -31,7 +28,7 @@ import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-d
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule, ReactiveFormsModule
], ],
providers: [] providers: []
}) })

13
apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html

@ -5,14 +5,21 @@
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.authDevice.name" /> <input
matInput
name="name"
required
[(ngModel)]="data.authDevice.name"
/>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button type='button' i18n mat-button (click)="dialogRef.close()">Cancel</button> <button type="button" i18n mat-button (click)="dialogRef.close()">
Cancel
</button>
<button <button
type='submit' type="submit"
color="primary" color="primary"
i18n i18n
mat-flat-button mat-flat-button

8
apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts

@ -8,14 +8,10 @@ import { AuthDeviceDialogParams } from '@ghostfolio/api/app/auth/interfaces/inte
styleUrls: ['./auth-device-dialog.component.css'] styleUrls: ['./auth-device-dialog.component.css']
}) })
export class AuthDeviceDialog implements OnInit { export class AuthDeviceDialog implements OnInit {
public constructor( public constructor(
public dialogRef: MatDialogRef<AuthDeviceDialog>, public dialogRef: MatDialogRef<AuthDeviceDialog>,
@Inject(MAT_DIALOG_DATA) public data: AuthDeviceDialogParams @Inject(MAT_DIALOG_DATA) public data: AuthDeviceDialogParams
) { ) {}
}
public ngOnInit(): void {
}
public ngOnInit(): void {}
} }

2
apps/client/src/app/pages/landing/landing-page.component.ts

@ -27,7 +27,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService, private webAuthnService: WebAuthnService
) {} ) {}
/** /**

5
apps/client/src/app/services/token-storage.service.ts

@ -11,7 +11,10 @@ export class TokenStorageService {
public constructor(private userService: UserService) {} public constructor(private userService: UserService) {}
public getToken(): string { public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY) || window.sessionStorage.getItem(TOKEN_KEY); return (
window.localStorage.getItem(TOKEN_KEY) ||
window.sessionStorage.getItem(TOKEN_KEY)
);
} }
public saveToken(token: string, staySignedIn: boolean = false): void { public saveToken(token: string, staySignedIn: boolean = false): void {

79
apps/client/src/app/services/web-authn.service.ts

@ -14,58 +14,83 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
providedIn: 'root' providedIn: 'root'
}) })
export class WebAuthnService { export class WebAuthnService {
private static readonly WEB_AUTH_N_USER_ID = 'WEB_AUTH_N_USER_ID'; private static readonly WEB_AUTH_N_USER_ID = 'WEB_AUTH_N_USER_ID';
private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID'; private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID';
public constructor( public constructor(
private userService: UserService, private userService: UserService,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private http: HttpClient, private http: HttpClient
) { ) {}
}
public startWebAuthn() { public startWebAuthn() {
return this.http.get<PublicKeyCredentialCreationOptionsJSON>(`/api/auth/webauthn/generate-attestation-options`, {}) return this.http
.get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`,
{}
)
.pipe( .pipe(
switchMap(attOps => { switchMap((attOps) => {
return startAttestation(attOps); return startAttestation(attOps);
}) })
); );
} }
public verifyAttestation(attResp, deviceName) { public verifyAttestation(attResp, deviceName) {
return this.http.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, { return this.http
.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, {
credential: attResp, credential: attResp,
deviceName: deviceName, deviceName: deviceName
}).pipe(tap(authDevice => })
.pipe(
tap((authDevice) =>
this.userService.get().subscribe((user) => { this.userService.get().subscribe((user) => {
this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID, authDevice.id); this.settingsStorageService.setSetting(
this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_USER_ID, user.id); WebAuthnService.WEB_AUTH_N_DEVICE_ID,
authDevice.id
);
this.settingsStorageService.setSetting(
WebAuthnService.WEB_AUTH_N_USER_ID,
user.id
);
}) })
)); )
);
} }
public verifyWebAuthn() { public verifyWebAuthn() {
const userId = this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_USER_ID); const userId = this.settingsStorageService.getSetting(
return this.http.post<PublicKeyCredentialRequestOptionsJSON>(`/api/auth/webauthn/generate-assertion-options`, {userId}) WebAuthnService.WEB_AUTH_N_USER_ID
);
return this.http
.post<PublicKeyCredentialRequestOptionsJSON>(
`/api/auth/webauthn/generate-assertion-options`,
{ userId }
)
.pipe( .pipe(
switchMap(startAssertion), switchMap(startAssertion),
switchMap(assertionResponse => { switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>(`/api/auth/webauthn/verify-assertion`, { return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`,
{
credential: assertionResponse, credential: assertionResponse,
userId userId
}) }
);
}) })
); );
} }
public getCurrentDeviceId() { public getCurrentDeviceId() {
return this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); return this.settingsStorageService.getSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
} }
public isEnabled() { public isEnabled() {
return !!this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); return !!this.settingsStorageService.getSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
} }
public fetchAuthDevices() { public fetchAuthDevices() {
@ -73,16 +98,22 @@ export class WebAuthnService {
} }
public updateAuthDevice(aAuthDevice: AuthDeviceDto) { public updateAuthDevice(aAuthDevice: AuthDeviceDto) {
return this.http.put<AuthDeviceDto>(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice); return this.http.put<AuthDeviceDto>(
`/api/auth-device/${aAuthDevice.id}`,
aAuthDevice
);
} }
public deleteAuthDevice(aId: string) { public deleteAuthDevice(aId: string) {
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${aId}`) return this.http.delete<AuthDeviceDto>(`/api/auth-device/${aId}`).pipe(
.pipe(
tap(() => { tap(() => {
if (aId === this.getCurrentDeviceId()) { if (aId === this.getCurrentDeviceId()) {
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); this.settingsStorageService.removeSetting(
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_USER_ID); WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
this.settingsStorageService.removeSetting(
WebAuthnService.WEB_AUTH_N_USER_ID
);
} }
}) })
); );

Loading…
Cancel
Save