feat(console): u2f (#1080)

* fix user table count

* grpc ge

* move grpc

* u2f

* add u2f funcs

* rm local grpc, u2f dialog

* dialog u2f

* 2fa button

* mfa u2f credentialoptions

* decode base64 to bytearray, id, challenge

* u2f verify

* spinner, remove, attribute col

* delete mfa

* add forcemfa to policy

* add id to remove

* fix: add missing remove u2f in management

* user mgmt u2f delete, login policy

* rm log

* show attr in mgmt user mfa

* add missing id of mfa

* mfa table

* multifaktor for admin, org

* add secondfactor to gen component

* remove circular dependency

* lint

* revert identity prov

* add divider

* login policy lint

* Update console/src/app/modules/policies/login-policy/login-policy.component.html

* Update console/src/app/modules/policies/login-policy/login-policy.component.html

Co-authored-by: Maximilian Peintner <csaq7175@uibk.ac.at>
Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
Max Peintner
2020-12-14 10:04:15 +01:00
committed by GitHub
parent cd44213e99
commit c6fed8ae86
33 changed files with 972 additions and 65 deletions

View File

@@ -1,11 +1,19 @@
<app-card title="{{'USER.MFA.TITLE' | translate}}" description="{{'USER.MFA.DESCRIPTION' | translate}}">
<app-refresh-table [loading]="loading$ | async" (refreshed)="getOTP()" [dataSize]="dataSource?.data?.length">
<app-refresh-table [loading]="loading$ | async" (refreshed)="getMFAs()" [dataSize]="dataSource?.data?.length">
<table class="table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.TABLETYPE' | translate }} </th>
<td mat-cell *matCellDef="let mfa"> {{'USER.MFA.TYPE.'+ mfa.type | translate}} </td>
</ng-container>
<ng-container matColumnDef="attr">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.ATTRIBUTE' | translate }} </th>
<td mat-cell *matCellDef="let mfa"><span *ngIf="mfa?.attr" class="centered">
{{ mfa.attr }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.TABLESTATE' | translate }} </th>
<td mat-cell *matCellDef="let mfa"><span class="centered">
@@ -20,7 +28,7 @@
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.TABLEACTIONS' | translate }} </th>
<td mat-cell *matCellDef="let mfa">
<button matTooltip="{{'ACTIONS.REMOVE' | translate}}" color="warn" mat-icon-button
(click)="deleteMFA(mfa.type)">
(click)="deleteMFA(mfa.type, mfa.id)">
<i class="las la-trash"></i>
</button>
</td>
@@ -35,6 +43,11 @@
matTooltip="{{'ACTIONS.NEW' | translate}}">
<mat-icon class="icon" svgIcon="mdi_radar"></mat-icon>{{'USER.MFA.OTP' | translate}}
</button>
<button class="button" *ngIf="otpAvailable" (click)="addU2F()" mat-stroked-button color="primary"
matTooltip="{{'ACTIONS.NEW' | translate}}">
<i class="las la-fingerprint"></i>
{{'USER.MFA.U2F' | translate}}
</button>
</div>
<div class="table-wrapper">
<div class="spinner-container" *ngIf="loading$ | async">

View File

@@ -2,6 +2,7 @@
.add-row {
display: flex;
margin: -.5rem;
flex-wrap: wrap;
.button {
margin: .5rem;

View File

@@ -4,11 +4,32 @@ import { MatSort } from '@angular/material/sort';
import { MatTable, MatTableDataSource } from '@angular/material/table';
import { BehaviorSubject, Observable } from 'rxjs';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { MfaOtpResponse, MFAState, MfaType, MultiFactor } from 'src/app/proto/generated/auth_pb';
import { MfaOtpResponse, MFAState, MfaType, MultiFactor, WebAuthNResponse } from 'src/app/proto/generated/auth_pb';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ToastService } from 'src/app/services/toast.service';
import { DialogOtpComponent } from '../dialog-otp/dialog-otp.component';
import { DialogU2FComponent } from '../dialog-u2f/dialog-u2f.component';
export function _base64ToArrayBuffer(base64: string): any {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export interface WebAuthNOptions {
challenge: string;
rp: { name: string, id: string; };
user: { name: string, id: string, displayName: string; };
pubKeyCredParams: any;
authenticatorSelection: { userVerification: string; };
timeout: number;
attestation: string;
}
@Component({
selector: 'app-auth-user-mfa',
@@ -16,7 +37,7 @@ import { DialogOtpComponent } from '../dialog-otp/dialog-otp.component';
styleUrls: ['./auth-user-mfa.component.scss'],
})
export class AuthUserMfaComponent implements OnInit, OnDestroy {
public displayedColumns: string[] = ['type', 'state', 'actions'];
public displayedColumns: string[] = ['type', 'attr', 'state', 'actions'];
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@@ -29,10 +50,12 @@ export class AuthUserMfaComponent implements OnInit, OnDestroy {
public error: string = '';
public otpAvailable: boolean = false;
constructor(private service: GrpcAuthService, private toast: ToastService, private dialog: MatDialog) { }
constructor(private service: GrpcAuthService,
private toast: ToastService,
private dialog: MatDialog) { }
public ngOnInit(): void {
this.getOTP();
this.getMFAs();
}
public ngOnDestroy(): void {
@@ -50,7 +73,7 @@ export class AuthUserMfaComponent implements OnInit, OnDestroy {
dialogRef.afterClosed().subscribe((code) => {
if (code) {
this.service.VerifyMfaOTP(code).then(() => {
this.getOTP();
this.getMFAs();
});
}
});
@@ -59,7 +82,40 @@ export class AuthUserMfaComponent implements OnInit, OnDestroy {
});
}
public getOTP(): void {
public verifyU2f(): void {
}
public addU2F(): void {
this.service.AddMyMfaU2F().then((u2fresp) => {
const webauthn: WebAuthNResponse.AsObject = u2fresp.toObject();
const credOptions: CredentialCreationOptions = JSON.parse(atob(webauthn.publicKey as string));
if (credOptions.publicKey?.challenge) {
credOptions.publicKey.challenge = _base64ToArrayBuffer(credOptions.publicKey.challenge as any);
credOptions.publicKey.user.id = _base64ToArrayBuffer(credOptions.publicKey.user.id as any);
const dialogRef = this.dialog.open(DialogU2FComponent, {
width: '400px',
data: {
credOptions,
},
});
dialogRef.afterClosed().subscribe(done => {
if (done) {
this.getMFAs();
} else {
this.getMFAs();
}
});
}
}, error => {
this.toast.showError(error);
});
}
public getMFAs(): void {
this.service.GetMyMfas().then(mfas => {
this.dataSource = new MatTableDataSource(mfas.toObject().mfasList);
this.dataSource.sort = this.sort;
@@ -73,13 +129,13 @@ export class AuthUserMfaComponent implements OnInit, OnDestroy {
});
}
public deleteMFA(type: MfaType): void {
public deleteMFA(type: MfaType, id?: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'USER.MFA.DIALOG.OTP_DELETE_TITLE',
descriptionKey: 'USER.MFA.DIALOG.OTP_DELETE_DESCRIPTION',
titleKey: 'USER.MFA.DIALOG.MFA_DELETE_TITLE',
descriptionKey: 'USER.MFA.DIALOG.MFA_DELETE_DESCRIPTION',
},
width: '400px',
});
@@ -94,7 +150,19 @@ export class AuthUserMfaComponent implements OnInit, OnDestroy {
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getOTP();
this.getMFAs();
}).catch(error => {
this.toast.showError(error);
});
} else if (type === MfaType.MFATYPE_U2F && id) {
this.service.RemoveMyMfaU2F(id).then(() => {
this.toast.showInfo('USER.TOAST.U2FREMOVED', true);
const index = this.dataSource.data.findIndex(mfa => mfa.type === type);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getMFAs();
}).catch(error => {
this.toast.showError(error);
});
@@ -102,4 +170,6 @@ export class AuthUserMfaComponent implements OnInit, OnDestroy {
}
});
}
}

View File

@@ -1,6 +1,6 @@
<h1 mat-dialog-title>{{'USER.MFA.OTP_DIALOG_TITLE' | translate}}</h1>
<div mat-dialog-content>
<p translate>{{'USER.MFA.OTP_DIALOG_DESCRIPTION' | translate}}</p>
<p>{{'USER.MFA.OTP_DIALOG_DESCRIPTION' | translate}}</p>
<div class="qrcode-wrapper">
<qrcode *ngIf="data" class="qrcode" [qrdata]="data" [width]="150" [errorCorrectionLevel]="'M'"></qrcode>
</div>
@@ -15,4 +15,4 @@
<button mat-raised-button class="ok-button" color="primary" (click)="closeDialogWithCode()"><span
translate>ACTIONS.OK</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,18 @@
<h1 mat-dialog-title>{{'USER.MFA.U2F_DIALOG_TITLE' | translate}}</h1>
<div mat-dialog-content>
<p>{{'USER.MFA.U2F_DIALOG_DESCRIPTION' | translate}}</p>
<cnsl-form-field class="form-field" label="Name" required="true">
<cnsl-label>{{'USER.MFA.U2F_NAME' | translate}}</cnsl-label>
<input cnslInput [(ngModel)]="name" required/>
</cnsl-form-field>
<mat-spinner diameter="30" *ngIf="loading"></mat-spinner>
<p class="error">{{error}}</p>
</div>
<div mat-dialog-actions class="action">
<button mat-button (click)="closeDialog()">{{'ACTIONS.CLOSE' | translate}}</button>
<button [disabled]="!name" mat-raised-button class="ok-button" color="primary" (click)="closeDialogWithCode()">{{'ACTIONS.VERIFY' | translate}}
</button>
</div>

View File

@@ -0,0 +1,27 @@
.qrcode-wrapper {
display: flex;
align-content: center;
.qrcode {
margin: 1rem auto;
}
}
.form-field {
width: 100%;
}
.error {
color: #f44336;
font-size: 14px;
}
.action {
display: flex;
justify-content: flex-end;
button {
margin-left: .5rem;
border-radius: .5rem;
}
}

View File

@@ -0,0 +1,89 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs/operators';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ToastService } from 'src/app/services/toast.service';
export function _arrayBufferToBase64(buffer: any): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
@Component({
selector: 'app-dialog-u2f',
templateUrl: './dialog-u2f.component.html',
styleUrls: ['./dialog-u2f.component.scss'],
})
export class DialogU2FComponent {
public name: string = '';
public error: string = '';
public loading: boolean = false;
constructor(public dialogRef: MatDialogRef<DialogU2FComponent>,
@Inject(MAT_DIALOG_DATA) public data: { credOptions: any; },
private service: GrpcAuthService, private translate: TranslateService, private toast: ToastService) { }
public closeDialog(): void {
this.dialogRef.close();
}
public closeDialogWithCode(): void {
this.error = '';
this.loading = true;
if (this.name && this.data.credOptions.publicKey) {
// this.data.credOptions.publicKey.rp.id = 'localhost';
navigator.credentials.create(this.data.credOptions).then((resp) => {
if (resp &&
(resp as any).response.attestationObject &&
(resp as any).response.clientDataJSON &&
(resp as any).rawId) {
const attestationObject = (resp as any).response.attestationObject;
const clientDataJSON = (resp as any).response.clientDataJSON;
const rawId = (resp as any).rawId;
const data = JSON.stringify({
id: resp.id,
rawId: _arrayBufferToBase64(rawId),
type: resp.type,
response: {
attestationObject: _arrayBufferToBase64(attestationObject),
clientDataJSON: _arrayBufferToBase64(clientDataJSON),
},
});
const base64 = btoa(data);
this.service.VerifyMyMfaU2F(base64, this.name).then(() => {
this.translate.get('USER.MFA.U2F_SUCCESS').pipe(take(1)).subscribe(msg => {
this.toast.showInfo(msg);
});
this.dialogRef.close(true);
this.loading = false;
}).catch(error => {
this.loading = false;
this.toast.showError(error);
});
} else {
this.loading = false;
this.translate.get('USER.MFA.U2F_ERROR').pipe(take(1)).subscribe(msg => {
this.toast.showInfo(msg);
});
this.dialogRef.close(true);
}
}).catch(error => {
this.loading = false;
this.error = error;
this.toast.showInfo(error.message);
});
}
}
}

View File

@@ -33,6 +33,7 @@ import { AuthUserDetailComponent } from './auth-user-detail/auth-user-detail.com
import { AuthUserMfaComponent } from './auth-user-detail/auth-user-mfa/auth-user-mfa.component';
import { CodeDialogComponent } from './auth-user-detail/code-dialog/code-dialog.component';
import { DialogOtpComponent } from './auth-user-detail/dialog-otp/dialog-otp.component';
import { DialogU2FComponent } from './auth-user-detail/dialog-u2f/dialog-u2f.component';
import { EditDialogComponent } from './auth-user-detail/edit-dialog/edit-dialog.component';
import { ResendEmailDialogComponent } from './auth-user-detail/resend-email-dialog/resend-email-dialog.component';
import { ThemeSettingComponent } from './auth-user-detail/theme-setting/theme-setting.component';
@@ -65,6 +66,7 @@ import { UserMfaComponent } from './user-detail/user-mfa/user-mfa.component';
ExternalIdpsComponent,
ContactComponent,
ResendEmailDialogComponent,
DialogU2FComponent,
],
imports: [
UserDetailRoutingModule,

View File

@@ -1,11 +1,19 @@
<app-card title="{{'USER.MFA.TITLE' | translate}}" description="{{'USER.MFA.DESCRIPTION' | translate}}">
<app-refresh-table [loading]="loading$ | async" (refreshed)="getOTP()" [dataSize]="dataSource?.data?.length">
<app-refresh-table [loading]="loading$ | async" (refreshed)="getMFAs()" [dataSize]="dataSource?.data?.length">
<table class="table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.TABLETYPE' | translate }} </th>
<td mat-cell *matCellDef="let mfa"> {{'USER.MFA.TYPE.'+ mfa.type | translate}} </td>
</ng-container>
<ng-container matColumnDef="attr">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.ATTRIBUTE' | translate }} </th>
<td mat-cell *matCellDef="let mfa"><span *ngIf="mfa?.attr" class="centered">
{{ mfa.attr }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.TABLESTATE' | translate }} </th>
<td mat-cell *matCellDef="let mfa">
@@ -21,7 +29,7 @@
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MFA.TABLEACTIONS' | translate }} </th>
<td mat-cell *matCellDef="let mfa">
<button matTooltip="{{'ACTIONS.REMOVE' | translate}}" color="warn" mat-icon-button
(click)="deleteMFA(mfa.type)">
(click)="deleteMFA(mfa.type, mfa.id)">
<i class="las la-trash"></i>
</button>
</td>

View File

@@ -20,7 +20,7 @@ export interface MFAItem {
styleUrls: ['./user-mfa.component.scss'],
})
export class UserMfaComponent implements OnInit, OnDestroy {
public displayedColumns: string[] = ['type', 'state', 'actions'];
public displayedColumns: string[] = ['type', 'attr', 'state', 'actions'];
@Input() private user!: UserView.AsObject;
public mfaSubject: BehaviorSubject<UserMultiFactor.AsObject[]> = new BehaviorSubject<UserMultiFactor.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
@@ -37,7 +37,7 @@ export class UserMfaComponent implements OnInit, OnDestroy {
constructor(private mgmtUserService: ManagementService, private dialog: MatDialog, private toast: ToastService) { }
public ngOnInit(): void {
this.getOTP();
this.getMFAs();
}
public ngOnDestroy(): void {
@@ -45,7 +45,7 @@ export class UserMfaComponent implements OnInit, OnDestroy {
this.loadingSubject.complete();
}
public getOTP(): void {
public getMFAs(): void {
this.mgmtUserService.getUserMfas(this.user.id).then(mfas => {
this.dataSource = new MatTableDataSource(mfas.toObject().mfasList);
this.dataSource.sort = this.sort;
@@ -54,13 +54,14 @@ export class UserMfaComponent implements OnInit, OnDestroy {
});
}
public deleteMFA(type: MfaType): void {
public deleteMFA(type: MfaType, id?: string): void {
console.log(type, id);
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'USER.MFA.DIALOG.OTP_DELETE_TITLE',
descriptionKey: 'USER.MFA.DIALOG.OTP_DELETE_DESCRIPTION',
titleKey: 'USER.MFA.DIALOG.MFA_DELETE_TITLE',
descriptionKey: 'USER.MFA.DIALOG.MFA_DELETE_DESCRIPTION',
},
width: '400px',
});
@@ -75,7 +76,19 @@ export class UserMfaComponent implements OnInit, OnDestroy {
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getOTP();
this.getMFAs();
}).catch(error => {
this.toast.showError(error);
});
} else if (type === MfaType.MFATYPE_U2F && id) {
this.mgmtUserService.RemoveMfaU2F(id).then(() => {
this.toast.showInfo('USER.TOAST.U2FREMOVED', true);
const index = this.dataSource.data.findIndex(mfa => mfa.type === type);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getMFAs();
}).catch(error => {
this.toast.showError(error);
});

View File

@@ -1,4 +1,4 @@
<app-refresh-table [loading]="loading$ | async" (refreshed)="refreshPage()" [dataSize]="dataSource.data.length"
<app-refresh-table [loading]="loading$ | async" (refreshed)="refreshPage()" [dataSize]="userResult?.totalResult"
[timestamp]="userResult?.viewTimestamp" [selection]="selection"
[emitRefreshOnPreviousRoutes]="refreshOnPreviousRoutes">
<cnsl-form-field @appearfade *ngIf="userSearchKey != undefined" actions class="filtername">