Browse Source

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
pull/82/head
Matthias Frey 4 years ago
committed by Thomas
parent
commit
3604d3a57d
  1. 12
      apps/api/src/app/auth-device/auth-device.controller.ts
  2. 2
      apps/api/src/app/auth-device/auth-device.module.ts
  3. 5
      apps/api/src/app/auth-device/auth-device.service.ts
  4. 43
      apps/api/src/app/auth/auth.controller.ts
  5. 2
      apps/api/src/app/auth/auth.module.ts
  6. 5
      apps/api/src/app/auth/interfaces/interfaces.ts
  7. 187
      apps/api/src/app/auth/interfaces/simplewebauthn.ts
  8. 62
      apps/api/src/app/auth/web-auth.service.ts
  9. 1
      apps/api/src/services/configuration.service.ts
  10. 1
      apps/api/src/services/interfaces/environment.interface.ts
  11. 2
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html
  12. 0
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss
  13. 7
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts
  14. 2
      apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts
  15. 12
      apps/client/src/app/components/header/header.component.ts
  16. 71
      apps/client/src/app/pages/account/account-page.component.ts
  17. 32
      apps/client/src/app/pages/account/account-page.html
  18. 16
      apps/client/src/app/pages/account/account-page.module.ts
  19. 0
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css
  20. 0
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html
  21. 8
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts
  22. 14
      apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts
  23. 46
      apps/client/src/app/pages/auth-devices/auth-devices-page.component.html
  24. 0
      apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss
  25. 115
      apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts
  26. 37
      apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts
  27. 37
      apps/client/src/app/pages/landing/landing-page.component.ts
  28. 12
      apps/client/src/app/services/data.service.ts
  29. 4
      apps/client/src/app/services/settings-storage.service.ts
  30. 12
      apps/client/src/app/services/token-storage.service.ts
  31. 90
      apps/client/src/app/services/web-authn.service.ts
  32. 6
      prisma/schema.prisma

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

@ -17,7 +17,7 @@ export class AuthDeviceController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAuthDevice(@Param('id') id: string): Promise<AuthDeviceDto> {
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
@ -30,7 +30,7 @@ export class AuthDeviceController {
);
}
const deletedAuthDevice = await this.authDeviceService.deleteAuthDevice(
await this.authDeviceService.deleteAuthDevice(
{
id_userId: {
id,
@ -38,11 +38,6 @@ export class AuthDeviceController {
}
}
);
return {
id: deletedAuthDevice.id,
createdAt: deletedAuthDevice.createdAt.toISOString(),
name: deletedAuthDevice.name
};
}
@Put(':id')
@ -92,15 +87,14 @@ export class AuthDeviceController {
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAuthDevices(): Promise<AuthDeviceDto[]> {
const authDevices = await this.authDeviceService.authDevices({
orderBy: { createdAt: 'desc' },
where: { userId: this.request.user.id }
});
return authDevices.map(authDevice => ({
id: authDevice.id,
createdAt: authDevice.createdAt.toISOString(),
id: authDevice.id,
name: authDevice.name
}));
}

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

@ -15,8 +15,8 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
],
providers: [
AuthDeviceService,
PrismaService,
ConfigurationService,
PrismaService,
]
})
export class AuthDeviceModule {}

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

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AuthDevice, Order, Prisma } from '@prisma/client';
import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
@ -40,7 +40,6 @@ export class AuthDeviceService {
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
data
});
@ -48,8 +47,8 @@ export class AuthDeviceService {
public async updateAuthDevice(
params: {
where: Prisma.AuthDeviceWhereUniqueInput;
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
},
): Promise<AuthDevice> {
const { data, where } = params;

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

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

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

@ -20,6 +20,7 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
})
],
providers: [
AuthDeviceService,
AuthService,
ConfigurationService,
GoogleStrategy,
@ -27,7 +28,6 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
PrismaService,
UserService,
WebAuthService,
AuthDeviceService,
]
})
export class AuthModule {}

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

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

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

