Browse Source

Format code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -14,58 +14,83 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
providedIn: 'root'
})
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
) {}
public startWebAuthn() {
return this.http.get<PublicKeyCredentialCreationOptionsJSON>(`/api/auth/webauthn/generate-attestation-options`, {})
return this.http
.get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`,
{}
)
.pipe(
switchMap(attOps => {
switchMap((attOps) => {
return startAttestation(attOps);
})
);
}
public verifyAttestation(attResp, deviceName) {
return this.http.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, {
return this.http
.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, {
credential: attResp,
deviceName: deviceName,
}).pipe(tap(authDevice =>
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);
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})
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`, {
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);
return this.settingsStorageService.getSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
}
public isEnabled() {
return !!this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID);
return !!this.settingsStorageService.getSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
}
public fetchAuthDevices() {
@ -73,16 +98,22 @@ export class WebAuthnService {
}
public updateAuthDevice(aAuthDevice: AuthDeviceDto) {
return this.http.put<AuthDeviceDto>(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice);
return this.http.put<AuthDeviceDto>(
`/api/auth-device/${aAuthDevice.id}`,
aAuthDevice
);
}
public deleteAuthDevice(aId: string) {
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${aId}`)
.pipe(
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);
this.settingsStorageService.removeSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
this.settingsStorageService.removeSetting(
WebAuthnService.WEB_AUTH_N_USER_ID
);
}
})
);

Loading…
Cancel
Save