Browse Source

Remove device list feature, sign in with deviceId instead

pull/82/head
Matthias Frey 4 years ago
committed by Thomas
parent
commit
ecfb800600
  1. 71
      apps/api/src/app/auth-device/auth-device.controller.ts
  2. 1
      apps/api/src/app/auth-device/auth-device.dto.ts
  3. 8
      apps/api/src/app/auth/auth.controller.ts
  4. 86
      apps/api/src/app/auth/web-auth.service.ts
  5. 57
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html
  6. 0
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss
  7. 64
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts
  8. 28
      apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts
  9. 7
      apps/client/src/app/components/header/header.component.ts
  10. 2
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html
  11. 17
      apps/client/src/app/core/http-response.interceptor.ts
  12. 74
      apps/client/src/app/pages/account/account-page.component.ts
  13. 52
      apps/client/src/app/pages/account/account-page.html
  14. 5
      apps/client/src/app/pages/account/account-page.module.ts
  15. 0
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css
  16. 32
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html
  17. 17
      apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts
  18. 20
      apps/client/src/app/services/token-storage.service.ts
  19. 103
      apps/client/src/app/services/web-authn.service.ts
  20. 18
      prisma/migrations/20210612110542_added_auth_device/migration.sql
  21. 5
      prisma/schema.prisma

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

@ -1,19 +1,15 @@
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
getPermissions,
@ -43,71 +39,6 @@ export class AuthDeviceController {
);
}
await this.authDeviceService.deleteAuthDevice({
id_userId: {
id,
userId: this.request.user.id
}
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateAuthDevice(
@Param('id') id: string,
@Body() data: AuthDeviceDto
) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAuthDevice = await this.authDeviceService.authDevice({
id_userId: {
id,
userId: this.request.user.id
}
});
if (!originalAuthDevice) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.authDeviceService.updateAuthDevice({
data: {
name: data.name
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
}
@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) => ({
createdAt: authDevice.createdAt.toISOString(),
id: authDevice.id,
name: authDevice.name
}));
await this.authDeviceService.deleteAuthDevice({ id });
}
}

1
apps/api/src/app/auth-device/auth-device.dto.ts

@ -1,5 +1,4 @@
export interface AuthDeviceDto {
createdAt: string;
id: string;
name: string;
}

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