@ -0,0 +1,187 @@
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly authenticatorData: ArrayBuffer;
readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null;
}
export interface AuthenticatorAttestationResponse extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer;
}
export interface AuthenticationExtensionsClientInputs {
appid?: string;
appidExclude?: string;
credProps?: boolean;
uvm?: boolean;
}
export interface AuthenticationExtensionsClientOutputs {
appid?: boolean;
credProps?: CredentialPropertiesOutput;
uvm?: UvmEntries;
}
export interface AuthenticatorSelectionCriteria {
authenticatorAttachment?: AuthenticatorAttachment;
requireResidentKey?: boolean;
residentKey?: ResidentKeyRequirement;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredential extends Credential {
readonly rawId: ArrayBuffer;
readonly response: AuthenticatorResponse;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
}
export interface PublicKeyCredentialCreationOptions {
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
challenge: BufferSource;
excludeCredentials?: PublicKeyCredentialDescriptor[];
extensions?: AuthenticationExtensionsClientInputs;
pubKeyCredParams: PublicKeyCredentialParameters[];
rp: PublicKeyCredentialRpEntity;
timeout?: number;
user: PublicKeyCredentialUserEntity;
}
export interface PublicKeyCredentialDescriptor {
id: BufferSource;
transports?: AuthenticatorTransport[];
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialParameters {
alg: COSEAlgorithmIdentifier;
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialRequestOptions {
allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: BufferSource;
extensions?: AuthenticationExtensionsClientInputs;
rpId?: string;
timeout?: number;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity {
displayName: string;
id: BufferSource;
}
export interface AuthenticatorResponse {
readonly clientDataJSON: ArrayBuffer;
}
export interface CredentialPropertiesOutput {
rk?: boolean;
}
export interface Credential {
readonly id: string;
readonly type: string;
}
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
id?: string;
}
export interface PublicKeyCredentialEntity {
name: string;
}
export declare type AttestationConveyancePreference = "direct" | "enterprise" | "indirect" | "none";
export declare type AuthenticatorTransport = "ble" | "internal" | "nfc" | "usb";
export declare type COSEAlgorithmIdentifier = number;
export declare type UserVerificationRequirement = "discouraged" | "preferred" | "required";
export declare type UvmEntries = UvmEntry[];
export declare type AuthenticatorAttachment = "cross-platform" | "platform";
export declare type ResidentKeyRequirement = "discouraged" | "preferred" | "required";
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = "public-key";
export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON extends Omit<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[];
}

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

@ -1,6 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { UserService } from '../user/user.service';
import {
generateAssertionOptions,
@ -16,24 +15,22 @@ import {
} from '@simplewebauthn/server';
import { REQUEST } from '@nestjs/core';
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
// TODO fix type compilation error
// import { AttestationCredentialJSON } from '@simplewebauthn/typescript-types';
import { AssertionCredentialJSON, AttestationCredentialJSON } from './interfaces/simplewebauthn';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import base64url from 'base64url';
import { JwtService } from '@nestjs/jwt';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
@Injectable()
export class WebAuthService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly userService: UserService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser,
) {}
get rpName() {
return this.configurationService.get('WEB_AUTH_RP_NAME');
}
get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID');
}
@ -47,7 +44,7 @@ export class WebAuthService {
const devices = await this.deviceService.authDevices({where: {userId: user.id}});
const opts: GenerateAttestationOptionsOpts = {
rpName: this.rpName,
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
@ -92,7 +89,7 @@ export class WebAuthService {
return options;
}
public async verifyAttestation(body: any){
public async verifyAttestation(deviceName: string, credential: AttestationCredentialJSON): Promise<AuthDeviceDto> {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
@ -100,7 +97,7 @@ export class WebAuthService {
let verification: VerifiedAttestation;
try {
const opts: VerifyAttestationResponseOpts = {
credential: body,
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
@ -108,7 +105,7 @@ export class WebAuthService {
verification = await verifyAttestationResponse(opts);
} catch (error) {
console.error(error);
return new InternalServerErrorException(error.message);
throw new InternalServerErrorException(error.message);
}
const { verified, attestationInfo } = verification;
@ -117,28 +114,37 @@ export class WebAuthService {
if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo;
const existingDevice = devices.find(device => device.credentialId === credentialID);
let existingDevice = devices.find(device => device.credentialId === credentialID);
if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
await this.deviceService.createAuthDevice({
existingDevice = await this.deviceService.createAuthDevice({
credentialPublicKey,
credentialId: credentialID,
counter,
name: body.deviceName,
name: deviceName,
User: { connect: { id: user.id } }
})
}
return {
createdAt: existingDevice.createdAt.toISOString(),
id: existingDevice.id,
name: existingDevice.name
};
}
return { verified };
throw new InternalServerErrorException('An unknown error occurred');
}
public async generateAssertionOptions(){
const user = this.request.user;
const devices = await this.deviceService.authDevices({where: {userId: user.id}});
public async generateAssertionOptions(userId: string){
const devices = await this.deviceService.authDevices({where: {userId: userId}});
if(devices.length === 0){
throw new Error('No registered auth devices found.')
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
@ -166,29 +172,29 @@ export class WebAuthService {
authChallenge: options.challenge,
},
where: {
id: user.id,
id: userId,
}
})
return options;
}
public async verifyAssertion(body: any){
public async verifyAssertion(userId: string, credential: AssertionCredentialJSON){
const user = this.request.user;
const user = await this.userService.user({ id: userId });
const bodyCredIDBuffer = base64url.toBuffer(body.rawId);
const bodyCredIDBuffer = base64url.toBuffer(credential.rawId);
const devices = await this.deviceService.authDevices({where: {credentialId: bodyCredIDBuffer}});
if (devices.length !== 1) {
throw new InternalServerErrorException(`Could not find authenticator matching ${body.id}`);
throw new InternalServerErrorException(`Could not find authenticator matching ${credential.id}`);
}
const authenticator = devices[0];
let verification: VerifiedAssertion;
try {
const opts: VerifyAssertionResponseOpts = {
credential: body,
credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
@ -214,8 +220,12 @@ export class WebAuthService {
data: authenticator,
where: {id_userId: { id: authenticator.id, userId: user.id}}
})
return this.jwtService.sign({
id: user.id
});
}
return { verified };
throw new Error();
}
}

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

@ -28,7 +28,6 @@ export class ConfigurationService {
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' }),
WEB_AUTH_RP_NAME: str({ default: 'Ghostfolio' }),
});
}

1
apps/api/src/services/interfaces/environment.interface.ts

@ -19,5 +19,4 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_PORT: number;
ROOT_URL: string;
WEB_AUTH_RP_ID: string;
WEB_AUTH_RP_NAME: string;
}

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

@ -8,7 +8,7 @@
>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Name</th>
<td mat-cell *matCellDef="let element">{{element.name}}</td>
<td mat-cell *matCellDef="let element">{{element.name}}{{element.id === currentDeviceId ? ' (current)' : ''}}</td>
</ng-container>
<ng-container matColumnDef="createdAt">

0
apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css → apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss

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

@ -8,11 +8,12 @@ import { MatSort } from '@angular/material/sort';
selector: 'gf-auth-device-settings',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './auth-device-settings.component.html',
styleUrls: ['./auth-device-settings.component.css']
styleUrls: ['./auth-device-settings.component.scss']
})
export class AuthDeviceSettingsComponent implements OnInit, OnChanges {
@Input() authDevices: AuthDeviceDto[];
@Input() currentDeviceId: string;
@Output() authDeviceDeleted = new EventEmitter<string>();
@Output() authDeviceToUpdate = new EventEmitter<AuthDeviceDto>();
@ -25,9 +26,9 @@ export class AuthDeviceSettingsComponent implements OnInit, OnChanges {
public isLoading = true;
public pageSize = 7;
constructor() { }
public constructor() { }
ngOnInit(): void {
public ngOnInit(): void {
}
public ngOnChanges() {

2
apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts

@ -1,7 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
@ -16,7 +15,6 @@ import { MatPaginatorModule } from '@angular/material/paginator';
imports: [
CommonModule,
MatButtonModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,

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

@ -1,3 +1,4 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
@ -16,6 +17,7 @@ import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
@Component({
selector: 'gf-header',
@ -42,7 +44,8 @@ export class HeaderComponent implements OnChanges {
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private tokenStorageService: TokenStorageService
private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService,
) {
this.impersonationStorageService
.onChangeHasImpersonation()
@ -83,6 +86,13 @@ export class HeaderComponent implements OnChanges {
}
public openLoginDialog(): void {
if(this.webAuthnService.isEnabled()){
this.webAuthnService.verifyWebAuthn().subscribe(({ authToken }) => {
this.setToken(authToken);
});
return;
}
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, {
autoFocus: false,
data: {

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

@ -5,8 +5,13 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ReplaySubject, Subject } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { isNonNull } from '@ghostfolio/client/util/rxjs.util';
import { MatDialog } from '@angular/material/dialog';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
@Component({
selector: 'gf-account-page',
@ -20,6 +25,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionToUpdateUserSettings: boolean;
public user: User;
public authDevices$: ReplaySubject<AuthDeviceDto[]> = new ReplaySubject(1);
private unsubscribeSubject = new Subject<void>();
@ -28,8 +34,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dialog: MatDialog,
private dataService: DataService,
private userService: UserService
private userService: UserService,
public webAuthnService: WebAuthnService,
) {
this.dataService
.fetchInfo()
@ -52,6 +60,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.fetchAuthDevices();
}
/**
@ -89,6 +99,61 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
public startWebAuthn() {
this.webAuthnService.startWebAuthn()
.pipe(
switchMap(attResp => {
const dialogRef = this.dialog.open(AuthDeviceDialog, {
data: {
authDevice: {}
}
});
return dialogRef.afterClosed().pipe(switchMap(data => {
return this.webAuthnService.verifyAttestation(attResp, data.authDevice.name)
}));
})
)
.subscribe(() => {
this.fetchAuthDevices();
});
}
public deleteAuthDevice(aId: string) {
this.webAuthnService.deleteAuthDevice(aId).subscribe({
next: () => {
this.fetchAuthDevices();
}
});
}
public updateAuthDevice(aAuthDevice: AuthDeviceDto) {
const dialogRef = this.dialog.open(AuthDeviceDialog, {
data: {
authDevice: aAuthDevice
}
});
dialogRef.afterClosed()
.pipe(
filter(isNonNull),
switchMap(data => this.webAuthnService.updateAuthDevice(data.authDevice))
)
.subscribe({
next: () => {
this.fetchAuthDevices();
}
});
}
private fetchAuthDevices() {
this.webAuthnService
.fetchAuthDevices()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(authDevices => {
this.authDevices$.next(authDevices);
});
}
private update() {
this.dataService
.fetchAccesses()

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

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

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

@ -6,23 +6,33 @@ import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
import { AccountPageRoutingModule } from './account-page-routing.module';
import { AccountPageComponent } from './account-page.component';
import { GfAuthDeviceSettingsModule } from '@ghostfolio/client/components/auth-device-settings/auth-device-settings.module';
import { MatInputModule } from '@angular/material/input';
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component';
@NgModule({
declarations: [AccountPageComponent],
declarations: [
AuthDeviceDialog,
AccountPageComponent,
],
exports: [],
imports: [
AccountPageRoutingModule,
CommonModule,
FormsModule,
GfAuthDeviceSettingsModule,
GfPortfolioAccessTableModule,
MatButtonModule,
MatCardModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
ReactiveFormsModule,
],
providers: []
})

0
apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css → apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css

0
apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html → apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html

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

@ -1,10 +1,6 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto,
}
import { AuthDeviceDialogParams } from '@ghostfolio/api/app/auth/interfaces/interfaces';
@Component({
selector: 'gf-auth-device-dialog',
@ -19,7 +15,7 @@ export class AuthDeviceDialog implements OnInit {
) {
}
ngOnInit(): void {
public ngOnInit(): void {
}
}

14
apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts

@ -1,14 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component';
const routes: Routes = [
{ path: '', component: AuthDevicesPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AuthDevicesPageRoutingModule {}

46
apps/client/src/app/pages/auth-devices/auth-devices-page.component.html

@ -1,46 +0,0 @@
<div class='container'>
<div class='row'>
<div class='col'>
<h3 class='mb-3 text-center' i18n>WebAuthn</h3>
<mat-card class='mb-3'>
<mat-card-content>
<div class='row mb-3'>
<div class='col'>
<gf-auth-device-settings [authDevices]='authDevices$ | async'
(authDeviceDeleted)='deleteAuthDevice($event)'
(authDeviceToUpdate)='updateAuthDevice($event)'
></gf-auth-device-settings>
</div>
</div>
<div class='row mb-3'>
<div class='col'>
<button
class='d-inline-block'
color='primary'
i18n
mat-flat-button
(click)='startWebAuthn()'
>
Add this device
</button>
</div>
</div>
<div class='row'>
<div class='col'>
<button
class='d-inline-block'
color='primary'
i18n
mat-flat-button
(click)='verifyWebAuthn()'
>
DEBUG: verify WebAuthn
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

0
apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss

115
apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts

@ -1,115 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { startAssertion, startAttestation } from '@simplewebauthn/browser';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { HttpClient } from '@angular/common/http';
import { MatDialog } from '@angular/material/dialog';
import { ReplaySubject, Subject } from 'rxjs';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component';
import { isNonNull } from '@ghostfolio/client/util/rxjs.util';
@Component({
selector: 'gf-auth-devices-page',
templateUrl: './auth-devices-page.component.html',
styleUrls: ['./auth-devices-page.component.scss']
})
export class AuthDevicesPageComponent implements OnDestroy, OnInit {
public authDevices$: ReplaySubject<AuthDeviceDto[]> = new ReplaySubject(1);
private unsubscribeSubject = new Subject<void>();
constructor(
private dataService: DataService,
private tokenStorageService: TokenStorageService,
private http: HttpClient,
private dialog: MatDialog
) {
this.fetchAuthDevices();
}
public ngOnInit() {
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
public startWebAuthn() {
this.http.get<any>(`/api/auth/webauthn/generate-attestation-options`, {})
.pipe(
switchMap(attOps => {
return startAttestation(attOps);
}),
switchMap(attResp => {
const dialogRef = this.dialog.open(AuthDeviceDialog, {
data: {
authDevice: {}
}
});
return dialogRef.afterClosed().pipe(switchMap(data => {
const reqBody = {
...attResp,
deviceName: data.authDevice.name
};
return this.http.post<any>(`/api/auth/webauthn/verify-attestation`, reqBody);
}));
})
)
.subscribe(() => {
this.fetchAuthDevices();
});
}
public verifyWebAuthn() {
this.http.get<any>(`/api/auth/webauthn/generate-assertion-options`, {})
.pipe(
switchMap(startAssertion),
switchMap(assertionResponse => this.http.post<any>(`/api/auth/webauthn/verify-assertion`, assertionResponse))
)
.subscribe(res => {
if (res?.verified) alert('success');
else alert('fail');
});
}
public deleteAuthDevice(aId: string) {
this.dataService.deleteAuthDevice(aId).subscribe({
next: () => {
this.fetchAuthDevices();
}
});
}
public updateAuthDevice(aAuthDevice: AuthDeviceDto) {
const dialogRef = this.dialog.open(AuthDeviceDialog, {
data: {
authDevice: aAuthDevice
}
});
dialogRef.afterClosed()
.pipe(
filter(isNonNull),
switchMap(data => this.dataService.updateAuthDevice(data.authDevice))
)
.subscribe({
next: () => {
this.fetchAuthDevices();
}
});
}
private fetchAuthDevices() {
this.dataService
.fetchAuthDevices()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(authDevices => {
this.authDevices$.next(authDevices);
});
}
}

37
apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts

@ -1,37 +0,0 @@
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthDevicesPageRoutingModule } from '@ghostfolio/client/pages/auth-devices/auth-devices-page-routing.module';
import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { GfAuthDeviceSettingsModule } from '@ghostfolio/client/components/auth-device-settings/auth-device-settings.module';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component';
@NgModule({
declarations: [
AuthDevicesPageComponent,
AuthDeviceDialog,
],
imports: [
CommonModule,
AuthDevicesPageRoutingModule,
FormsModule,
GfAuthDeviceSettingsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDialogModule,
ReactiveFormsModule,
MatButtonModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AuthDevicesPageModule { }

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

@ -5,6 +5,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
@Component({
selector: 'gf-landing-page',
@ -25,7 +26,8 @@ export class LandingPageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private router: Router,
private tokenStorageService: TokenStorageService
private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService,
) {}
/**
@ -254,6 +256,39 @@ export class LandingPageComponent implements OnDestroy, OnInit {
];
}
public openShowAccessTokenDialog(
accessToken: string,
authToken: string
): void {
if(this.webAuthnService.isEnabled()){
this.webAuthnService.verifyWebAuthn().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
}
});
return;
}
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken
},
disableClose: true,
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
}
});
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);

12
apps/client/src/app/services/data.service.ts

@ -174,4 +174,16 @@ export class DataService {
public putUserSettings(aData: UpdateUserSettingsDto) {
return this.http.put<User>(`/api/user/settings`, aData);
}
public fetchAuthDevices() {
return this.http.get<AuthDeviceDto[]>('/api/auth-device');
}
public updateAuthDevice(aAuthDevice: AuthDeviceDto) {
return this.http.put<AuthDeviceDto>(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice);
}
public deleteAuthDevice(aId: string) {
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${aId}`);
}
}

4
apps/client/src/app/services/settings-storage.service.ts

@ -15,4 +15,8 @@ export class SettingsStorageService {
public setSetting(aKey: string, aValue: string) {
window.localStorage.setItem(aKey, aValue);
}
public removeSetting(aKey: string): void {
return window.localStorage.removeItem(aKey);
}
}

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

@ -11,23 +11,23 @@ export class TokenStorageService {
public constructor(private userService: UserService) {}
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
return window.sessionStorage.getItem(TOKEN_KEY);
}
public saveToken(token: string): void {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.setItem(TOKEN_KEY, token);
window.sessionStorage.removeItem(TOKEN_KEY);
window.sessionStorage.setItem(TOKEN_KEY, token);
}
public signOut(): void {
const utmSource = window.localStorage.getItem('utm_source');
const utmSource = window.sessionStorage.getItem('utm_source');
window.localStorage.clear();
window.sessionStorage.clear();
this.userService.remove();
if (utmSource) {
window.localStorage.setItem('utm_source', utmSource);
window.sessionStorage.setItem('utm_source', utmSource);
}
}
}

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

@ -0,0 +1,90 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { switchMap, tap } from 'rxjs/operators';
import { startAssertion, startAttestation } from '@simplewebauthn/browser';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn';
import { DataService } from '@ghostfolio/client/services/data.service';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
@Injectable({
providedIn: 'root'
})
export class WebAuthnService {
private static readonly WEB_AUTH_N_USER_ID = 'WEB_AUTH_N_USER_ID';
private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID';
public constructor(
private dataService: DataService,
private settingsStorageService: SettingsStorageService,
private http: HttpClient,
) {
}
public startWebAuthn() {
return this.http.get<PublicKeyCredentialCreationOptionsJSON>(`/api/auth/webauthn/generate-attestation-options`, {})
.pipe(
switchMap(attOps => {
return startAttestation(attOps);
})
);
}
public verifyAttestation(attResp, deviceName) {
return this.http.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, {
credential: attResp,
deviceName: deviceName,
}).pipe(tap(authDevice =>
this.dataService.fetchUser().subscribe((user) => {
this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID, authDevice.id);
this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_USER_ID, user.id);
})
));
}
public verifyWebAuthn() {
const userId = this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_USER_ID);
return this.http.post<PublicKeyCredentialRequestOptionsJSON>(`/api/auth/webauthn/generate-assertion-options`, {userId})
.pipe(
switchMap(startAssertion),
switchMap(assertionResponse => {
return this.http.post<{ authToken: string }>(`/api/auth/webauthn/verify-assertion`, {
credential: assertionResponse,
userId
})
})
);
}
public getCurrentDeviceId() {
return this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID);
}
public isEnabled() {
return !!this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID);
}
public fetchAuthDevices() {
return this.http.get<AuthDeviceDto[]>('/api/auth-device');
}
public updateAuthDevice(aAuthDevice: AuthDeviceDto) {
return this.http.put<AuthDeviceDto>(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice);
}
public deleteAuthDevice(aId: string) {
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${aId}`)
.pipe(
tap(() => {
if (aId === this.getCurrentDeviceId()) {
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID);
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_USER_ID);
}
})
);
}
}

6
prisma/schema.prisma

@ -52,8 +52,8 @@ model AuthDevice {
credentialId Bytes
credentialPublicKey Bytes
counter Int
name String
id String @default(uuid())
name String
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
@ -146,8 +146,9 @@ model User {
Account Account[]
alias String?
Analytics Analytics?
createdAt DateTime @default(now())
authChallenge String?
AuthDevice AuthDevice[]
createdAt DateTime @default(now())
id String @id @default(uuid())
Order Order[]
provider Provider?
@ -156,7 +157,6 @@ model User {
Subscription Subscription[]
thirdPartyId String?
updatedAt DateTime @updatedAt
authChallenge String?
}
enum AccountType {

Loading…
Cancel
Save