add webauthn support, update deps
This commit is contained in:
Generated
+485
-571
File diff suppressed because it is too large
Load Diff
+20
-20
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "we-bstly-angular",
|
"name": "we-bstly-angular",
|
||||||
"version": "3.5.1",
|
"version": "4.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
@@ -11,20 +11,20 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^20.3.10",
|
"@angular/animations": "^20.3.15",
|
||||||
"@angular/cdk": "^20.2.12",
|
"@angular/cdk": "^20.2.14",
|
||||||
"@angular/common": "^20.3.10",
|
"@angular/common": "^20.3.15",
|
||||||
"@angular/compiler": "^20.3.10",
|
"@angular/compiler": "^20.3.15",
|
||||||
"@angular/core": "^20.3.10",
|
"@angular/core": "^20.3.15",
|
||||||
"@angular/forms": "^20.3.10",
|
"@angular/forms": "^20.3.15",
|
||||||
"@angular/material": "^20.2.12",
|
"@angular/material": "^20.2.14",
|
||||||
"@angular/material-moment-adapter": "^20.2.12",
|
"@angular/material-moment-adapter": "^20.2.14",
|
||||||
"@angular/platform-browser": "^20.3.10",
|
"@angular/platform-browser": "^20.3.15",
|
||||||
"@angular/platform-browser-dynamic": "^20.3.10",
|
"@angular/platform-browser-dynamic": "^20.3.15",
|
||||||
"@angular/router": "^20.3.10",
|
"@angular/router": "^20.3.15",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"ng-qrcode": "^20.0.1",
|
"ng-qrcode": "^20.0.1",
|
||||||
"openpgp": "^6.2.2",
|
"openpgp": "^6.3.0",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
@@ -32,15 +32,15 @@
|
|||||||
"zone.js": "~0.15.1"
|
"zone.js": "~0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^20.3.9",
|
"@angular-devkit/build-angular": "^20.3.13",
|
||||||
"@angular/cli": "^20.3.9",
|
"@angular/cli": "^20.3.13",
|
||||||
"@angular/compiler-cli": "^20.3.10",
|
"@angular/compiler-cli": "^20.3.15",
|
||||||
"@angular/localize": "^20.3.10",
|
"@angular/localize": "^20.3.15",
|
||||||
"@types/jasmine": "^5.1.12",
|
"@types/jasmine": "^5.1.13",
|
||||||
"@types/jasminewd2": "^2.0.13",
|
"@types/jasminewd2": "^2.0.13",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.4",
|
||||||
"@types/openpgp": "^5.0.0",
|
"@types/openpgp": "^5.0.0",
|
||||||
"jasmine-core": "~5.12.1",
|
"jasmine-core": "~5.13.0",
|
||||||
"jasmine-spec-reporter": "~7.0.0",
|
"jasmine-spec-reporter": "~7.0.0",
|
||||||
"karma": "^6.4.4",
|
"karma": "^6.4.4",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const routes: Routes = [
|
|||||||
{ path: 'partey/manage', component: ParteyComponent, canActivate: [AuthenticatedGuard] },
|
{ path: 'partey/manage', component: ParteyComponent, canActivate: [AuthenticatedGuard] },
|
||||||
{
|
{
|
||||||
path: '', component: MainComponent, children: [
|
path: '', component: MainComponent, children: [
|
||||||
{ path: '', redirectTo: "/services", pathMatch: 'full' },
|
{ path: '', redirectTo: "/account/info", pathMatch: 'full' },
|
||||||
{ path: 'login', component: FormLoginComponent, canActivate: [AnonymousGuard] },
|
{ path: 'login', component: FormLoginComponent, canActivate: [AnonymousGuard] },
|
||||||
{ path: 'login/2fa', component: FormLogin2FAComponent, canActivate: [AnonymousGuard] },
|
{ path: 'login/2fa', component: FormLogin2FAComponent, canActivate: [AnonymousGuard] },
|
||||||
{ path: 'login/oidc', component: FormLoginOidcComponent, canActivate: [AuthenticatedGuard] },
|
{ path: 'login/oidc', component: FormLoginOidcComponent, canActivate: [AuthenticatedGuard] },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { ServicesGridComponent } from './ui/servicesgrid/servicesgrid.component'
|
|||||||
import { ServicesTableComponent } from './ui/servicestable/servicestable.component';
|
import { ServicesTableComponent } from './ui/servicestable/servicestable.component';
|
||||||
import { ProfileFieldPgpBlob } from './ui/profilefields/binary/pgp/profilefield.pgp-blob';
|
import { ProfileFieldPgpBlob } from './ui/profilefields/binary/pgp/profilefield.pgp-blob';
|
||||||
import { QuotasComponent } from './ui/quotas/quotas.component';
|
import { QuotasComponent } from './ui/quotas/quotas.component';
|
||||||
import { SecurityComponent, SecurityTotpDialog } from './pages/account/security/security.component';
|
import { SecurityComponent, SecurityKeyDialog, SecurityTotpDialog } from './pages/account/security/security.component';
|
||||||
import { VoucherComponent } from './pages/account/voucher/voucher.component';
|
import { VoucherComponent } from './pages/account/voucher/voucher.component';
|
||||||
import { VoucherDialog } from './pages/account/voucher/voucher.component';
|
import { VoucherDialog } from './pages/account/voucher/voucher.component';
|
||||||
import { InfoComponent } from './pages/account/info/info.component';
|
import { InfoComponent } from './pages/account/info/info.component';
|
||||||
@@ -140,6 +140,7 @@ export class XhrInterceptor implements HttpInterceptor {
|
|||||||
QuotasComponent,
|
QuotasComponent,
|
||||||
SecurityComponent,
|
SecurityComponent,
|
||||||
SecurityTotpDialog,
|
SecurityTotpDialog,
|
||||||
|
SecurityKeyDialog,
|
||||||
VoucherComponent,
|
VoucherComponent,
|
||||||
VoucherDialog,
|
VoucherDialog,
|
||||||
InfoComponent,
|
InfoComponent,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<h1 mat-dialog-title>
|
||||||
|
{{'security.webauthn.create' | i18n}}
|
||||||
|
</h1>
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<p>
|
||||||
|
{{'security.webauthn.dialog.info' | i18n}}
|
||||||
|
</p>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{'security.webauthn.nickname' | i18n}}</mat-label>
|
||||||
|
<input matInput [formControl]="nickname">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions>
|
||||||
|
<button mat-button (click)="dialogRef.close(null)">{{'cancel' | i18n}}</button>
|
||||||
|
<button mat-button (click)="dialogRef.close(nickname.value)" cdkFocusInitial>{{'ok' | i18n}}</button>
|
||||||
|
</div>
|
||||||
@@ -4,30 +4,30 @@
|
|||||||
<h2>{{'password.change' | i18n}}</h2>
|
<h2>{{'password.change' | i18n}}</h2>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'password.current' | i18n}}</mat-label>
|
<mat-label>{{'password.current' | i18n}}</mat-label>
|
||||||
<input matInput type="password" formControlName="oldPassword" [(ngModel)]="model.old">
|
<input matInput type="password" formControlName="oldPassword">
|
||||||
@for (error of passwordForm.get('oldPassword').errors | keyvalue; track error) {
|
@for (error of passwordForm.get('oldPassword').errors | keyvalue; track error) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{error.key}}
|
{{error.key}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
@if (success) {
|
@if (success) {
|
||||||
<mat-hint>
|
<mat-hint>
|
||||||
{{'password.changed' | i18n}}
|
{{'password.changed' | i18n}}
|
||||||
</mat-hint>
|
</mat-hint>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'password' | i18n}}</mat-label>
|
<mat-label>{{'password' | i18n}}</mat-label>
|
||||||
<input matInput type="password" formControlName="password" [(ngModel)]="model.password">
|
<input matInput type="password" formControlName="password">
|
||||||
@for (error of passwordForm.get('password').errors | keyvalue; track error) {
|
@for (error of passwordForm.get('password').errors | keyvalue; track error) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{error.key}}
|
{{error.key}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'password.confirm' | i18n}}</mat-label>
|
<mat-label>{{'password.confirm' | i18n}}</mat-label>
|
||||||
<input matInput type="password" formControlName="password2" [(ngModel)]="model.password2">
|
<input matInput type="password" formControlName="password2">
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{'password.not-match' | i18n}}
|
{{'password.not-match' | i18n}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
@@ -35,12 +35,12 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
@if (working) {
|
@if (working) {
|
||||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
}
|
}
|
||||||
@if (!working) {
|
@if (!working) {
|
||||||
<button type="submit" mat-raised-button color="primary" [disabled]="passwordForm.invalid">
|
<button type="submit" mat-raised-button color="primary" [disabled]="passwordForm.invalid">
|
||||||
{{'password.change' | i18n}}
|
{{'password.change' | i18n}}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
@@ -53,34 +53,34 @@
|
|||||||
<p> {{'security.status.hint' | i18n}}</p>
|
<p> {{'security.status.hint' | i18n}}</p>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'security.status' | i18n}}</mat-label>
|
<mat-label>{{'security.status' | i18n}}</mat-label>
|
||||||
<mat-select [(ngModel)]="model.status" formControlName="status">
|
<mat-select #statusSelect formControlName="status">
|
||||||
@for (status of statuses; track status) {
|
@for (status of statuses; track status) {
|
||||||
<mat-option [value]="status">
|
<mat-option [value]="status">
|
||||||
{{'security.status.' + status | i18n}}
|
{{'security.status.' + status | i18n}}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
@if (successStatus) {
|
@if (successStatus) {
|
||||||
<mat-hint>
|
<mat-hint>
|
||||||
{{'security.status.success' | i18n}}
|
{{'security.status.success' | i18n}}
|
||||||
</mat-hint>
|
</mat-hint>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-label>{{'security.status.' + model.status + '.hint' | i18n}}</mat-label>
|
<mat-label>{{'security.status.' + statusSelect.value + '.hint' | i18n}}</mat-label>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
@if (working) {
|
@if (working) {
|
||||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
}
|
}
|
||||||
@if (!working) {
|
@if (!working) {
|
||||||
<button type="submit" mat-raised-button color="primary" [disabled]="statusForm.invalid">
|
<button type="submit" mat-raised-button color="primary" [disabled]="statusForm.invalid">
|
||||||
{{'security.status.change' | i18n}}
|
{{'security.status.change' | i18n}}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
<mat-card-footer>
|
<mat-card-footer>
|
||||||
<a href="https://wiki.bstly.de/services/webstly#status" class="help-button"
|
<a href="https://wiki.bstly.de/services/webstly#status" class="help-button" matTooltip="{{'help-button' | i18n}}"
|
||||||
matTooltip="{{'help-button' | i18n}}" matTooltipPosition="above" target="_blank" mat-fab color="accent">
|
matTooltipPosition="above" target="_blank" mat-fab color="accent">
|
||||||
<mat-icon>contact_support</mat-icon>
|
<mat-icon>contact_support</mat-icon>
|
||||||
</a>
|
</a>
|
||||||
</mat-card-footer>
|
</mat-card-footer>
|
||||||
@@ -94,11 +94,11 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
@if (!totp) {
|
@if (!totp) {
|
||||||
<a (click)="createTotp()" mat-raised-button color="accent">{{'security.2fa.totp.create' |
|
<a (click)="createTotp()" mat-raised-button color="accent">{{'security.2fa.totp.create' |
|
||||||
i18n}}</a>
|
i18n}}</a>
|
||||||
}
|
}
|
||||||
@if (totp) {
|
@if (totp) {
|
||||||
<a (click)="removeTotp()" mat-raised-button color="warn">{{'security.2fa.totp.remove' |
|
<a (click)="removeTotp()" mat-raised-button color="warn">{{'security.2fa.totp.remove' |
|
||||||
i18n}}</a>
|
i18n}}</a>
|
||||||
}
|
}
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
@@ -108,4 +108,53 @@
|
|||||||
<mat-icon>contact_support</mat-icon>
|
<mat-icon>contact_support</mat-icon>
|
||||||
</a>
|
</a>
|
||||||
</mat-card-footer>
|
</mat-card-footer>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
|
@if (webAuthnSupported) {
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-content>
|
||||||
|
<h2>{{'security.webauthn' | i18n}}</h2>
|
||||||
|
<p>{{'security.webauthn.info' | i18n}}</p>
|
||||||
|
|
||||||
|
@if (webAuthnCredentials.length > 0) {
|
||||||
|
<h3>{{'security.webauthn.registered' | i18n}}</h3>
|
||||||
|
<mat-list>
|
||||||
|
@for (credential of webAuthnCredentials; track credential.id) {
|
||||||
|
<mat-list-item>
|
||||||
|
<span matListItemTitle>
|
||||||
|
{{ credential.nickname | i18n }}
|
||||||
|
</span>
|
||||||
|
<span matListItemLine>
|
||||||
|
{{ credential.createdAt | datef }}
|
||||||
|
</span>
|
||||||
|
<div matListItemMeta style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<mat-form-field style="width: 120px;">
|
||||||
|
<mat-label>{{'security.webauthn.usage' | i18n}}</mat-label>
|
||||||
|
<mat-select [value]="credential.usage"
|
||||||
|
(selectionChange)="updateCredentialUsage(credential.id, $event.value)">
|
||||||
|
<mat-option value="NONE">{{'security.webauthn.usage.none' | i18n}}</mat-option>
|
||||||
|
<mat-option value="TWO_FA">{{'security.webauthn.usage.two-fa' | i18n}}</mat-option>
|
||||||
|
<mat-option value="LOGIN">{{'security.webauthn.usage.login' | i18n}}</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-icon-button color="warn" (click)="removeWebAuthnCredential(credential.id, credential.nickname)"
|
||||||
|
[matTooltip]="'security.webauthn.remove' | i18n">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-list-item>
|
||||||
|
}
|
||||||
|
</mat-list>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions>
|
||||||
|
<a (click)="createWebAuthn()" mat-raised-button color="accent">{{'security.webauthn.create' | i18n}}</a>
|
||||||
|
</mat-card-actions>
|
||||||
|
<mat-card-footer>
|
||||||
|
<a href="https://wiki.bstly.de/services/webstly#webauthn" class="help-button" matTooltip="{{'help-button' | i18n}}"
|
||||||
|
matTooltipPosition="above" target="_blank" mat-fab color="accent">
|
||||||
|
<mat-icon>contact_support</mat-icon>
|
||||||
|
</a>
|
||||||
|
</mat-card-footer>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import { Component, Inject, OnInit, ViewChild } from '@angular/core';
|
|||||||
import { FormBuilder, FormControl, FormGroup, NgForm, Validators } from '@angular/forms';
|
import { FormBuilder, FormControl, FormGroup, NgForm, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
|
||||||
|
import { ConfirmDialog } from 'src/app/ui/confirm/confirm.component';
|
||||||
import { Auth2FAService } from './../../../services/auth.2fa.service';
|
import { Auth2FAService } from './../../../services/auth.2fa.service';
|
||||||
import { UserService } from './../../../services/user.service';
|
import { UserService } from './../../../services/user.service';
|
||||||
|
import { WebAuthnService } from './../../../services/webauthn.service';
|
||||||
import { MatchingValidator } from './../../../utils/matching.validator';
|
import { MatchingValidator } from './../../../utils/matching.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -14,12 +16,13 @@ import { MatchingValidator } from './../../../utils/matching.validator';
|
|||||||
})
|
})
|
||||||
export class SecurityComponent implements OnInit {
|
export class SecurityComponent implements OnInit {
|
||||||
|
|
||||||
|
|
||||||
model: any = {};
|
|
||||||
public working: boolean;
|
public working: boolean;
|
||||||
public success: boolean;
|
public success: boolean;
|
||||||
public successStatus: boolean;
|
public successStatus: boolean;
|
||||||
public totp: boolean = false;
|
public totp: boolean = false;
|
||||||
|
public webauthn: boolean = false;
|
||||||
|
public webAuthnSupported: boolean = false;
|
||||||
|
public webAuthnCredentials: any[] = [];
|
||||||
|
|
||||||
statuses = ["NORMAL", "SLEEP", "PURGE"];
|
statuses = ["NORMAL", "SLEEP", "PURGE"];
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ export class SecurityComponent implements OnInit {
|
|||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private auth2FAService: Auth2FAService,
|
private auth2FAService: Auth2FAService,
|
||||||
|
private webAuthnService: WebAuthnService,
|
||||||
public dialog: MatDialog) { }
|
public dialog: MatDialog) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -49,7 +53,7 @@ export class SecurityComponent implements OnInit {
|
|||||||
|
|
||||||
this.userService.get().subscribe({
|
this.userService.get().subscribe({
|
||||||
next: (response: any) => {
|
next: (response: any) => {
|
||||||
this.model.status = response.status;
|
this.statusForm.get('status').setValue(response.status);
|
||||||
}, error: (error) => { }
|
}, error: (error) => { }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,13 +65,38 @@ export class SecurityComponent implements OnInit {
|
|||||||
this.totp = false;
|
this.totp = false;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.loadWebAuthnCredentials();
|
||||||
|
|
||||||
|
this.webAuthnSupported = this.webAuthnService.isSupported();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWebAuthnCredentials() {
|
||||||
|
if (this.webAuthnService.isSupported()) {
|
||||||
|
this.webAuthnService.getCredentials().subscribe({
|
||||||
|
next: (credentials) => {
|
||||||
|
this.webAuthnCredentials = credentials;
|
||||||
|
this.webauthn = credentials.length > 0;
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.webAuthnCredentials = [];
|
||||||
|
this.webauthn = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changePassword() {
|
changePassword() {
|
||||||
if (this.passwordForm.valid && !this.working) {
|
if (this.passwordForm.valid && !this.working) {
|
||||||
this.working = true;
|
this.working = true;
|
||||||
|
|
||||||
this.userService.password(this.model).subscribe({
|
const model = {
|
||||||
|
old: this.passwordForm.get('oldPassword').value,
|
||||||
|
password: this.passwordForm.get('password').value,
|
||||||
|
password2: this.passwordForm.get('password2').value
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userService.password(model).subscribe({
|
||||||
next: (result: any) => {
|
next: (result: any) => {
|
||||||
this.passwordFormDirective.resetForm();
|
this.passwordFormDirective.resetForm();
|
||||||
this.success = true;
|
this.success = true;
|
||||||
@@ -96,7 +125,7 @@ export class SecurityComponent implements OnInit {
|
|||||||
if (this.statusForm.valid && !this.working) {
|
if (this.statusForm.valid && !this.working) {
|
||||||
this.working = true;
|
this.working = true;
|
||||||
|
|
||||||
this.userService.update({ status: this.model.status }).subscribe({
|
this.userService.update({ status: this.statusForm.get('status').value }).subscribe({
|
||||||
next: (result: any) => {
|
next: (result: any) => {
|
||||||
this.successStatus = true;
|
this.successStatus = true;
|
||||||
this.working = false;
|
this.working = false;
|
||||||
@@ -140,21 +169,87 @@ export class SecurityComponent implements OnInit {
|
|||||||
|
|
||||||
|
|
||||||
enableTotp() {
|
enableTotp() {
|
||||||
const dialogRef = this.dialog.open(SecurityTotpDialog, {
|
this.dialog.open(SecurityTotpDialog, {
|
||||||
closeOnNavigation: false,
|
closeOnNavigation: false,
|
||||||
disableClose: true,
|
disableClose: true
|
||||||
data: {}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
removeTotp() {
|
removeTotp() {
|
||||||
this.auth2FAService.remove('totp').subscribe({
|
const dialogRef = this.dialog.open(ConfirmDialog, {
|
||||||
next: (result: any) => {
|
data: {
|
||||||
this.totp = false;
|
'label': 'security.2fa.totp.confirmRemove'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
if (result) {
|
||||||
|
this.auth2FAService.remove('totp').subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.totp = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createWebAuthn() {
|
||||||
|
const dialogRef = this.dialog.open(SecurityKeyDialog, {
|
||||||
|
closeOnNavigation: false,
|
||||||
|
disableClose: true
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(nickname => {
|
||||||
|
if (!!nickname) {
|
||||||
|
this.webAuthnService.register(nickname).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadWebAuthnCredentials();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Webauthn registration failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWebAuthnCredential(credentialId: number, nickname: string) {
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialog, {
|
||||||
|
data: {
|
||||||
|
'label': 'security.webauthn.confirmRemove',
|
||||||
|
'args': [nickname]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
if (result) {
|
||||||
|
this.webAuthnService.deleteCredential(credentialId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadWebAuthnCredentials();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Failed to remove credential:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCredentialUsage(credentialId: number, usage: string) {
|
||||||
|
this.webAuthnService.updateUsage(credentialId, usage).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadWebAuthnCredentials();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Failed to update credential usage:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -176,4 +271,22 @@ export class SecurityTotpDialog {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.code = new FormControl('', [Validators.required, Validators.pattern("[0-9]{6}")]);
|
this.code = new FormControl('', [Validators.required, Validators.pattern("[0-9]{6}")]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: false,
|
||||||
|
selector: 'app-security-key-dialog',
|
||||||
|
templateUrl: 'security-key.dialog.html',
|
||||||
|
styleUrls: ['./security.component.scss']
|
||||||
|
})
|
||||||
|
export class SecurityKeyDialog implements OnInit {
|
||||||
|
|
||||||
|
nickname: FormControl;
|
||||||
|
|
||||||
|
constructor(public dialogRef: MatDialogRef<SecurityKeyDialog>) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.nickname = new FormControl('', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,18 +32,21 @@ export class VoucherComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(name: string) {
|
create(name: string) {
|
||||||
this.voucherService.create(name).toPromise().then(data => {
|
this.voucherService.create(name).subscribe({
|
||||||
this.model.type = name;
|
next: (data) => {
|
||||||
this.model.code = data;
|
this.model.type = name;
|
||||||
this.vouchers.push(this.model);
|
this.model.code = data;
|
||||||
this.voucherSource.data = this.vouchers;
|
this.vouchers.push(this.model);
|
||||||
const dialogRef = this.dialog.open(VoucherDialog, {
|
this.voucherSource.data = this.vouchers;
|
||||||
closeOnNavigation: false,
|
|
||||||
disableClose: true,
|
|
||||||
data: this.model
|
|
||||||
});
|
|
||||||
}, error => {
|
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(VoucherDialog, {
|
||||||
|
closeOnNavigation: false,
|
||||||
|
disableClose: true,
|
||||||
|
data: this.model
|
||||||
|
})
|
||||||
|
}, error: () => {
|
||||||
|
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class AdminUserEditDialog implements OnInit {
|
|||||||
|
|
||||||
const userData = {
|
const userData = {
|
||||||
...this.user,
|
...this.user,
|
||||||
username: this.form.value.username,
|
username: this.form.value.username || this.user.username,
|
||||||
disabled: this.form.value.disabled,
|
disabled: this.form.value.disabled,
|
||||||
locked: this.form.value.locked,
|
locked: this.form.value.locked,
|
||||||
status: this.form.value.status
|
status: this.form.value.status
|
||||||
|
|||||||
@@ -1,50 +1,52 @@
|
|||||||
<form action="{{apiUrl}}/auth/login/2fa" method="POST" #form2FA>
|
<form action="{{apiUrl}}/auth/login/2fa" method="POST" #form2FA id="form2FA">
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<h2>{{'security.2fa.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
<h2>{{'security.2fa.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
</h2>
|
</h2>
|
||||||
@if (loginInvalid) {
|
@if (loginInvalid) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{'security.2fa.invalid' | i18n}}
|
{{'security.2fa.invalid' | i18n}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
<input id="provider" name="provider" matInput hidden [value]="selectedProvider.id">
|
<input id="provider" name="provider" matInput hidden [value]="selectedProvider.id">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'security.2fa.provider' | i18n}}</mat-label>
|
<mat-label>{{'security.2fa.provider' | i18n}}</mat-label>
|
||||||
<mat-select [(ngModel)]="selectedProvider" [ngModelOptions]="{standalone: true}">
|
<mat-select [(ngModel)]="selectedProvider" [ngModelOptions]="{standalone: true}"
|
||||||
@for (provider of providers; track provider) {
|
(ngModelChange)="changeProvider()">
|
||||||
|
@for (provider of providers; track provider) {
|
||||||
<mat-option [value]="provider">
|
<mat-option [value]="provider">
|
||||||
{{'security.2fa.' + provider.id | i18n}}
|
{{'security.2fa.' + provider.id | i18n}}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@if (selectedProvider && selectedProvider.request) {
|
<mat-form-field [ngClass]="{'hidden' : webAuthn}">
|
||||||
<a mat-raised-button (click)="request()"
|
<mat-label>{{'security.2fa.code' | i18n}}</mat-label>
|
||||||
>{{'security.2fa.' + selectedProvider.id +
|
<input id="code" name="code" #codeInput matInput required matAutofocus>
|
||||||
'.request'
|
<mat-error>
|
||||||
| i18n}}</a>
|
{{'security.2fa.missing' | i18n}}
|
||||||
}
|
</mat-error>
|
||||||
<mat-form-field>
|
</mat-form-field>
|
||||||
<mat-label>{{'security.2fa.code' | i18n}}</mat-label>
|
@if (keep) {
|
||||||
<input id="code" name="code" matInput required matAutofocus>
|
<mat-slide-toggle #toggle [checked]="true" [disabled]="true">
|
||||||
<mat-error>
|
|
||||||
{{'security.2fa.missing' | i18n}}
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
@if (keep) {
|
|
||||||
<mat-slide-toggle #toggle [checked]="true" [disabled]="true">
|
|
||||||
{{'security.2fa.keepSession' | i18n}}
|
{{'security.2fa.keepSession' | i18n}}
|
||||||
</mat-slide-toggle>
|
</mat-slide-toggle>
|
||||||
}
|
}
|
||||||
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="keep">
|
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="keep">
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
<a type="submit" mat-raised-button color="primary" (click)="form2FA.submit()"
|
@if (webAuthn) {
|
||||||
[disabled]="form2FA.invalid">{{'security.2fa.login' | i18n}}<mat-icon style="font-size: 1em;">
|
<a mat-raised-button color="primary" (click)="webAuthnRequest()">{{'security.2fa.' + selectedProvider.id +
|
||||||
open_in_new
|
'.request'
|
||||||
</mat-icon></a>
|
| i18n}}</a>
|
||||||
</mat-card-actions>
|
}
|
||||||
</mat-card>
|
@if (!webAuthn) {
|
||||||
|
<a type="submit" mat-raised-button color="primary" (click)="form2FA.submit()"
|
||||||
|
[disabled]="form2FA.invalid">{{'security.2fa.login' | i18n}}<mat-icon style="font-size: 1em;">
|
||||||
|
open_in_new
|
||||||
|
</mat-icon></a>
|
||||||
|
}
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
mat-form-field {
|
mat-form-field {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input#keep {
|
input#keep {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
import { Auth2FAService } from '../../services/auth.2fa.service';
|
import { Auth2FAService } from '../../services/auth.2fa.service';
|
||||||
|
import { WebAuthnService } from '../../services/webauthn.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: false,
|
standalone: false,
|
||||||
@@ -18,11 +19,13 @@ export class FormLogin2FAComponent implements OnInit {
|
|||||||
apiUrl = environment.apiUrl;
|
apiUrl = environment.apiUrl;
|
||||||
selectedProvider = { id: "", request: false };
|
selectedProvider = { id: "", request: false };
|
||||||
providers = [];
|
providers = [];
|
||||||
|
webAuthn: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private auth2FAService: Auth2FAService) { }
|
private auth2FAService: Auth2FAService,
|
||||||
|
private webAuthnService: WebAuthnService) { }
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.route.queryParams.subscribe({
|
this.route.queryParams.subscribe({
|
||||||
@@ -43,15 +46,42 @@ export class FormLogin2FAComponent implements OnInit {
|
|||||||
this.providers = providers;
|
this.providers = providers;
|
||||||
if (this.providers[0]) {
|
if (this.providers[0]) {
|
||||||
this.selectedProvider = this.providers[0];
|
this.selectedProvider = this.providers[0];
|
||||||
|
this.changeProvider();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeProvider() {
|
||||||
|
this.webAuthn = false;
|
||||||
|
if (!!this.selectedProvider && this.selectedProvider.id.startsWith('webauthn')) {
|
||||||
|
this.webAuthn = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
request() {
|
async webAuthnRequest() {
|
||||||
|
if (!this.selectedProvider || !this.selectedProvider.request || !this.selectedProvider.id.startsWith('webauthn')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.webAuthnService.requestAuthentication(this.selectedProvider.id).subscribe({
|
||||||
|
next: async (response) => {
|
||||||
|
const requestJson = typeof response === 'string' ? JSON.parse(response) : response;
|
||||||
|
const assertionResponse = await this.webAuthnService.authenticateWithOptions(requestJson);
|
||||||
|
const codeInput = document.getElementById('code') as HTMLInputElement;
|
||||||
|
if (codeInput) {
|
||||||
|
codeInput.value = assertionResponse;
|
||||||
|
const form2FA = document.getElementById('form2FA') as HTMLFormElement;
|
||||||
|
if (form2FA) {
|
||||||
|
form2FA.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('WebAuthn authentication failed:', error);
|
||||||
|
console.error('Error details:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,38 +2,47 @@
|
|||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<h2>{{'login.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
<h2>{{'login.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
</h2>
|
</h2>
|
||||||
@if (loginInvalid) {
|
@if (loginInvalid) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{'login.invalid' | i18n}}
|
{{'login.invalid' | i18n}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'username' | i18n}}</mat-label>
|
<mat-label>{{'username' | i18n}}</mat-label>
|
||||||
<input id="username" name="username" matInput required matAutofocus>
|
<input id="username" name="username" matInput required matAutofocus #username>
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{'username.missing' | i18n}}
|
{{'username.missing' | i18n}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{'password' | i18n}}</mat-label>
|
<mat-label>{{'password' | i18n}}</mat-label>
|
||||||
<input id="password" name="password" matInput type="password" required>
|
<input id="password" name="password" matInput type="password" required #password>
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{'password.invalid.hint' | i18n}}
|
{{'password.invalid.hint' | i18n}}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-slide-toggle #toggle>
|
<mat-slide-toggle #toggle>
|
||||||
{{'login.keepSession' | i18n}}
|
{{'login.keepSession' | i18n}}
|
||||||
</mat-slide-toggle>
|
</mat-slide-toggle>
|
||||||
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="toggle.checked">
|
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="toggle.checked">
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
<button type="submit" (click)="loginForm.submit()" mat-raised-button color="primary"
|
<button type="submit" (click)="loginForm.submit()" mat-raised-button color="primary"
|
||||||
[disabled]="loginForm.invalid">{{'login.external' |
|
[disabled]="loginForm.invalid || !username.value || !password.value">{{'login.external' |
|
||||||
i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
||||||
</mat-icon></button>
|
</mat-icon></button>
|
||||||
<a routerLink="/password" mat-raised-button color="warn">{{'password.forgot' | i18n}}</a>
|
<a routerLink="/password" mat-raised-button color="warn">{{'password.forgot' | i18n}}</a>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
<mat-card-actions>
|
||||||
|
<a (click)="webAuthnLogin()" mat-raised-button color="primary">{{'login.webauthn' | i18n}} <mat-icon
|
||||||
|
style="font-size: 1em;">lock</mat-icon></a>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="webAuthnForm" action="{{apiUrl}}/auth/webauthn/login" method="POST">
|
||||||
|
<input id="assertionJson" type="hidden" name="assertionJson" required>
|
||||||
|
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="toggle.checked">
|
||||||
</form>
|
</form>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { WebAuthnService } from 'src/app/services/webauthn.service';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,7 +19,9 @@ export class FormLoginComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private route: ActivatedRoute) { }
|
private route: ActivatedRoute,
|
||||||
|
private webAuthnService: WebAuthnService
|
||||||
|
) { }
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.route.queryParams.subscribe({
|
this.route.queryParams.subscribe({
|
||||||
@@ -42,5 +45,21 @@ export class FormLoginComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webAuthnLogin() {
|
||||||
|
this.webAuthnService.loginStart().subscribe({
|
||||||
|
next: async (result: any) => {
|
||||||
|
const assertionJson = await this.webAuthnService.authenticateWithOptions(result);
|
||||||
|
|
||||||
|
const webAuthnForm = document.getElementById('webAuthnForm') as HTMLFormElement;
|
||||||
|
const assertionJsonInput = document.getElementById('assertionJson') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (webAuthnForm && assertionJsonInput) {
|
||||||
|
assertionJsonInput.value = assertionJson;
|
||||||
|
webAuthnForm.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64URL encoding/decoding helper
|
||||||
|
*/
|
||||||
|
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(base64url: string): ArrayBuffer {
|
||||||
|
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeWebAuthnExtensions<T>(extensions: T | null | undefined): T | undefined {
|
||||||
|
if (extensions === null || extensions === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(extensions)) {
|
||||||
|
const cleaned = extensions
|
||||||
|
.map(sanitizeWebAuthnExtensions)
|
||||||
|
.filter((v) => v !== undefined) as unknown as T;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof extensions === 'object') {
|
||||||
|
const cleaned: any = {};
|
||||||
|
for (const [key, value] of Object.entries(extensions as any)) {
|
||||||
|
const sanitized = sanitizeWebAuthnExtensions(value as any);
|
||||||
|
if (sanitized !== undefined) {
|
||||||
|
cleaned[key] = sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(cleaned).length > 0 ? (cleaned as T) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTransports(transports: any): AuthenticatorTransport[] | undefined {
|
||||||
|
if (!Array.isArray(transports)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only string values; browsers are strict about the sequence type.
|
||||||
|
return transports.filter((t) => typeof t === 'string') as AuthenticatorTransport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class WebAuthnService {
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
|
isSupported(): boolean {
|
||||||
|
return window.PublicKeyCredential !== undefined &&
|
||||||
|
navigator.credentials !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(nickname: string): Observable<any> {
|
||||||
|
return this.http.post<any>(
|
||||||
|
`${environment.apiUrl}/auth/webauthn/register/start`, {}
|
||||||
|
).pipe(
|
||||||
|
switchMap(async options => {
|
||||||
|
const publicKeyOptions = this.convertRegistrationOptions(options);
|
||||||
|
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions });
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Registration failed: No credential received');
|
||||||
|
}
|
||||||
|
const credentialJSON = this.credentialToJSON(credential as PublicKeyCredential);
|
||||||
|
return this.http.post(
|
||||||
|
`${environment.apiUrl}/auth/webauthn/register/finish`,
|
||||||
|
{ credential: JSON.stringify(credentialJSON), nickname: nickname }
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
switchMap(result => result)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCredentials(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`${environment.apiUrl}/auth/webauthn`);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCredential(credentialId: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${environment.apiUrl}/auth/webauthn/${credentialId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNickname(credentialId: number, nickname: string): Observable<any> {
|
||||||
|
return this.http.patch(`${environment.apiUrl}/auth/webauthn/${credentialId}/nickname`, nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUsage(credentialId: number, usage: string): Observable<any> {
|
||||||
|
return this.http.patch(`${environment.apiUrl}/auth/webauthn/${credentialId}/usage`, usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAuthentication(provider: string) {
|
||||||
|
return this.http.post<any>(`${environment.apiUrl}/auth/2fa/${provider}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
loginStart(): Observable<any[]> {
|
||||||
|
return this.http.post<any>(
|
||||||
|
`${environment.apiUrl}/auth/webauthn/login/start`, {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateWithOptions(credentialsGetJson: any): Promise<string> {
|
||||||
|
const publicKeyOptions = this.convertAuthenticationOptions(credentialsGetJson.publicKey);
|
||||||
|
const credential = await navigator.credentials.get({ publicKey: publicKeyOptions });
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Authentication failed: No credential received');
|
||||||
|
}
|
||||||
|
return JSON.stringify(this.assertionToJSON(credential as PublicKeyCredential));
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(options: any): Promise<string> {
|
||||||
|
const publicKeyOptions = this.convertAuthenticationOptions(options);
|
||||||
|
const credential = await navigator.credentials.get({ publicKey: publicKeyOptions });
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Authentication failed: No credential received');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(this.assertionToJSON(credential as PublicKeyCredential));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private convertAuthenticationOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||||
|
if (!options || !options.challenge) {
|
||||||
|
throw new Error('Invalid authentication options: missing challenge');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: base64UrlDecode(options.challenge),
|
||||||
|
timeout: options.timeout,
|
||||||
|
rpId: options.rpId,
|
||||||
|
allowCredentials: (options.allowCredentials || []).map((cred: any) => ({
|
||||||
|
type: cred.type,
|
||||||
|
id: base64UrlDecode(cred.id),
|
||||||
|
...(sanitizeTransports(cred.transports)
|
||||||
|
? { transports: sanitizeTransports(cred.transports) }
|
||||||
|
: {})
|
||||||
|
})),
|
||||||
|
userVerification: options.userVerification,
|
||||||
|
extensions: sanitizeWebAuthnExtensions(options.extensions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertionToJSON(credential: PublicKeyCredential): any {
|
||||||
|
const response = credential.response as AuthenticatorAssertionResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: base64UrlEncode(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
||||||
|
authenticatorData: base64UrlEncode(response.authenticatorData),
|
||||||
|
signature: base64UrlEncode(response.signature),
|
||||||
|
userHandle: response.userHandle ? base64UrlEncode(response.userHandle) : null
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertRegistrationOptions(options: any): PublicKeyCredentialCreationOptions {
|
||||||
|
return {
|
||||||
|
challenge: base64UrlDecode(options.challenge),
|
||||||
|
rp: options.rp,
|
||||||
|
user: {
|
||||||
|
id: base64UrlDecode(options.user.id),
|
||||||
|
name: options.user.name,
|
||||||
|
displayName: options.user.displayName
|
||||||
|
},
|
||||||
|
pubKeyCredParams: options.pubKeyCredParams,
|
||||||
|
timeout: options.timeout,
|
||||||
|
excludeCredentials: (options.excludeCredentials || []).map((cred: any) => ({
|
||||||
|
type: cred.type,
|
||||||
|
id: base64UrlDecode(cred.id),
|
||||||
|
...(sanitizeTransports(cred.transports)
|
||||||
|
? { transports: sanitizeTransports(cred.transports) }
|
||||||
|
: {})
|
||||||
|
})),
|
||||||
|
authenticatorSelection: options.authenticatorSelection,
|
||||||
|
attestation: options.attestation,
|
||||||
|
extensions: sanitizeWebAuthnExtensions(options.extensions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private credentialToJSON(credential: PublicKeyCredential): any {
|
||||||
|
const response = credential.response as AuthenticatorAttestationResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: base64UrlEncode(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
||||||
|
attestationObject: base64UrlEncode(response.attestationObject),
|
||||||
|
transports: (response as any).getTransports ? (response as any).getTransports() : []
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -696,7 +696,8 @@
|
|||||||
".": "Login",
|
".": "Login",
|
||||||
"external": "Login",
|
"external": "Login",
|
||||||
"invalid": "Falscher Username oder Passwort.",
|
"invalid": "Falscher Username oder Passwort.",
|
||||||
"keepSession": "Eingeloggt bleiben"
|
"keepSession": "Eingeloggt bleiben",
|
||||||
|
"webauthn": "Mit Security-Key/Passkey einloggen"
|
||||||
},
|
},
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"minetest": {
|
"minetest": {
|
||||||
@@ -1024,6 +1025,7 @@
|
|||||||
".": "2FA (TOTP)",
|
".": "2FA (TOTP)",
|
||||||
"activate": "Um TOTP als 2FA zu aktivieren, gebe bitte deinen aktuellen Code ein.",
|
"activate": "Um TOTP als 2FA zu aktivieren, gebe bitte deinen aktuellen Code ein.",
|
||||||
"code": "TOTP Code",
|
"code": "TOTP Code",
|
||||||
|
"confirmRemove": "Bist du sicher, dass du den TOTP als 2FA deaktivieren möchtest?",
|
||||||
"create": "2FA (TOTP) einrichten",
|
"create": "2FA (TOTP) einrichten",
|
||||||
"enable": "Aktiviere 2FA (TOTP)",
|
"enable": "Aktiviere 2FA (TOTP)",
|
||||||
"external": "2FA (TOTP)",
|
"external": "2FA (TOTP)",
|
||||||
@@ -1032,7 +1034,8 @@
|
|||||||
"login": "Code verfizieren",
|
"login": "Code verfizieren",
|
||||||
"missing": "Bitte TOTP Code eingeben",
|
"missing": "Bitte TOTP Code eingeben",
|
||||||
"remove": "2FA (TOTP) deaktivieren"
|
"remove": "2FA (TOTP) deaktivieren"
|
||||||
}
|
},
|
||||||
|
"webauthn": "Security-Key/Passkey"
|
||||||
},
|
},
|
||||||
"oidc": {
|
"oidc": {
|
||||||
".": "OpenID Connect Login",
|
".": "OpenID Connect Login",
|
||||||
@@ -1061,6 +1064,24 @@
|
|||||||
"hint": "Dein Account sowie alle gespeicherten Daten werden nicht(!) gelöscht. Du kannst deinen Account also jederzeit wieder reaktivieren."
|
"hint": "Dein Account sowie alle gespeicherten Daten werden nicht(!) gelöscht. Du kannst deinen Account also jederzeit wieder reaktivieren."
|
||||||
},
|
},
|
||||||
"success": "Status erfolgreich geändert"
|
"success": "Status erfolgreich geändert"
|
||||||
|
},
|
||||||
|
"webauthn": {
|
||||||
|
".": "Security Keys/Passkeys",
|
||||||
|
"confirmRemove": "Bist du sicher, dass du den Security-Key/Passkey '{0}' löschen möchtest?",
|
||||||
|
"create": "Security-Key/Passkey hinzufügen",
|
||||||
|
"dialog": {
|
||||||
|
"info": "Lege einen Namen für den Security-Key/Passkey fest."
|
||||||
|
},
|
||||||
|
"info": "Verwalte deine Security-Keys/Passkeys definiere die Verwendung als Login oder 2FA.",
|
||||||
|
"nickname": "Name",
|
||||||
|
"registered": "Deine Security Keys/Passkeys",
|
||||||
|
"remove": "Security Key/Passkey löschen",
|
||||||
|
"usage": {
|
||||||
|
".": "Verwendung",
|
||||||
|
"login": "Login",
|
||||||
|
"none": "Kein",
|
||||||
|
"two-fa": "2FA"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"service": {
|
"service": {
|
||||||
|
|||||||
+24
-3
@@ -696,7 +696,8 @@
|
|||||||
".": "Login",
|
".": "Login",
|
||||||
"external": "Login",
|
"external": "Login",
|
||||||
"invalid": "Wrong username or password.",
|
"invalid": "Wrong username or password.",
|
||||||
"keepSession": "Stay logged in"
|
"keepSession": "Stay logged in",
|
||||||
|
"webauthn": "Login with Security-Key/Passkey"
|
||||||
},
|
},
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"minetest": {
|
"minetest": {
|
||||||
@@ -1008,7 +1009,7 @@
|
|||||||
".": "Two-Factor-Authentication (2FA)",
|
".": "Two-Factor-Authentication (2FA)",
|
||||||
"code": "Code",
|
"code": "Code",
|
||||||
"external": "2FA required",
|
"external": "2FA required",
|
||||||
"info": "You can additionally add a second factor to your password. Please keep in mind, that this only affects your we.bstly-Account and not your email login. Currently only TOTP (also known as Google Authenticator) is supported as 2FA method.",
|
"info": "You can additionally add a second factor to your password. Please keep in mind, that this only affects your we.bstly-Account and not your email login. Currently only TOTP (also known as Google Authenticator) and Security-Keys/Passkeys supported as 2FA method.",
|
||||||
"invalid": "Invalid code",
|
"invalid": "Invalid code",
|
||||||
"keepSession": "Remember 2FA for this device",
|
"keepSession": "Remember 2FA for this device",
|
||||||
"login": "Verify code",
|
"login": "Verify code",
|
||||||
@@ -1018,11 +1019,13 @@
|
|||||||
".": "2FA (TOTP)",
|
".": "2FA (TOTP)",
|
||||||
"activate": "Please enter your current code to enable TOTP as your 2FA.",
|
"activate": "Please enter your current code to enable TOTP as your 2FA.",
|
||||||
"code": "TOTP code",
|
"code": "TOTP code",
|
||||||
|
"confirmRemove": "Are you sure you want to disbale 2FA widh TOTP?",
|
||||||
"create": "2FA (TOTP) create",
|
"create": "2FA (TOTP) create",
|
||||||
"enable": "Enable 2FA (TOTP)",
|
"enable": "Enable 2FA (TOTP)",
|
||||||
"hint": "To use TOP as second factor, please scan the shown QR-Code with your TOTP App.",
|
"hint": "To use TOP as second factor, please scan the shown QR-Code with your TOTP App.",
|
||||||
"remove": "Disable 2FA (TOTP)"
|
"remove": "Disable 2FA (TOTP)"
|
||||||
}
|
},
|
||||||
|
"webauthn": "Security-Key/Passkey"
|
||||||
},
|
},
|
||||||
"oidc": {
|
"oidc": {
|
||||||
".": "OpenID Connect Login",
|
".": "OpenID Connect Login",
|
||||||
@@ -1051,6 +1054,24 @@
|
|||||||
"hint": "Your account and all your data will not(!) be deleted. So you have reactivate your account anytime."
|
"hint": "Your account and all your data will not(!) be deleted. So you have reactivate your account anytime."
|
||||||
},
|
},
|
||||||
"success": "Status successfully changed"
|
"success": "Status successfully changed"
|
||||||
|
},
|
||||||
|
"webauthn": {
|
||||||
|
".": "Security Keys/Passkeys",
|
||||||
|
"confirmRemove": "Are you sure you want to delete your Security-Key/Passkey '{0}'?",
|
||||||
|
"create": "Add Security-Key/Passkey",
|
||||||
|
"dialog": {
|
||||||
|
"info": "Define a name for your Security-Key/Passkey"
|
||||||
|
},
|
||||||
|
"info": "Manage your Security-Keys/Passkeys and define if the devices should be used for Login or 2FA.",
|
||||||
|
"nickname": "Name",
|
||||||
|
"registered": "Your Security Keys/Passkeys",
|
||||||
|
"remove": "Remove Security Key/Passkey",
|
||||||
|
"usage": {
|
||||||
|
".": "Usage",
|
||||||
|
"login": "Login",
|
||||||
|
"none": "None",
|
||||||
|
"two-fa": "2FA"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"service": {
|
"service": {
|
||||||
|
|||||||
Reference in New Issue
Block a user