@ -79,17 +79,17 @@ export class AuthController {
}
@Post('webauthn/generate-assertion-options')
public async generateAssertionOptions(@Body() body: { userId: string }) {
return this.webAuthService.generateAssertionOptions(body.userId);
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
return this.webAuthService.generateAssertionOptions(body.deviceId);
}
@Post('webauthn/verify-assertion')
public async verifyAssertion(
@Body() body: { userId: string; credential: AssertionCredentialJSON }
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) {
try {
const authToken = await this.webAuthService.verifyAssertion(
body.userId,
body.deviceId,
body.credential
);
return { authToken };

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

@ -23,7 +23,6 @@ import {
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';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -48,9 +47,6 @@ export class WebAuthService {
public async generateAttestationOptions() {
const user = this.request.user;
const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
const opts: GenerateAttestationOptionsOpts = {
rpName: 'Ghostfolio',
@ -59,21 +55,6 @@ export class WebAuthService {
userName: user.alias,
timeout: 60000,
attestationType: 'indirect',
/**
* Passing in a user's list of already-registered authenticator IDs here prevents users from
* registering the same device multiple times. The authenticator will simply throw an error in
* the browser if it's asked to perform an attestation when one of these ID's already resides
* on it.
*/
excludeCredentials: devices.map((device) => ({
id: device.credentialId,
type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal']
})),
/**
* The optional authenticatorSelection property allows for specifying more constraints around
* the types of authenticators that users to can use for attestation
*/
authenticatorSelection: {
userVerification: 'preferred',
requireResidentKey: false
@ -82,10 +63,6 @@ export class WebAuthService {
const options = generateAttestationOptions(opts);
/**
* The server needs to temporarily remember this value for verification, so don't lose it until
* after you verify an authenticator response.
*/
await this.userService.updateUser({
data: {
authChallenge: options.challenge
@ -139,57 +116,47 @@ export class WebAuthService {
credentialPublicKey,
credentialId: credentialID,
counter,
name: deviceName,
User: { connect: { id: user.id } }
});
}
return {
createdAt: existingDevice.createdAt.toISOString(),
id: existingDevice.id,
name: existingDevice.name
id: existingDevice.id
};
}
throw new InternalServerErrorException('An unknown error occurred');
}
public async generateAssertionOptions(userId: string) {
const devices = await this.deviceService.authDevices({
where: { userId: userId }
});
public async generateAssertionOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (devices.length === 0) {
throw new Error('No registered auth devices found.');
if (!device) {
throw new Error('Device not found');
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
allowCredentials: devices.map((dev) => ({
id: dev.credentialId,
allowCredentials: [
{
id: device.credentialId,
type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal']
})),
/**
* 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...)
*/
}
],
userVerification: 'preferred',
rpID: this.rpID
};
const options = generateAssertionOptions(opts);
/**
* The server needs to temporarily remember this value for verification, so don't lose it until
* after you verify an authenticator response.
*/
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: userId
id: device.userId
}
});
@ -197,22 +164,16 @@ export class WebAuthService {
}
public async verifyAssertion(
userId: string,
deviceId: string,
credential: AssertionCredentialJSON
) {
const user = await this.userService.user({ id: userId });
const bodyCredIDBuffer = base64url.toBuffer(credential.rawId);
const devices = await this.deviceService.authDevices({
where: { credentialId: bodyCredIDBuffer }
});
const device = await this.deviceService.authDevice({ id: deviceId });
if (devices.length !== 1) {
throw new InternalServerErrorException(
`Could not find authenticator matching ${credential.id}`
);
if (!device) {
throw new Error('Device not found');
}
const authenticator = devices[0];
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion;
try {
@ -222,9 +183,9 @@ export class WebAuthService {
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: {
credentialID: authenticator.credentialId,
credentialPublicKey: authenticator.credentialPublicKey,
counter: authenticator.counter
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
}
};
verification = verifyAssertionResponse(opts);
@ -236,12 +197,11 @@ export class WebAuthService {
const { verified, assertionInfo } = verification;
if (verified) {
// Update the authenticator's counter in the DB to the newest count in the assertion
authenticator.counter = assertionInfo.newCounter;
device.counter = assertionInfo.newCounter;
await this.deviceService.updateAuthDevice({
data: authenticator,
where: { id_userId: { id: authenticator.id, userId: user.id } }
data: device,
where: { id: device.id }
});
return this.jwtService.sign({

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

@ -1,57 +0,0 @@
<table
class="w-100"
matSort
matSortActive="shareCurrent"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Name</th>
<td mat-cell *matCellDef="let element">
{{ element.name }}{{ element.id === currentDeviceId ? ' (current)' : '' }}
</td>
</ng-container>
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Created at</th>
<td mat-cell *matCellDef="let element">{{ element.createdAt | date }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-0 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-0 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="authDeviceToUpdate.emit(element)">
Edit
</button>
<button i18n mat-menu-item (click)="onDeleteAuthDevice(element.id)">
Delete
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

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

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

@ -1,64 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
@Component({
selector: 'gf-auth-device-settings',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './auth-device-settings.component.html',
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>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AuthDeviceDto> = new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public pageSize = 7;
public constructor() {}
public ngOnInit(): void {}
public ngOnChanges() {
this.displayedColumns = ['name', 'createdAt', 'actions'];
this.isLoading = true;
if (this.authDevices) {
this.dataSource = new MatTableDataSource(this.authDevices);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.isLoading = false;
}
}
public onDeleteAuthDevice(aId: string) {
const confirmation = confirm(
'Do you really want to remove this authenticator?'
);
if (confirmation) {
this.authDeviceDeleted.emit(aId);
}
}
}

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

@ -1,28 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AuthDeviceSettingsComponent } from './auth-device-settings.component';
import { MatPaginatorModule } from '@angular/material/paginator';
@NgModule({
declarations: [AuthDeviceSettingsComponent],
exports: [AuthDeviceSettingsComponent],
imports: [
CommonModule,
MatButtonModule,
MatMenuModule,
MatSortModule,
MatTableModule,
MatPaginatorModule,
NgxSkeletonLoaderModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAuthDeviceSettingsModule {}

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

@ -85,13 +85,6 @@ export class HeaderComponent implements OnChanges {
}
public openLoginDialog(): void {
if (this.webAuthnService.isEnabled()) {
this.webAuthnService.verifyWebAuthn().subscribe(({ authToken }) => {
this.setToken(authToken, false);
});
return;
}
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, {
autoFocus: false,
data: {

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

@ -19,7 +19,7 @@
[(ngModel)]="data.accessToken"
></textarea>
</mat-form-field>
<mat-checkbox [(ngModel)]="data.staySignedIn">Stay signed in</mat-checkbox>
<mat-checkbox i18n [(ngModel)]="data.staySignedIn">Stay signed in</mat-checkbox>
</div>
</div>
<div class="float-right" mat-dialog-actions>

17
apps/client/src/app/core/http-response.interceptor.ts

@ -2,12 +2,10 @@ import {
HTTP_INTERCEPTORS,
HttpErrorResponse,
HttpEvent,
HttpResponse
} from '@angular/common/http';
import {
HttpHandler,
HttpInterceptor,
HttpRequest
HttpRequest,
HttpResponse
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
@ -21,6 +19,7 @@ import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { TokenStorageService } from '../services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
@Injectable()
export class HttpResponseInterceptor implements HttpInterceptor {
@ -29,7 +28,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
public constructor(
private router: Router,
private tokenStorageService: TokenStorageService,
private snackBar: MatSnackBar
private snackBar: MatSnackBar,
private webAuthnService: WebAuthnService
) {}
public intercept(
@ -78,8 +78,15 @@ export class HttpResponseInterceptor implements HttpInterceptor {
});
}
} else if (error.status === StatusCodes.UNAUTHORIZED) {
if (this.webAuthnService.isEnabled()) {
this.webAuthnService.login().subscribe(({ authToken }) => {
this.tokenStorageService.saveToken(authToken, false);
window.location.reload();
});
} else {
this.tokenStorageService.signOut();
}
}
return throwError('');
})

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

@ -5,11 +5,8 @@ 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 { 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 { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
@ -25,7 +22,6 @@ 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>();
@ -60,8 +56,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.fetchAuthDevices();
}
/**
@ -99,68 +93,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
public startWebAuthn() {
public registerDevice() {
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();
.register()
.subscribe(() => this.changeDetectorRef.markForCheck());
}
});
}
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() {
public deregisterDevice() {
this.webAuthnService
.fetchAuthDevices()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((authDevices) => {
this.authDevices$.next(authDevices);
});
.deregister()
.subscribe(() => this.changeDetectorRef.markForCheck());
}
private update() {

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

@ -66,42 +66,28 @@
</form>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div *ngIf="accesses?.length > 0" class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
<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">
<div class="d-flex mt-4 py-1">
<div class="pt-4 w-50" i18n>Authenticator</div>
<div class="w-50">
<button
*ngIf="!webAuthnService.isEnabled()"
class="d-inline-block"
color="primary"
i18n
mat-flat-button
(click)="registerDevice()"
>
Setup
</button>
<button
*ngIf="webAuthnService.isEnabled()"
class="d-inline-block"
color="primary"
i18n
mat-flat-button
(click)="startWebAuthn()"
[disabled]="webAuthnService.isEnabled()"
(click)="deregisterDevice()"
>
Add current device
Remove
</button>
</div>
</div>
@ -109,4 +95,10 @@
</mat-card>
</div>
</div>
<div *ngIf="accesses?.length > 0" class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
<gf-access-table [accesses]="accesses"></gf-access-table>
</div>
</div>
</div>

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

@ -8,19 +8,16 @@ 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 { AuthDeviceDialog } from '@ghostfolio/client/pages/account/auth-device-dialog/auth-device-dialog.component';
@NgModule({
declarations: [AuthDeviceDialog, AccountPageComponent],
declarations: [AccountPageComponent],
exports: [],
imports: [
AccountPageRoutingModule,
CommonModule,
FormsModule,
GfAuthDeviceSettingsModule,
GfPortfolioAccessTableModule,
MatButtonModule,
MatCardModule,

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

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

@ -1,32 +0,0 @@
<form #addAuthDeviceForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.authDevice.id" mat-dialog-title i18n>Update Device</h1>
<h1 *ngIf="!data.authDevice.id" mat-dialog-title i18n>Add Device</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input
matInput
name="name"
required
[(ngModel)]="data.authDevice.name"
/>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button type="button" i18n mat-button (click)="dialogRef.close()">
Cancel
</button>
<button
type="submit"
color="primary"
i18n
mat-flat-button
[disabled]="!addAuthDeviceForm.form.valid"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

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

@ -1,17 +0,0 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AuthDeviceDialogParams } from '@ghostfolio/api/app/auth/interfaces/interfaces';
@Component({
selector: 'gf-auth-device-dialog',
templateUrl: './auth-device-dialog.component.html',
styleUrls: ['./auth-device-dialog.component.css']
})
export class AuthDeviceDialog implements OnInit {
public constructor(
public dialogRef: MatDialogRef<AuthDeviceDialog>,
@Inject(MAT_DIALOG_DATA) public data: AuthDeviceDialogParams
) {}
public ngOnInit(): void {}
}

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

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { UserService } from './user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
const TOKEN_KEY = 'auth-token';
@ -8,12 +9,15 @@ const TOKEN_KEY = 'auth-token';
providedIn: 'root'
})
export class TokenStorageService {
public constructor(private userService: UserService) {}
public constructor(
private userService: UserService,
private webAuthnService: WebAuthnService
) {}
public getToken(): string {
return (
window.localStorage.getItem(TOKEN_KEY) ||
window.sessionStorage.getItem(TOKEN_KEY)
window.sessionStorage.getItem(TOKEN_KEY) ||
window.localStorage.getItem(TOKEN_KEY)
);
}
@ -25,15 +29,19 @@ export class TokenStorageService {
}
public signOut(): void {
const utmSource = window.sessionStorage.getItem('utm_source');
const utmSource = window.localStorage.getItem('utm_source');
if (this.webAuthnService.isEnabled()) {
this.webAuthnService.deregister().subscribe();
}
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.removeItem(TOKEN_KEY);
this.userService.remove();
if (utmSource) {
window.sessionStorage.setItem('utm_source', utmSource);
window.localStorage.setItem('utm_source', utmSource);
}
}
}

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

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { switchMap, tap } from 'rxjs/operators';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { startAssertion, startAttestation } from '@simplewebauthn/browser';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import {
@ -8,22 +8,28 @@ import {
PublicKeyCredentialRequestOptionsJSON
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { of } from 'rxjs';
@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 userService: UserService,
private settingsStorageService: SettingsStorageService,
private http: HttpClient
private http: HttpClient,
private settingsStorageService: SettingsStorageService
) {}
public startWebAuthn() {
public isSupported() {
return typeof PublicKeyCredential !== 'undefined';
}
public isEnabled() {
return !!this.getDeviceId();
}
public register() {
return this.http
.get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`,
@ -32,40 +38,45 @@ export class WebAuthnService {
.pipe(
switchMap((attOps) => {
return startAttestation(attOps);
})
);
}),
switchMap((attResp) => {
return this.http.post<AuthDeviceDto>(
`/api/auth/webauthn/verify-attestation`,
{
credential: attResp
}
public verifyAttestation(attResp, deviceName) {
return this.http
.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, {
credential: attResp,
deviceName: deviceName
})
.pipe(
);
}),
tap((authDevice) =>
this.userService.get().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
public deregister() {
const deviceId = this.getDeviceId();
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${deviceId}`).pipe(
catchError((e) => {
console.warn(`Could not deregister device ${deviceId}`, e);
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`,
{ userId }
{ deviceId }
)
.pipe(
switchMap(startAssertion),
@ -74,48 +85,16 @@ export class WebAuthnService {
`/api/auth/webauthn/verify-assertion`,
{
credential: assertionResponse,
userId
deviceId
}
);
})
);
}
public getCurrentDeviceId() {
private getDeviceId() {
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
);
}
})
);
}
}

18
prisma/migrations/20210612110542_added_auth_device/migration.sql

@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "authChallenge" TEXT;
-- CreateTable
CREATE TABLE "AuthDevice" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"credentialId" BYTEA NOT NULL,
"credentialPublicKey" BYTEA NOT NULL,
"counter" INTEGER NOT NULL,
"id" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "AuthDevice" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

5
prisma/schema.prisma

@ -52,13 +52,10 @@ model AuthDevice {
credentialId Bytes
credentialPublicKey Bytes
counter Int
id String @default(uuid())
name String
id String @id @default(uuid())
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
@@id([id, userId])
}
model MarketData {

Loading…
Cancel
Save