add webauthn support, update deps

This commit is contained in:
_Bastler
2025-12-18 20:56:18 +01:00
parent 522ea848be
commit 1b7593f008
17 changed files with 1166 additions and 729 deletions
+485 -571
View File
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -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",
+1 -1
View File
@@ -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] },
+2 -1
View File
@@ -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,30 +4,30 @@
<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}}
</mat-error>
<mat-error>
{{error.key}}
</mat-error>
}
@if (success) {
<mat-hint>
{{'password.changed' | i18n}}
</mat-hint>
<mat-hint>
{{'password.changed' | i18n}}
</mat-hint>
}
</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}}
</mat-error>
<mat-error>
{{error.key}}
</mat-error>
}
</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>
@@ -35,12 +35,12 @@
</mat-card-content>
<mat-card-actions>
@if (working) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (!working) {
<button type="submit" mat-raised-button color="primary" [disabled]="passwordForm.invalid">
{{'password.change' | i18n}}
</button>
<button type="submit" mat-raised-button color="primary" [disabled]="passwordForm.invalid">
{{'password.change' | i18n}}
</button>
}
</mat-card-actions>
</mat-card>
@@ -53,34 +53,34 @@
<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}}
</mat-option>
<mat-option [value]="status">
{{'security.status.' + status | i18n}}
</mat-option>
}
</mat-select>
@if (successStatus) {
<mat-hint>
{{'security.status.success' | i18n}}
</mat-hint>
<mat-hint>
{{'security.status.success' | i18n}}
</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) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (!working) {
<button type="submit" mat-raised-button color="primary" [disabled]="statusForm.invalid">
{{'security.status.change' | i18n}}
</button>
<button type="submit" mat-raised-button color="primary" [disabled]="statusForm.invalid">
{{'security.status.change' | i18n}}
</button>
}
</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>
@@ -94,11 +94,11 @@
</mat-card-content>
<mat-card-actions>
@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>
}
@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>
}
</mat-card-actions>
@@ -108,4 +108,53 @@
<mat-icon>contact_support</mat-icon>
</a>
</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 { 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() {
this.auth2FAService.remove('totp').subscribe({
next: (result: any) => {
this.totp = false;
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: () => {
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 {
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.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 => {
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: () => {
}
})
}
+1 -1
View File
@@ -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,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-content>
<h2>{{'security.2fa.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
</mat-icon>
</h2>
@if (loginInvalid) {
</mat-icon>
</h2>
@if (loginInvalid) {
<mat-error>
{{'security.2fa.invalid' | i18n}}
</mat-error>
}
<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}">
@for (provider of providers; track provider) {
}
<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}"
(ngModelChange)="changeProvider()">
@for (provider of providers; track provider) {
<mat-option [value]="provider">
{{'security.2fa.' + provider.id | i18n}}
</mat-option>
}
</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-label>{{'security.2fa.code' | i18n}}</mat-label>
<input id="code" name="code" matInput required matAutofocus>
<mat-error>
{{'security.2fa.missing' | i18n}}
</mat-error>
</mat-form-field>
@if (keep) {
<mat-slide-toggle #toggle [checked]="true" [disabled]="true">
}
</mat-select>
</mat-form-field>
<mat-form-field [ngClass]="{'hidden' : webAuthn}">
<mat-label>{{'security.2fa.code' | i18n}}</mat-label>
<input id="code" name="code" #codeInput matInput required matAutofocus>
<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}}
</mat-slide-toggle>
}
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="keep">
</mat-card-content>
<mat-card-actions>
<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>
}
<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,7 +1,11 @@
mat-form-field {
display: block;
&.hidden {
display: none;
}
}
input#keep {
display: none;
}
}
@@ -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);
}
});
}
@@ -2,38 +2,47 @@
<mat-card>
<mat-card-content>
<h2>{{'login.external' | i18n}}<mat-icon style="font-size: 1em;">open_in_new
</mat-icon>
</h2>
@if (loginInvalid) {
</mat-icon>
</h2>
@if (loginInvalid) {
<mat-error>
{{'login.invalid' | i18n}}
</mat-error>
}
<mat-form-field>
<mat-label>{{'username' | i18n}}</mat-label>
<input id="username" name="username" matInput required matAutofocus>
<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>
<mat-error>
{{'password.invalid.hint' | i18n}}
</mat-error>
</mat-form-field>
<mat-slide-toggle #toggle>
{{'login.keepSession' | i18n}}
</mat-slide-toggle>
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="toggle.checked">
</mat-card-content>
<mat-card-actions>
<button type="submit" (click)="loginForm.submit()" mat-raised-button color="primary"
[disabled]="loginForm.invalid">{{'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>
}
<mat-form-field>
<mat-label>{{'username' | i18n}}</mat-label>
<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 #password>
<mat-error>
{{'password.invalid.hint' | i18n}}
</mat-error>
</mat-form-field>
<mat-slide-toggle #toggle>
{{'login.keepSession' | i18n}}
</mat-slide-toggle>
<input class="hidden" type="checkbox" id="keep" name="keep" [checked]="toggle.checked">
</mat-card-content>
<mat-card-actions>
<button type="submit" (click)="loginForm.submit()" mat-raised-button color="primary"
[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();
}
}
})
}
}
+235
View File
@@ -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()
};
}
}
+23 -2
View File
@@ -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
View File
@@ -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": {