mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Move device registration to account page * Replace the token login with a WebAuthn prompt if the current device has been registered * Mark the current device in the list of registered auth devicespull/82/head
committed by
Thomas
32 changed files with 536 additions and 307 deletions
@ -0,0 +1,187 @@ |
|||
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse { |
|||
readonly authenticatorData: ArrayBuffer; |
|||
readonly signature: ArrayBuffer; |
|||
readonly userHandle: ArrayBuffer | null; |
|||
} |
|||
export interface AuthenticatorAttestationResponse extends AuthenticatorResponse { |
|||
readonly attestationObject: ArrayBuffer; |
|||
} |
|||
export interface AuthenticationExtensionsClientInputs { |
|||
appid?: string; |
|||
appidExclude?: string; |
|||
credProps?: boolean; |
|||
uvm?: boolean; |
|||
} |
|||
export interface AuthenticationExtensionsClientOutputs { |
|||
appid?: boolean; |
|||
credProps?: CredentialPropertiesOutput; |
|||
uvm?: UvmEntries; |
|||
} |
|||
export interface AuthenticatorSelectionCriteria { |
|||
authenticatorAttachment?: AuthenticatorAttachment; |
|||
requireResidentKey?: boolean; |
|||
residentKey?: ResidentKeyRequirement; |
|||
userVerification?: UserVerificationRequirement; |
|||
} |
|||
export interface PublicKeyCredential extends Credential { |
|||
readonly rawId: ArrayBuffer; |
|||
readonly response: AuthenticatorResponse; |
|||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs; |
|||
} |
|||
export interface PublicKeyCredentialCreationOptions { |
|||
attestation?: AttestationConveyancePreference; |
|||
authenticatorSelection?: AuthenticatorSelectionCriteria; |
|||
challenge: BufferSource; |
|||
excludeCredentials?: PublicKeyCredentialDescriptor[]; |
|||
extensions?: AuthenticationExtensionsClientInputs; |
|||
pubKeyCredParams: PublicKeyCredentialParameters[]; |
|||
rp: PublicKeyCredentialRpEntity; |
|||
timeout?: number; |
|||
user: PublicKeyCredentialUserEntity; |
|||
} |
|||
export interface PublicKeyCredentialDescriptor { |
|||
id: BufferSource; |
|||
transports?: AuthenticatorTransport[]; |
|||
type: PublicKeyCredentialType; |
|||
} |
|||
export interface PublicKeyCredentialParameters { |
|||
alg: COSEAlgorithmIdentifier; |
|||
type: PublicKeyCredentialType; |
|||
} |
|||
export interface PublicKeyCredentialRequestOptions { |
|||
allowCredentials?: PublicKeyCredentialDescriptor[]; |
|||
challenge: BufferSource; |
|||
extensions?: AuthenticationExtensionsClientInputs; |
|||
rpId?: string; |
|||
timeout?: number; |
|||
userVerification?: UserVerificationRequirement; |
|||
} |
|||
export interface PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity { |
|||
displayName: string; |
|||
id: BufferSource; |
|||
} |
|||
export interface AuthenticatorResponse { |
|||
readonly clientDataJSON: ArrayBuffer; |
|||
} |
|||
export interface CredentialPropertiesOutput { |
|||
rk?: boolean; |
|||
} |
|||
export interface Credential { |
|||
readonly id: string; |
|||
readonly type: string; |
|||
} |
|||
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity { |
|||
id?: string; |
|||
} |
|||
export interface PublicKeyCredentialEntity { |
|||
name: string; |
|||
} |
|||
export declare type AttestationConveyancePreference = "direct" | "enterprise" | "indirect" | "none"; |
|||
export declare type AuthenticatorTransport = "ble" | "internal" | "nfc" | "usb"; |
|||
export declare type COSEAlgorithmIdentifier = number; |
|||
export declare type UserVerificationRequirement = "discouraged" | "preferred" | "required"; |
|||
export declare type UvmEntries = UvmEntry[]; |
|||
export declare type AuthenticatorAttachment = "cross-platform" | "platform"; |
|||
export declare type ResidentKeyRequirement = "discouraged" | "preferred" | "required"; |
|||
export declare type BufferSource = ArrayBufferView | ArrayBuffer; |
|||
export declare type PublicKeyCredentialType = "public-key"; |
|||
export declare type UvmEntry = number[]; |
|||
|
|||
export interface PublicKeyCredentialCreationOptionsJSON extends Omit<PublicKeyCredentialCreationOptions, 'challenge' | 'user' | 'excludeCredentials'> { |
|||
user: PublicKeyCredentialUserEntityJSON; |
|||
challenge: Base64URLString; |
|||
excludeCredentials: PublicKeyCredentialDescriptorJSON[]; |
|||
extensions?: AuthenticationExtensionsClientInputs; |
|||
} |
|||
/** |
|||
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to |
|||
* (eventually) get passed into navigator.credentials.get(...) in the browser. |
|||
*/ |
|||
export interface PublicKeyCredentialRequestOptionsJSON extends Omit<PublicKeyCredentialRequestOptions, 'challenge' | 'allowCredentials'> { |
|||
challenge: Base64URLString; |
|||
allowCredentials?: PublicKeyCredentialDescriptorJSON[]; |
|||
extensions?: AuthenticationExtensionsClientInputs; |
|||
} |
|||
export interface PublicKeyCredentialDescriptorJSON extends Omit<PublicKeyCredentialDescriptor, 'id'> { |
|||
id: Base64URLString; |
|||
} |
|||
export interface PublicKeyCredentialUserEntityJSON extends Omit<PublicKeyCredentialUserEntity, 'id'> { |
|||
id: string; |
|||
} |
|||
/** |
|||
* The value returned from navigator.credentials.create() |
|||
*/ |
|||
export interface AttestationCredential extends PublicKeyCredential { |
|||
response: AuthenticatorAttestationResponseFuture; |
|||
} |
|||
/** |
|||
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that |
|||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server. |
|||
*/ |
|||
export interface AttestationCredentialJSON extends Omit<AttestationCredential, 'response' | 'rawId' | 'getClientExtensionResults'> { |
|||
rawId: Base64URLString; |
|||
response: AuthenticatorAttestationResponseJSON; |
|||
clientExtensionResults: AuthenticationExtensionsClientOutputs; |
|||
transports?: AuthenticatorTransport[]; |
|||
} |
|||
/** |
|||
* The value returned from navigator.credentials.get() |
|||
*/ |
|||
export interface AssertionCredential extends PublicKeyCredential { |
|||
response: AuthenticatorAssertionResponse; |
|||
} |
|||
/** |
|||
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that |
|||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server. |
|||
*/ |
|||
export interface AssertionCredentialJSON extends Omit<AssertionCredential, 'response' | 'rawId' | 'getClientExtensionResults'> { |
|||
rawId: Base64URLString; |
|||
response: AuthenticatorAssertionResponseJSON; |
|||
clientExtensionResults: AuthenticationExtensionsClientOutputs; |
|||
} |
|||
/** |
|||
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that |
|||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server. |
|||
*/ |
|||
export interface AuthenticatorAttestationResponseJSON extends Omit<AuthenticatorAttestationResponseFuture, 'clientDataJSON' | 'attestationObject'> { |
|||
clientDataJSON: Base64URLString; |
|||
attestationObject: Base64URLString; |
|||
} |
|||
/** |
|||
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that |
|||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server. |
|||
*/ |
|||
export interface AuthenticatorAssertionResponseJSON extends Omit<AuthenticatorAssertionResponse, 'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'> { |
|||
authenticatorData: Base64URLString; |
|||
clientDataJSON: Base64URLString; |
|||
signature: Base64URLString; |
|||
userHandle?: string; |
|||
} |
|||
/** |
|||
* A WebAuthn-compatible device and the information needed to verify assertions by it |
|||
*/ |
|||
export declare type AuthenticatorDevice = { |
|||
credentialPublicKey: Buffer; |
|||
credentialID: Buffer; |
|||
counter: number; |
|||
transports?: AuthenticatorTransport[]; |
|||
}; |
|||
/** |
|||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string |
|||
*/ |
|||
export declare type Base64URLString = string; |
|||
/** |
|||
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7). |
|||
* Maintain an augmented version here so we can implement additional properties as the WebAuthn |
|||
* spec evolves. |
|||
* |
|||
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
|
|||
* |
|||
* Properties marked optional are not supported in all browsers. |
|||
*/ |
|||
export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse { |
|||
getTransports?: () => AuthenticatorTransport[]; |
|||
getAuthenticatorData?: () => ArrayBuffer; |
|||
getPublicKey?: () => ArrayBuffer; |
|||
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[]; |
|||
} |
@ -1,14 +0,0 @@ |
|||
import { NgModule } from '@angular/core'; |
|||
import { RouterModule, Routes } from '@angular/router'; |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component'; |
|||
|
|||
const routes: Routes = [ |
|||
{ path: '', component: AuthDevicesPageComponent, canActivate: [AuthGuard] } |
|||
]; |
|||
|
|||
@NgModule({ |
|||
imports: [RouterModule.forChild(routes)], |
|||
exports: [RouterModule] |
|||
}) |
|||
export class AuthDevicesPageRoutingModule {} |
@ -1,46 +0,0 @@ |
|||
<div class='container'> |
|||
<div class='row'> |
|||
<div class='col'> |
|||
<h3 class='mb-3 text-center' i18n>WebAuthn</h3> |
|||
<mat-card class='mb-3'> |
|||
<mat-card-content> |
|||
<div class='row mb-3'> |
|||
<div class='col'> |
|||
<gf-auth-device-settings [authDevices]='authDevices$ | async' |
|||
(authDeviceDeleted)='deleteAuthDevice($event)' |
|||
(authDeviceToUpdate)='updateAuthDevice($event)' |
|||
></gf-auth-device-settings> |
|||
</div> |
|||
</div> |
|||
<div class='row mb-3'> |
|||
<div class='col'> |
|||
<button |
|||
class='d-inline-block' |
|||
color='primary' |
|||
i18n |
|||
mat-flat-button |
|||
(click)='startWebAuthn()' |
|||
> |
|||
Add this device |
|||
</button> |
|||
</div> |
|||
|
|||
</div> |
|||
<div class='row'> |
|||
<div class='col'> |
|||
<button |
|||
class='d-inline-block' |
|||
color='primary' |
|||
i18n |
|||
mat-flat-button |
|||
(click)='verifyWebAuthn()' |
|||
> |
|||
DEBUG: verify WebAuthn |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
</div> |
@ -1,115 +0,0 @@ |
|||
import { Component, OnDestroy, OnInit } from '@angular/core'; |
|||
import { startAssertion, startAttestation } from '@simplewebauthn/browser'; |
|||
import { filter, switchMap, takeUntil } from 'rxjs/operators'; |
|||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; |
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { ReplaySubject, Subject } from 'rxjs'; |
|||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component'; |
|||
import { isNonNull } from '@ghostfolio/client/util/rxjs.util'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-auth-devices-page', |
|||
templateUrl: './auth-devices-page.component.html', |
|||
styleUrls: ['./auth-devices-page.component.scss'] |
|||
}) |
|||
export class AuthDevicesPageComponent implements OnDestroy, OnInit { |
|||
|
|||
public authDevices$: ReplaySubject<AuthDeviceDto[]> = new ReplaySubject(1); |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
|
|||
constructor( |
|||
private dataService: DataService, |
|||
private tokenStorageService: TokenStorageService, |
|||
private http: HttpClient, |
|||
private dialog: MatDialog |
|||
) { |
|||
this.fetchAuthDevices(); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
public startWebAuthn() { |
|||
this.http.get<any>(`/api/auth/webauthn/generate-attestation-options`, {}) |
|||
.pipe( |
|||
switchMap(attOps => { |
|||
return startAttestation(attOps); |
|||
}), |
|||
switchMap(attResp => { |
|||
const dialogRef = this.dialog.open(AuthDeviceDialog, { |
|||
data: { |
|||
authDevice: {} |
|||
} |
|||
}); |
|||
return dialogRef.afterClosed().pipe(switchMap(data => { |
|||
const reqBody = { |
|||
...attResp, |
|||
deviceName: data.authDevice.name |
|||
}; |
|||
return this.http.post<any>(`/api/auth/webauthn/verify-attestation`, reqBody); |
|||
})); |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.fetchAuthDevices(); |
|||
}); |
|||
} |
|||
|
|||
public verifyWebAuthn() { |
|||
this.http.get<any>(`/api/auth/webauthn/generate-assertion-options`, {}) |
|||
.pipe( |
|||
switchMap(startAssertion), |
|||
switchMap(assertionResponse => this.http.post<any>(`/api/auth/webauthn/verify-assertion`, assertionResponse)) |
|||
) |
|||
.subscribe(res => { |
|||
if (res?.verified) alert('success'); |
|||
else alert('fail'); |
|||
}); |
|||
} |
|||
|
|||
public deleteAuthDevice(aId: string) { |
|||
this.dataService.deleteAuthDevice(aId).subscribe({ |
|||
next: () => { |
|||
this.fetchAuthDevices(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public updateAuthDevice(aAuthDevice: AuthDeviceDto) { |
|||
const dialogRef = this.dialog.open(AuthDeviceDialog, { |
|||
data: { |
|||
authDevice: aAuthDevice |
|||
} |
|||
}); |
|||
|
|||
dialogRef.afterClosed() |
|||
.pipe( |
|||
filter(isNonNull), |
|||
switchMap(data => this.dataService.updateAuthDevice(data.authDevice)) |
|||
) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.fetchAuthDevices(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private fetchAuthDevices() { |
|||
this.dataService |
|||
.fetchAuthDevices() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(authDevices => { |
|||
this.authDevices$.next(authDevices); |
|||
}); |
|||
} |
|||
} |
@ -1,37 +0,0 @@ |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { AuthDevicesPageRoutingModule } from '@ghostfolio/client/pages/auth-devices/auth-devices-page-routing.module'; |
|||
import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component'; |
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|||
import { GfAuthDeviceSettingsModule } from '@ghostfolio/client/components/auth-device-settings/auth-device-settings.module'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatInputModule } from '@angular/material/input'; |
|||
import { MatSelectModule } from '@angular/material/select'; |
|||
import { MatDialogModule } from '@angular/material/dialog'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component'; |
|||
|
|||
|
|||
|
|||
@NgModule({ |
|||
declarations: [ |
|||
AuthDevicesPageComponent, |
|||
AuthDeviceDialog, |
|||
], |
|||
imports: [ |
|||
CommonModule, |
|||
AuthDevicesPageRoutingModule, |
|||
FormsModule, |
|||
GfAuthDeviceSettingsModule, |
|||
MatCardModule, |
|||
MatFormFieldModule, |
|||
MatInputModule, |
|||
MatSelectModule, |
|||
MatDialogModule, |
|||
ReactiveFormsModule, |
|||
MatButtonModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
|||
}) |
|||
export class AuthDevicesPageModule { } |
@ -0,0 +1,90 @@ |
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { Injectable } from '@angular/core'; |
|||
import { switchMap, tap } from 'rxjs/operators'; |
|||
import { startAssertion, startAttestation } from '@simplewebauthn/browser'; |
|||
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; |
|||
import { |
|||
PublicKeyCredentialCreationOptionsJSON, |
|||
PublicKeyCredentialRequestOptionsJSON |
|||
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root' |
|||
}) |
|||
export class WebAuthnService { |
|||
|
|||
private static readonly WEB_AUTH_N_USER_ID = 'WEB_AUTH_N_USER_ID'; |
|||
private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID'; |
|||
|
|||
public constructor( |
|||
private dataService: DataService, |
|||
private settingsStorageService: SettingsStorageService, |
|||
private http: HttpClient, |
|||
) { |
|||
} |
|||
|
|||
public startWebAuthn() { |
|||
return this.http.get<PublicKeyCredentialCreationOptionsJSON>(`/api/auth/webauthn/generate-attestation-options`, {}) |
|||
.pipe( |
|||
switchMap(attOps => { |
|||
return startAttestation(attOps); |
|||
}) |
|||
); |
|||
} |
|||
|
|||
public verifyAttestation(attResp, deviceName) { |
|||
return this.http.post<AuthDeviceDto>(`/api/auth/webauthn/verify-attestation`, { |
|||
credential: attResp, |
|||
deviceName: deviceName, |
|||
}).pipe(tap(authDevice => |
|||
this.dataService.fetchUser().subscribe((user) => { |
|||
this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID, authDevice.id); |
|||
this.settingsStorageService.setSetting(WebAuthnService.WEB_AUTH_N_USER_ID, user.id); |
|||
}) |
|||
)); |
|||
} |
|||
|
|||
public verifyWebAuthn() { |
|||
const userId = this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_USER_ID); |
|||
return this.http.post<PublicKeyCredentialRequestOptionsJSON>(`/api/auth/webauthn/generate-assertion-options`, {userId}) |
|||
.pipe( |
|||
switchMap(startAssertion), |
|||
switchMap(assertionResponse => { |
|||
return this.http.post<{ authToken: string }>(`/api/auth/webauthn/verify-assertion`, { |
|||
credential: assertionResponse, |
|||
userId |
|||
}) |
|||
}) |
|||
); |
|||
} |
|||
|
|||
public getCurrentDeviceId() { |
|||
return this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); |
|||
} |
|||
|
|||
public isEnabled() { |
|||
return !!this.settingsStorageService.getSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); |
|||
} |
|||
|
|||
public fetchAuthDevices() { |
|||
return this.http.get<AuthDeviceDto[]>('/api/auth-device'); |
|||
} |
|||
|
|||
public updateAuthDevice(aAuthDevice: AuthDeviceDto) { |
|||
return this.http.put<AuthDeviceDto>(`/api/auth-device/${aAuthDevice.id}`, aAuthDevice); |
|||
} |
|||
|
|||
public deleteAuthDevice(aId: string) { |
|||
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${aId}`) |
|||
.pipe( |
|||
tap(() => { |
|||
if (aId === this.getCurrentDeviceId()) { |
|||
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_DEVICE_ID); |
|||
this.settingsStorageService.removeSetting(WebAuthnService.WEB_AUTH_N_USER_ID); |
|||
} |
|||
}) |
|||
); |
|||
} |
|||
} |
Loading…
Reference in new issue