From ecfb800600184dc38c35b931814229034c17014c Mon Sep 17 00:00:00 2001 From: Matthias Frey Date: Sat, 12 Jun 2021 13:11:03 +0200 Subject: [PATCH] Remove device list feature, sign in with deviceId instead --- .../app/auth-device/auth-device.controller.ts | 71 +---------- .../src/app/auth-device/auth-device.dto.ts | 1 - apps/api/src/app/auth/auth.controller.ts | 8 +- apps/api/src/app/auth/web-auth.service.ts | 90 ++++---------- .../auth-device-settings.component.html | 57 --------- .../auth-device-settings.component.scss | 0 .../auth-device-settings.component.ts | 64 ---------- .../auth-device-settings.module.ts | 28 ----- .../app/components/header/header.component.ts | 7 -- .../login-with-access-token-dialog.html | 2 +- .../src/app/core/http-response.interceptor.ts | 19 ++- .../pages/account/account-page.component.ts | 74 ++---------- .../src/app/pages/account/account-page.html | 52 ++++---- .../app/pages/account/account-page.module.ts | 5 +- .../auth-device-dialog.component.css | 0 .../auth-device-dialog.component.html | 32 ----- .../auth-device-dialog.component.ts | 17 --- .../src/app/services/token-storage.service.ts | 20 +++- .../src/app/services/web-authn.service.ts | 111 +++++++----------- .../migration.sql | 18 +++ prisma/schema.prisma | 5 +- 21 files changed, 153 insertions(+), 528 deletions(-) delete mode 100644 apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html delete mode 100644 apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss delete mode 100644 apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts delete mode 100644 apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts delete mode 100644 apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css delete mode 100644 apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html delete mode 100644 apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts create mode 100644 prisma/migrations/20210612110542_added_auth_device/migration.sql diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts index 5a0106c70..2eca1e165 100644 --- a/apps/api/src/app/auth-device/auth-device.controller.ts +++ b/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 { - 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 }); } } diff --git a/apps/api/src/app/auth-device/auth-device.dto.ts b/apps/api/src/app/auth-device/auth-device.dto.ts index 20819e0e2..3be7f4cac 100644 --- a/apps/api/src/app/auth-device/auth-device.dto.ts +++ b/apps/api/src/app/auth-device/auth-device.dto.ts @@ -1,5 +1,4 @@ export interface AuthDeviceDto { createdAt: string; id: string; - name: string; } diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index befa72a1a..cbcccdaac 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/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 }; diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index f4521f625..39dcfe98e 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/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, - 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...) - */ + allowCredentials: [ + { + id: device.credentialId, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + } + ], 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 device = await this.deviceService.authDevice({ id: deviceId }); - 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 ${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({ diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html deleted file mode 100644 index 4cd1c8b80..000000000 --- a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - -
Name - {{ element.name }}{{ element.id === currentDeviceId ? ' (current)' : '' }} - Created at{{ element.createdAt | date }} - - - - - -
- - - - diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts deleted file mode 100644 index c6b9e22d8..000000000 --- a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts +++ /dev/null @@ -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(); - @Output() authDeviceToUpdate = new EventEmitter(); - - @ViewChild(MatPaginator) paginator: MatPaginator; - @ViewChild(MatSort) sort: MatSort; - - public dataSource: MatTableDataSource = 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); - } - } -} diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts b/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts deleted file mode 100644 index 55e5fa230..000000000 --- a/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index a3c47bfc0..1affa49cd 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/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: { diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 8460a3c9e..2ae0dc82d 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -19,7 +19,7 @@ [(ngModel)]="data.accessToken" > - Stay signed in + Stay signed in
diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 3f49e7891..290fac58f 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/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,7 +78,14 @@ export class HttpResponseInterceptor implements HttpInterceptor { }); } } else if (error.status === StatusCodes.UNAUTHORIZED) { - this.tokenStorageService.signOut(); + if (this.webAuthnService.isEnabled()) { + this.webAuthnService.login().subscribe(({ authToken }) => { + this.tokenStorageService.saveToken(authToken, false); + window.location.reload(); + }); + } else { + this.tokenStorageService.signOut(); + } } return throwError(''); diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 18bbfd32a..6fdca84ca 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/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 = new ReplaySubject(1); private unsubscribeSubject = new Subject(); @@ -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() { diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 187b35632..f945fd19b 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -66,42 +66,28 @@
- - - - -
-
-

Granted Access

- -
-
-
-
-

WebAuthn devices

- - -
-
- -
-
-
-
+
+
Authenticator
+
+
@@ -109,4 +95,10 @@
+
+
+

Granted Access

+ +
+
diff --git a/apps/client/src/app/pages/account/account-page.module.ts b/apps/client/src/app/pages/account/account-page.module.ts index 32bb36b40..d7dbff730 100644 --- a/apps/client/src/app/pages/account/account-page.module.ts +++ b/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, diff --git a/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html deleted file mode 100644 index 47e1f7934..000000000 --- a/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.html +++ /dev/null @@ -1,32 +0,0 @@ -
-

Update Device

-

Add Device

-
-
- - Name - - -
-
-
- - -
-
diff --git a/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts b/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts deleted file mode 100644 index c7359a871..000000000 --- a/apps/client/src/app/pages/account/auth-device-dialog/auth-device-dialog.component.ts +++ /dev/null @@ -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, - @Inject(MAT_DIALOG_DATA) public data: AuthDeviceDialogParams - ) {} - - public ngOnInit(): void {} -} diff --git a/apps/client/src/app/services/token-storage.service.ts b/apps/client/src/app/services/token-storage.service.ts index 8a33159ed..e872748da 100644 --- a/apps/client/src/app/services/token-storage.service.ts +++ b/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); } } } diff --git a/apps/client/src/app/services/web-authn.service.ts b/apps/client/src/app/services/web-authn.service.ts index 98e06b3b7..add613e22 100644 --- a/apps/client/src/app/services/web-authn.service.ts +++ b/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( `/api/auth/webauthn/generate-attestation-options`, @@ -32,40 +38,45 @@ export class WebAuthnService { .pipe( switchMap((attOps) => { return startAttestation(attOps); - }) - ); - } - - public verifyAttestation(attResp, deviceName) { - return this.http - .post(`/api/auth/webauthn/verify-attestation`, { - credential: attResp, - deviceName: deviceName - }) - .pipe( + }), + switchMap((attResp) => { + return this.http.post( + `/api/auth/webauthn/verify-attestation`, + { + credential: attResp + } + ); + }), 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 - ); - }) + this.settingsStorageService.setSetting( + WebAuthnService.WEB_AUTH_N_DEVICE_ID, + authDevice.id + ) ) ); } - public verifyWebAuthn() { - const userId = this.settingsStorageService.getSetting( - WebAuthnService.WEB_AUTH_N_USER_ID + public deregister() { + const deviceId = this.getDeviceId(); + return this.http.delete(`/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( `/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('/api/auth-device'); - } - - public updateAuthDevice(aAuthDevice: AuthDeviceDto) { - return this.http.put( - `/api/auth-device/${aAuthDevice.id}`, - aAuthDevice - ); - } - - public deleteAuthDevice(aId: string) { - return this.http.delete(`/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 - ); - } - }) - ); - } } diff --git a/prisma/migrations/20210612110542_added_auth_device/migration.sql b/prisma/migrations/20210612110542_added_auth_device/migration.sql new file mode 100644 index 000000000..28d8d7c25 --- /dev/null +++ b/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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c8a19a309..f7ed0bf47 100644 --- a/prisma/schema.prisma +++ b/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 {