feat: DSC enrollment + MFA controls, user CRUD UX, auth error hardening
- DSC module: list/enroll/revoke dialogs and dsc.service (m_bank_user_dsc API) - Access control: DSC + MFA toggles via disable-confirm-dialog - Two-factor auth: expanded UX (DSC + OTP flows) - Users: create/edit form polish, success snackbar on edit, DSC widget retained only inside dsc/ (removed from view-user) - Auth: X-Requested-With added globally; error handler null-safe on empty 500 bodies Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3b752dc6ae
commit
09fab88451
@@ -13,16 +13,22 @@
|
||||
|
||||
<h2 class="tfa-title">{{ 'labels.text.Two Factor Authentication' | translate }}</h2>
|
||||
<p class="tfa-sub">
|
||||
@if (!otpRequested) {
|
||||
Choose how you want to receive your one-time passcode.
|
||||
@if (dscChallengeActive) {
|
||||
Plug in your DSC token and follow the prompts in the helper.
|
||||
} @else if (!otpRequested) {
|
||||
Choose how you want to verify your identity.
|
||||
} @else {
|
||||
Enter the 6-digit code we sent you to continue.
|
||||
}
|
||||
</p>
|
||||
|
||||
<!-- Step 1 — Pick a delivery method -->
|
||||
@if (!otpRequested) {
|
||||
<form class="tfa-form" [formGroup]="twoFactorAuthenticationDeliveryMethodForm" (ngSubmit)="requestOTP()">
|
||||
@if (!otpRequested && !dscChallengeActive) {
|
||||
<form
|
||||
class="tfa-form"
|
||||
[formGroup]="twoFactorAuthenticationDeliveryMethodForm"
|
||||
(ngSubmit)="continueWithSelectedFactor()"
|
||||
>
|
||||
<div class="tfa-methods" formGroupName="">
|
||||
<mat-radio-group class="tfa-methods__group" formControlName="twoFactorAuthenticationDeliveryMethod">
|
||||
@for (method of twoFactorAuthenticationDeliveryMethods; track method.name) {
|
||||
@@ -46,17 +52,17 @@
|
||||
(click)="$event.stopPropagation()"
|
||||
></mat-radio-button>
|
||||
<div class="tfa-method__icon">
|
||||
<fa-icon
|
||||
[icon]="
|
||||
(method.name | lowercase) === 'sms' || (method.name | lowercase) === 'mobile'
|
||||
? 'mobile-alt'
|
||||
: 'envelope'
|
||||
"
|
||||
></fa-icon>
|
||||
<fa-icon [icon]="iconFor(method)"></fa-icon>
|
||||
</div>
|
||||
<div class="tfa-method__body">
|
||||
<span class="tfa-method__name">{{ method.name | titlecase }}</span>
|
||||
<span class="tfa-method__target">{{ maskTarget(method) }}</span>
|
||||
<span class="tfa-method__target">
|
||||
@if (method.kind === 'DSC') {
|
||||
{{ method.target }}
|
||||
} @else {
|
||||
{{ maskTarget(method) }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -73,23 +79,66 @@
|
||||
<span>Sending…</span>
|
||||
} @else {
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<span>{{ 'labels.buttons.Request OTP' | translate }}</span>
|
||||
<span>Continue</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- DSC challenge sub-screen — replaces the OTP input when DSC was picked. -->
|
||||
@if (dscChallengeActive) {
|
||||
<div class="tfa-context tfa-context--dsc">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
<div>
|
||||
<span class="tfa-context__label">Signing as</span>
|
||||
<strong class="tfa-context__target">{{ selectedTwoFactorAuthenticationDeliveryMethod?.target }}</strong>
|
||||
</div>
|
||||
@if (dscCountdownLabel) {
|
||||
<span class="tfa-context__validity">
|
||||
<fa-icon icon="clock"></fa-icon>
|
||||
{{ dscCountdownLabel }} remaining
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tfa-dsc-hint">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
A verification helper has been downloaded. If your browser didn't open it, click
|
||||
<strong>Re-download helper</strong>. Need OpenWebStart?
|
||||
<a href="https://openwebstart.com/" target="_blank" rel="noopener">Install it</a>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (dscDetecting) {
|
||||
<div class="tfa-dsc-spinner">
|
||||
<mat-spinner diameter="36"></mat-spinner>
|
||||
<span>Waiting for DSC verification…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (dscFailed) {
|
||||
<div class="tfa-error">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
{{ dscErrorMessage }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="tfa-btn tfa-btn--ghost" (click)="reDownloadDscHelper()" [disabled]="loading">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
<span>Re-download helper</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="tfa-btn tfa-btn--ghost" (click)="useAnotherMethod()" [disabled]="loading">
|
||||
<fa-icon icon="arrow-left"></fa-icon>
|
||||
<span>Use another method</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Step 2 — Enter the code -->
|
||||
@if (otpRequested) {
|
||||
@if (otpRequested && !dscChallengeActive) {
|
||||
<div class="tfa-context">
|
||||
<fa-icon
|
||||
[icon]="
|
||||
(selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'sms' ||
|
||||
(selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'mobile'
|
||||
? 'mobile-alt'
|
||||
: 'envelope'
|
||||
"
|
||||
></fa-icon>
|
||||
<fa-icon [icon]="iconFor(selectedTwoFactorAuthenticationDeliveryMethod)"></fa-icon>
|
||||
<div>
|
||||
<span class="tfa-context__label">Code sent to</span>
|
||||
<strong class="tfa-context__target">{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}</strong>
|
||||
|
||||
@@ -154,6 +154,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────── DSC challenge sub-screen ──────── */
|
||||
.tfa-context--dsc {
|
||||
> fa-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-dsc-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 10px;
|
||||
background: rgb(59 130 246 / 12%);
|
||||
border: 1px solid rgb(59 130 246 / 25%);
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
|
||||
> fa-icon {
|
||||
color: #93c5fd;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #93c5fd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-dsc-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 0 8px;
|
||||
color: rgb(255 255 255 / 78%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ──────── Step 2 — context strip ──────── */
|
||||
.tfa-context {
|
||||
display: flex;
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { Subject, Subscription, interval, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { DscService } from '../../users/dsc/dsc.service';
|
||||
import { BankUserDsc, DscLoginChallenge } from '../../users/dsc/dsc.model';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
@@ -35,9 +38,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private dscService = inject(DscService);
|
||||
|
||||
/** Available delivery methods to receive OTP. */
|
||||
twoFactorAuthenticationDeliveryMethods: any;
|
||||
@@ -56,6 +60,21 @@ export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
/** True if loading. */
|
||||
resendOTPLoading = false;
|
||||
|
||||
/** True once the user picked DSC and the challenge sub-screen is active. */
|
||||
dscChallengeActive = false;
|
||||
/** Currently-running DSC login challenge (set by requestDscChallenge). */
|
||||
dscChallenge: DscLoginChallenge | null = null;
|
||||
/** Status flags for the DSC challenge sub-screen. */
|
||||
dscDetecting = false;
|
||||
dscFailed = false;
|
||||
dscErrorMessage = '';
|
||||
/** Seconds remaining on the DSC challenge — driven off server response. */
|
||||
dscSecondsRemaining = 0;
|
||||
dscCountdownLabel = '';
|
||||
|
||||
private dscPollSub?: Subscription;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Creates two factor authentication delivery method form.
|
||||
*
|
||||
@@ -63,22 +82,68 @@ export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.createTwoFactorAuthenticationDeliveryMethodForm();
|
||||
this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => {
|
||||
// Keep only methods the user has actually configured (target populated).
|
||||
// Methods returned without a destination are noise from the backend.
|
||||
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
|
||||
this.twoFactorAuthenticationDeliveryMethods = enabled;
|
||||
|
||||
// If exactly one method is enabled, skip the picker and request OTP automatically.
|
||||
if (enabled.length === 1) {
|
||||
this.twoFactorAuthenticationDeliveryMethodForm
|
||||
.get('twoFactorAuthenticationDeliveryMethod')
|
||||
?.setValue(enabled[0]);
|
||||
this.requestOTP();
|
||||
// Fetch SMS/EMAIL delivery methods and the user's ACTIVE DSCs in parallel,
|
||||
// merge them into a single factor list, then auto-route if there's only one.
|
||||
this.authenticationService.getDeliveryMethods().subscribe({
|
||||
next: (deliveryMethods: any) => {
|
||||
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (dscs) => this.mergeFactors(enabled, dscs || []),
|
||||
error: () => this.mergeFactors(enabled, [])
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
// If delivery-methods call fails, still try to surface DSC.
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (dscs) => this.mergeFactors([], dscs || []),
|
||||
error: () => this.mergeFactors([], [])
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.dscPollSub?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge OTP delivery methods with ACTIVE DSCs into a single list of factors.
|
||||
* DSC factors carry `kind: 'DSC'` so the picker and Continue button can branch.
|
||||
*/
|
||||
private mergeFactors(otpMethods: any[], dscs: BankUserDsc[]): void {
|
||||
const dscFactors = (dscs || [])
|
||||
.filter((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE')
|
||||
.map((d) => ({
|
||||
kind: 'DSC',
|
||||
name: 'DSC',
|
||||
target: d.commonName || d.dscName || d.dscSerialNoMasked || `DSC #${d.id}`,
|
||||
dsc: d
|
||||
}));
|
||||
const combined = [
|
||||
...otpMethods.map((m) => ({ ...m, kind: 'OTP' })),
|
||||
...dscFactors
|
||||
];
|
||||
this.twoFactorAuthenticationDeliveryMethods = combined;
|
||||
if (combined.length === 1) {
|
||||
this.twoFactorAuthenticationDeliveryMethodForm
|
||||
.get('twoFactorAuthenticationDeliveryMethod')
|
||||
?.setValue(combined[0]);
|
||||
this.continueWithSelectedFactor();
|
||||
}
|
||||
}
|
||||
|
||||
/** Branches on the picked factor: OTP path vs DSC challenge path. */
|
||||
continueWithSelectedFactor(): void {
|
||||
const method = this.twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod;
|
||||
if (method?.kind === 'DSC') {
|
||||
this.requestDscChallenge(method);
|
||||
} else {
|
||||
this.requestOTP();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests OTP via the selected delivery method.
|
||||
*/
|
||||
@@ -131,6 +196,149 @@ export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.get('twoFactorAuthenticationDeliveryMethod')?.setValue(method);
|
||||
}
|
||||
|
||||
/** Icon name for a factor row in the picker / context strip. */
|
||||
iconFor(method: any): string {
|
||||
const name = (method?.name || '').toLowerCase();
|
||||
if (method?.kind === 'DSC' || name === 'dsc') return 'signature';
|
||||
if (name === 'sms' || name === 'mobile' || name === 'phone') return 'mobile-alt';
|
||||
return 'envelope';
|
||||
}
|
||||
|
||||
/** Start a DSC login challenge: POST /challenge → fire JNLP → begin polling. */
|
||||
requestDscChallenge(method: any): void {
|
||||
this.loading = true;
|
||||
this.dscFailed = false;
|
||||
this.dscErrorMessage = '';
|
||||
this.dscChallengeActive = true;
|
||||
this.selectedTwoFactorAuthenticationDeliveryMethod = method;
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.disable();
|
||||
|
||||
this.dscService
|
||||
.requestLoginChallenge()
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (challenge) => {
|
||||
this.dscChallenge = challenge;
|
||||
this.dscSecondsRemaining = challenge.expiresInSeconds || 120;
|
||||
this.dscDetecting = true;
|
||||
this.tickDscCountdown();
|
||||
// Pass the whole challenge — the service builds the JNLP locally and bypasses
|
||||
// JWS server fetches entirely.
|
||||
this.dscService.downloadAndOpenJnlp(challenge);
|
||||
this.beginDscPolling();
|
||||
},
|
||||
error: (err) => {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
'Could not start DSC verification.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-fire the JNLP without restarting the challenge. */
|
||||
reDownloadDscHelper(): void {
|
||||
if (this.dscChallenge) this.dscService.downloadAndOpenJnlp(this.dscChallenge);
|
||||
}
|
||||
|
||||
/** Drop back to the factor picker without consuming the challenge. */
|
||||
useAnotherMethod(): void {
|
||||
this.dscPollSub?.unsubscribe();
|
||||
this.dscChallengeActive = false;
|
||||
this.dscChallenge = null;
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = false;
|
||||
this.dscErrorMessage = '';
|
||||
this.selectedTwoFactorAuthenticationDeliveryMethod = null;
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.enable();
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.reset();
|
||||
}
|
||||
|
||||
private beginDscPolling(): void {
|
||||
if (!this.dscChallenge?.randomNo) return;
|
||||
const timeoutMs = (this.dscChallenge.expiresInSeconds || 120) * 1000;
|
||||
this.dscPollSub?.unsubscribe();
|
||||
this.dscPollSub = this.dscService
|
||||
.pollStatus(() => this.dscService.getLoginStatus(this.dscChallenge!.randomNo), timeoutMs)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
if (res.status === 'VERIFIED') {
|
||||
this.dscDetecting = false;
|
||||
this.claimAndValidate(this.dscChallenge!.randomNo);
|
||||
} else if (res.status === 'FAILED' || res.status === 'EXPIRED') {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
(res as any).errorMessage ||
|
||||
(res.status === 'EXPIRED' ? 'Verification took too long — try again.' : 'DSC verification failed.');
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
if (this.dscDetecting) {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage = 'Verification took too long — try again.';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage = err?.error?.defaultUserMessage || err?.message || 'Polling failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** After /status reports VERIFIED, fetch the TFA token via /claim and finalise login. */
|
||||
private claimAndValidate(randomNo: string): void {
|
||||
this.loading = true;
|
||||
this.dscService
|
||||
.claimLoginToken(randomNo)
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
const token: string | null = res?.tfaToken || null;
|
||||
if (!token) {
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
res?.errorMessage || 'DSC verified, but the verification token could not be claimed.';
|
||||
return;
|
||||
}
|
||||
// Reuse the existing /twofactor/validate pipeline; backend treats the
|
||||
// DSC-issued bearer token the same as an OTP code.
|
||||
this.authenticationService.validateOTP(token).subscribe({
|
||||
error: (err) => {
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Token rejected by server.';
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
'Could not claim verification token.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private tickDscCountdown(): void {
|
||||
interval(1000)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
if (this.dscSecondsRemaining > 0) this.dscSecondsRemaining--;
|
||||
const m = Math.floor(this.dscSecondsRemaining / 60);
|
||||
const s = this.dscSecondsRemaining % 60;
|
||||
this.dscCountdownLabel = `${m}:${String(s).padStart(2, '0')}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask the delivery target so only the last 4 characters are visible.
|
||||
* Mobile → "+XXXXXX2076"; Email → "ad***@gmail.com".
|
||||
|
||||
Reference in New Issue
Block a user