add webauthn support, update deps
This commit is contained in:
Generated
+484
-570
File diff suppressed because it is too large
Load Diff
+20
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "we-bstly-angular",
|
||||
"version": "3.5.1",
|
||||
"version": "4.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
@@ -11,20 +11,20 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.3.10",
|
||||
"@angular/cdk": "^20.2.12",
|
||||
"@angular/common": "^20.3.10",
|
||||
"@angular/compiler": "^20.3.10",
|
||||
"@angular/core": "^20.3.10",
|
||||
"@angular/forms": "^20.3.10",
|
||||
"@angular/material": "^20.2.12",
|
||||
"@angular/material-moment-adapter": "^20.2.12",
|
||||
"@angular/platform-browser": "^20.3.10",
|
||||
"@angular/platform-browser-dynamic": "^20.3.10",
|
||||
"@angular/router": "^20.3.10",
|
||||
"@angular/animations": "^20.3.15",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@angular/common": "^20.3.15",
|
||||
"@angular/compiler": "^20.3.15",
|
||||
"@angular/core": "^20.3.15",
|
||||
"@angular/forms": "^20.3.15",
|
||||
"@angular/material": "^20.2.14",
|
||||
"@angular/material-moment-adapter": "^20.2.14",
|
||||
"@angular/platform-browser": "^20.3.15",
|
||||
"@angular/platform-browser-dynamic": "^20.3.15",
|
||||
"@angular/router": "^20.3.15",
|
||||
"moment": "^2.30.1",
|
||||
"ng-qrcode": "^20.0.1",
|
||||
"openpgp": "^6.2.2",
|
||||
"openpgp": "^6.3.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
@@ -32,15 +32,15 @@
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^20.3.9",
|
||||
"@angular/cli": "^20.3.9",
|
||||
"@angular/compiler-cli": "^20.3.10",
|
||||
"@angular/localize": "^20.3.10",
|
||||
"@types/jasmine": "^5.1.12",
|
||||
"@angular-devkit/build-angular": "^20.3.13",
|
||||
"@angular/cli": "^20.3.13",
|
||||
"@angular/compiler-cli": "^20.3.15",
|
||||
"@angular/localize": "^20.3.15",
|
||||
"@types/jasmine": "^5.1.13",
|
||||
"@types/jasminewd2": "^2.0.13",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.10.4",
|
||||
"@types/openpgp": "^5.0.0",
|
||||
"jasmine-core": "~5.12.1",
|
||||
"jasmine-core": "~5.13.0",
|
||||
"jasmine-spec-reporter": "~7.0.0",
|
||||
"karma": "^6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
|
||||
@@ -64,7 +64,7 @@ const routes: Routes = [
|
||||
{ path: 'partey/manage', component: ParteyComponent, canActivate: [AuthenticatedGuard] },
|
||||
{
|
||||
path: '', component: MainComponent, children: [
|
||||
{ path: '', redirectTo: "/services", pathMatch: 'full' },
|
||||
{ path: '', redirectTo: "/account/info", pathMatch: 'full' },
|
||||
{ path: 'login', component: FormLoginComponent, canActivate: [AnonymousGuard] },
|
||||
{ path: 'login/2fa', component: FormLogin2FAComponent, canActivate: [AnonymousGuard] },
|
||||
{ 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 { ProfileFieldPgpBlob } from './ui/profilefields/binary/pgp/profilefield.pgp-blob';
|
||||
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 { VoucherDialog } from './pages/account/voucher/voucher.component';
|
||||
import { InfoComponent } from './pages/account/info/info.component';
|
||||
@@ -140,6 +140,7 @@ export class XhrInterceptor implements HttpInterceptor {
|
||||
QuotasComponent,
|
||||
SecurityComponent,
|
||||
SecurityTotpDialog,
|
||||
SecurityKeyDialog,
|
||||
VoucherComponent,
|
||||
VoucherDialog,
|
||||
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,7 +4,7 @@
|
||||
<h2>{{'password.change' | i18n}}</h2>
|
||||
<mat-form-field>
|
||||
<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) {
|
||||
<mat-error>
|
||||
{{error.key}}
|
||||
@@ -18,7 +18,7 @@
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<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) {
|
||||
<mat-error>
|
||||
{{error.key}}
|
||||
@@ -27,7 +27,7 @@
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>{{'password.confirm' | i18n}}</mat-label>
|
||||
<input matInput type="password" formControlName="password2" [(ngModel)]="model.password2">
|
||||
<input matInput type="password" formControlName="password2">
|
||||
<mat-error>
|
||||
{{'password.not-match' | i18n}}
|
||||
</mat-error>
|
||||
@@ -53,7 +53,7 @@
|
||||
<p> {{'security.status.hint' | i18n}}</p>
|
||||
<mat-form-field>
|
||||
<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) {
|
||||
<mat-option [value]="status">
|
||||
{{'security.status.' + status | i18n}}
|
||||
@@ -66,7 +66,7 @@
|
||||
</mat-hint>
|
||||
}
|
||||
</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-actions>
|
||||
@if (working) {
|
||||
@@ -79,8 +79,8 @@
|
||||
}
|
||||
</mat-card-actions>
|
||||
<mat-card-footer>
|
||||
<a href="https://wiki.bstly.de/services/webstly#status" class="help-button"
|
||||
matTooltip="{{'help-button' | i18n}}" matTooltipPosition="above" target="_blank" mat-fab color="accent">
|
||||
<a href="https://wiki.bstly.de/services/webstly#status" 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>
|
||||
@@ -109,3 +109,52 @@
|
||||
</a>
|
||||
</mat-card-footer>
|
||||
</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 { 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 { UserService } from './../../../services/user.service';
|
||||
import { WebAuthnService } from './../../../services/webauthn.service';
|
||||
import { MatchingValidator } from './../../../utils/matching.validator';
|
||||
|
||||
@Component({
|
||||
@@ -14,12 +16,13 @@ import { MatchingValidator } from './../../../utils/matching.validator';
|
||||
})
|
||||
export class SecurityComponent implements OnInit {
|
||||
|
||||
|
||||
model: any = {};
|
||||
public working: boolean;
|
||||
public success: boolean;
|
||||
public successStatus: boolean;
|
||||
public totp: boolean = false;
|
||||
public webauthn: boolean = false;
|
||||
public webAuthnSupported: boolean = false;
|
||||
public webAuthnCredentials: any[] = [];
|
||||
|
||||
statuses = ["NORMAL", "SLEEP", "PURGE"];
|
||||
|
||||
@@ -31,6 +34,7 @@ export class SecurityComponent implements OnInit {
|
||||
private formBuilder: FormBuilder,
|
||||
private userService: UserService,
|
||||
private auth2FAService: Auth2FAService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
public dialog: MatDialog) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -49,7 +53,7 @@ export class SecurityComponent implements OnInit {
|
||||
|
||||
this.userService.get().subscribe({
|
||||
next: (response: any) => {
|
||||
this.model.status = response.status;
|
||||
this.statusForm.get('status').setValue(response.status);
|
||||
}, error: (error) => { }
|
||||
})
|
||||
|
||||
@@ -61,13 +65,38 @@ export class SecurityComponent implements OnInit {
|
||||
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() {
|
||||
if (this.passwordForm.valid && !this.working) {
|
||||
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) => {
|
||||
this.passwordFormDirective.resetForm();
|
||||
this.success = true;
|
||||
@@ -96,7 +125,7 @@ export class SecurityComponent implements OnInit {
|
||||
if (this.statusForm.valid && !this.working) {
|
||||
this.working = true;
|
||||
|
||||
this.userService.update({ status: this.model.status }).subscribe({
|
||||
this.userService.update({ status: this.statusForm.get('status').value }).subscribe({
|
||||
next: (result: any) => {
|
||||
this.successStatus = true;
|
||||
this.working = false;
|
||||
@@ -140,21 +169,87 @@ export class SecurityComponent implements OnInit {
|
||||
|
||||
|
||||
enableTotp() {
|
||||
const dialogRef = this.dialog.open(SecurityTotpDialog, {
|
||||
this.dialog.open(SecurityTotpDialog, {
|
||||
closeOnNavigation: false,
|
||||
disableClose: true,
|
||||
data: {}
|
||||
disableClose: true
|
||||
});
|
||||
}
|
||||
|
||||
removeTotp() {
|
||||
const dialogRef = this.dialog.open(ConfirmDialog, {
|
||||
data: {
|
||||
'label': 'security.2fa.totp.confirmRemove'
|
||||
}
|
||||
})
|
||||
|
||||
dialogRef.afterClosed().subscribe({
|
||||
next: (result) => {
|
||||
if (result) {
|
||||
this.auth2FAService.remove('totp').subscribe({
|
||||
next: (result: any) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,3 +272,21 @@ export class SecurityTotpDialog {
|
||||
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) {
|
||||
this.voucherService.create(name).toPromise().then(data => {
|
||||
this.voucherService.create(name).subscribe({
|
||||
next: (data) => {
|
||||
this.model.type = name;
|
||||
this.model.code = data;
|
||||
this.vouchers.push(this.model);
|
||||
this.voucherSource.data = this.vouchers;
|
||||
|
||||
const dialogRef = this.dialog.open(VoucherDialog, {
|
||||
closeOnNavigation: false,
|
||||
disableClose: true,
|
||||
data: this.model
|
||||
});
|
||||
}, error => {
|
||||
})
|
||||
}, error: () => {
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export class AdminUserEditDialog implements OnInit {
|
||||
|
||||
const userData = {
|
||||
...this.user,
|
||||
username: this.form.value.username,
|
||||
username: this.form.value.username || this.user.username,
|
||||
disabled: this.form.value.disabled,
|
||||
locked: this.form.value.locked,
|
||||
status: this.form.value.status
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form action="{{apiUrl}}/auth/login/2fa" method="POST" #form2FA>
|
||||
<form action="{{apiUrl}}/auth/login/2fa" method="POST" #form2FA id="form2FA">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<h2>{{'security.2fa.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
|
||||
@@ -12,7 +12,8 @@
|
||||
<input id="provider" name="provider" matInput hidden [value]="selectedProvider.id">
|
||||
<mat-form-field>
|
||||
<mat-label>{{'security.2fa.provider' | i18n}}</mat-label>
|
||||
<mat-select [(ngModel)]="selectedProvider" [ngModelOptions]="{standalone: true}">
|
||||
<mat-select [(ngModel)]="selectedProvider" [ngModelOptions]="{standalone: true}"
|
||||
(ngModelChange)="changeProvider()">
|
||||
@for (provider of providers; track provider) {
|
||||
<mat-option [value]="provider">
|
||||
{{'security.2fa.' + provider.id | i18n}}
|
||||
@@ -20,15 +21,9 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (selectedProvider && selectedProvider.request) {
|
||||
<a mat-raised-button (click)="request()"
|
||||
>{{'security.2fa.' + selectedProvider.id +
|
||||
'.request'
|
||||
| i18n}}</a>
|
||||
}
|
||||
<mat-form-field>
|
||||
<mat-form-field [ngClass]="{'hidden' : webAuthn}">
|
||||
<mat-label>{{'security.2fa.code' | i18n}}</mat-label>
|
||||
<input id="code" name="code" matInput required matAutofocus>
|
||||
<input id="code" name="code" #codeInput matInput required matAutofocus>
|
||||
<mat-error>
|
||||
{{'security.2fa.missing' | i18n}}
|
||||
</mat-error>
|
||||
@@ -41,10 +36,17 @@
|
||||
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="keep">
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
@if (webAuthn) {
|
||||
<a mat-raised-button color="primary" (click)="webAuthnRequest()">{{'security.2fa.' + selectedProvider.id +
|
||||
'.request'
|
||||
| i18n}}</a>
|
||||
}
|
||||
@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>
|
||||
@@ -1,5 +1,9 @@
|
||||
mat-form-field {
|
||||
display: block;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
input#keep {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
import { Auth2FAService } from '../../services/auth.2fa.service';
|
||||
import { WebAuthnService } from '../../services/webauthn.service';
|
||||
|
||||
@Component({
|
||||
standalone: false,
|
||||
@@ -18,11 +19,13 @@ export class FormLogin2FAComponent implements OnInit {
|
||||
apiUrl = environment.apiUrl;
|
||||
selectedProvider = { id: "", request: false };
|
||||
providers = [];
|
||||
webAuthn: boolean = false;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private auth2FAService: Auth2FAService) { }
|
||||
private auth2FAService: Auth2FAService,
|
||||
private webAuthnService: WebAuthnService) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.route.queryParams.subscribe({
|
||||
@@ -43,15 +46,42 @@ export class FormLogin2FAComponent implements OnInit {
|
||||
this.providers = providers;
|
||||
if (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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
}
|
||||
<mat-form-field>
|
||||
<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>
|
||||
{{'username.missing' | i18n}}
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<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>
|
||||
{{'password.invalid.hint' | i18n}}
|
||||
</mat-error>
|
||||
@@ -30,10 +30,19 @@
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<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
|
||||
</mat-icon></button>
|
||||
<a routerLink="/password" mat-raised-button color="warn">{{'password.forgot' | i18n}}</a>
|
||||
</mat-card-actions>
|
||||
<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>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { WebAuthnService } from 'src/app/services/webauthn.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
@@ -18,7 +19,9 @@ export class FormLoginComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute) { }
|
||||
private route: ActivatedRoute,
|
||||
private webAuthnService: WebAuthnService
|
||||
) { }
|
||||
|
||||
async ngOnInit() {
|
||||
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",
|
||||
"external": "Login",
|
||||
"invalid": "Falscher Username oder Passwort.",
|
||||
"keepSession": "Eingeloggt bleiben"
|
||||
"keepSession": "Eingeloggt bleiben",
|
||||
"webauthn": "Mit Security-Key/Passkey einloggen"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"minetest": {
|
||||
@@ -1024,6 +1025,7 @@
|
||||
".": "2FA (TOTP)",
|
||||
"activate": "Um TOTP als 2FA zu aktivieren, gebe bitte deinen aktuellen Code ein.",
|
||||
"code": "TOTP Code",
|
||||
"confirmRemove": "Bist du sicher, dass du den TOTP als 2FA deaktivieren möchtest?",
|
||||
"create": "2FA (TOTP) einrichten",
|
||||
"enable": "Aktiviere 2FA (TOTP)",
|
||||
"external": "2FA (TOTP)",
|
||||
@@ -1032,7 +1034,8 @@
|
||||
"login": "Code verfizieren",
|
||||
"missing": "Bitte TOTP Code eingeben",
|
||||
"remove": "2FA (TOTP) deaktivieren"
|
||||
}
|
||||
},
|
||||
"webauthn": "Security-Key/Passkey"
|
||||
},
|
||||
"oidc": {
|
||||
".": "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."
|
||||
},
|
||||
"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": {
|
||||
|
||||
+24
-3
@@ -696,7 +696,8 @@
|
||||
".": "Login",
|
||||
"external": "Login",
|
||||
"invalid": "Wrong username or password.",
|
||||
"keepSession": "Stay logged in"
|
||||
"keepSession": "Stay logged in",
|
||||
"webauthn": "Login with Security-Key/Passkey"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"minetest": {
|
||||
@@ -1008,7 +1009,7 @@
|
||||
".": "Two-Factor-Authentication (2FA)",
|
||||
"code": "Code",
|
||||
"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",
|
||||
"keepSession": "Remember 2FA for this device",
|
||||
"login": "Verify code",
|
||||
@@ -1018,11 +1019,13 @@
|
||||
".": "2FA (TOTP)",
|
||||
"activate": "Please enter your current code to enable TOTP as your 2FA.",
|
||||
"code": "TOTP code",
|
||||
"confirmRemove": "Are you sure you want to disbale 2FA widh TOTP?",
|
||||
"create": "2FA (TOTP) create",
|
||||
"enable": "Enable 2FA (TOTP)",
|
||||
"hint": "To use TOP as second factor, please scan the shown QR-Code with your TOTP App.",
|
||||
"remove": "Disable 2FA (TOTP)"
|
||||
}
|
||||
},
|
||||
"webauthn": "Security-Key/Passkey"
|
||||
},
|
||||
"oidc": {
|
||||
".": "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."
|
||||
},
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user