Format code

pull/82/head
Matthias Frey 4 years ago committed by Thomas
parent cf4d78a8b7
commit d3662888a2

@ -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(
{
id_userId: {
id,
userId: this.request.user.id
}
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,19 +83,17 @@ export class AuthDeviceController {
);
}
return this.authDeviceService.updateAuthDevice(
{
data: {
name: data.name
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
return this.authDeviceService.updateAuthDevice({
data: {
name: data.name
},
where: {
id_userId: {
id,
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

@ -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 {}

@ -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: {
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
},
): Promise<AuthDevice> {
public async updateAuthDevice(params: {
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
}): 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

@ -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(

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

@ -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 {

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

@ -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

@ -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' })
});
}

@ -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

@ -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);

@ -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);
});

@ -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';

@ -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);
});
}

@ -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'
[currentDeviceId]="webAuthnService.getCurrentDeviceId()"
(authDeviceDeleted)='deleteAuthDevice($event)'
(authDeviceToUpdate)='updateAuthDevice($event)'
<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="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>

@ -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: []
})

@ -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,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 {}
}

@ -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
) {}
/**

@ -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 {

@ -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`, {
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);
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);
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`, {
credential: assertionResponse,
userId
})
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,18 +98,24 @@ 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(
tap(() => {
if (aId === this.getCurrentDeviceId()) {
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID);
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_USER_ID);
}
})
);
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
);
}
})
);
}
}

Loading…
Cancel
Save