442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
/**
|
|
* Two-Factor Authentication Component
|
|
* Professional Angular Resume Builder - Device Registration 2FA
|
|
*
|
|
* @author David Valera Melendez <david@valera-melendez.de>
|
|
* @created 2025-08-08
|
|
* @location Made in Germany 🇩🇪
|
|
*/
|
|
|
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
|
import { Router, ActivatedRoute } from '@angular/router';
|
|
import { Subject, timer } from 'rxjs';
|
|
import { takeUntil, finalize, tap } from 'rxjs/operators';
|
|
|
|
// Angular Material Modules
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
|
import { MatDividerModule } from '@angular/material/divider';
|
|
import { MatChipsModule } from '@angular/material/chips';
|
|
|
|
// Services and Models
|
|
import { AuthService } from '../../../services/auth.service';
|
|
import { TwoFactorInitiationResponse } from '../../../models/auth.model';
|
|
|
|
interface TwoFactorPageData {
|
|
userId: number;
|
|
userEmail: string;
|
|
userName: string;
|
|
reason?: string;
|
|
riskScore?: number;
|
|
flowType?: 'device_registration' | 'code_required';
|
|
deviceInfo?: {
|
|
isTrusted: boolean;
|
|
deviceName?: string;
|
|
lastUsed?: Date;
|
|
};
|
|
tempToken?: string | null;
|
|
timestamp?: number; // For data expiration checking
|
|
}
|
|
|
|
/**
|
|
* Two-Factor Authentication Page Component
|
|
*/
|
|
@Component({
|
|
selector: 'app-two-factor-auth',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
ReactiveFormsModule,
|
|
MatCardModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule,
|
|
MatSnackBarModule,
|
|
MatDividerModule,
|
|
MatChipsModule
|
|
],
|
|
templateUrl: './two-factor-auth.component.html',
|
|
styleUrls: ['./two-factor-auth.component.css']
|
|
})
|
|
export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
|
// Form Management
|
|
twoFactorForm!: FormGroup;
|
|
|
|
// State Management
|
|
isInitiating = false;
|
|
isVerifying = false;
|
|
step: 'request' | 'verify' = 'request';
|
|
twoFactorResponse: TwoFactorInitiationResponse | null = null;
|
|
countdown = 0;
|
|
|
|
// Computed Properties
|
|
get isVerificationCodeValid(): boolean {
|
|
const codeControl = this.twoFactorForm?.get('verificationCode');
|
|
return !!(codeControl && codeControl.valid && codeControl.value && codeControl.value.length === 6);
|
|
}
|
|
|
|
get canSubmitVerification(): boolean {
|
|
return this.isVerificationCodeValid && !this.isVerifying && !!this.twoFactorResponse;
|
|
}
|
|
|
|
// Page Data
|
|
data: TwoFactorPageData = {
|
|
userId: 0,
|
|
userEmail: '',
|
|
userName: ''
|
|
};
|
|
|
|
// Cleanup
|
|
private destroy$ = new Subject<void>();
|
|
private countdownTimer$ = new Subject<void>();
|
|
|
|
constructor(
|
|
private formBuilder: FormBuilder,
|
|
private authService: AuthService,
|
|
private snackBar: MatSnackBar,
|
|
private router: Router,
|
|
private route: ActivatedRoute
|
|
) {
|
|
this.initializeForms();
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
// Get data from sessionStorage (more secure than URL parameters)
|
|
const storedData = sessionStorage.getItem('twoFactorData');
|
|
|
|
if (storedData) {
|
|
try {
|
|
const parsedData = JSON.parse(storedData);
|
|
|
|
// Check if data is not too old (10 minutes max)
|
|
const maxAge = 10 * 60 * 1000; // 10 minutes in milliseconds
|
|
if (Date.now() - parsedData.timestamp > maxAge) {
|
|
sessionStorage.removeItem('twoFactorData');
|
|
this.router.navigate(['/login']);
|
|
return;
|
|
}
|
|
|
|
this.data = parsedData;
|
|
} catch (error) {
|
|
this.router.navigate(['/login']);
|
|
return;
|
|
}
|
|
} else {
|
|
// Fallback: try to get from query params (backward compatibility)
|
|
this.route.queryParams.subscribe(params => {
|
|
if (params['userId'] || params['email']) {
|
|
this.data = {
|
|
userId: +params['userId'] || 0,
|
|
userEmail: params['email'] || '',
|
|
userName: params['name'] || '',
|
|
reason: params['reason'] || 'New device detected',
|
|
riskScore: +params['riskScore'] || 0,
|
|
flowType: params['flowType'] || 'device_registration',
|
|
tempToken: params['tempToken'] || null,
|
|
timestamp: Date.now()
|
|
};
|
|
} else {
|
|
// No data found, redirect to login
|
|
this.snackBar.open('Invalid 2FA session. Please login again.', 'Close', {
|
|
duration: 5000,
|
|
panelClass: ['error-snackbar']
|
|
});
|
|
this.router.navigate(['/login']);
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
// If no valid data, redirect back to login
|
|
if (!this.data.userId || !this.data.userEmail) {
|
|
this.snackBar.open('Invalid 2FA session. Please login again.', 'Close', {
|
|
duration: 5000,
|
|
panelClass: ['error-snackbar']
|
|
});
|
|
this.router.navigate(['/login']);
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
this.countdownTimer$.next();
|
|
this.countdownTimer$.complete();
|
|
|
|
// Clean up sensitive data when component is destroyed
|
|
sessionStorage.removeItem('twoFactorData');
|
|
}
|
|
|
|
/**
|
|
* Initialize reactive forms
|
|
*/
|
|
private initializeForms(): void {
|
|
this.twoFactorForm = this.formBuilder.group({
|
|
verificationCode: ['', [
|
|
Validators.required,
|
|
Validators.pattern(/^\d{6}$/)
|
|
]]
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Request 2FA code from backend
|
|
*/
|
|
requestTwoFactorCode(): void {
|
|
if (this.isInitiating) return;
|
|
|
|
this.isInitiating = true;
|
|
|
|
this.authService.initiateTwoFactor(this.data.userId)
|
|
.pipe(
|
|
takeUntil(this.destroy$),
|
|
finalize(() => {
|
|
this.isInitiating = false;
|
|
})
|
|
)
|
|
.subscribe({
|
|
next: (response) => {
|
|
this.twoFactorResponse = response;
|
|
this.step = 'verify';
|
|
this.startCountdown(response.durationMinutes * 60);
|
|
|
|
this.snackBar.open(response.message, 'Close', {
|
|
duration: 5000,
|
|
panelClass: ['info-snackbar']
|
|
});
|
|
},
|
|
error: (error) => {
|
|
this.snackBar.open('Failed to request verification code. Please try again.', 'Close', {
|
|
duration: 5000,
|
|
panelClass: ['error-snackbar']
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Submit verification code
|
|
*/
|
|
submitVerificationCode(): void {
|
|
// Enhanced validation
|
|
if (!this.canSubmitVerification) {
|
|
this.twoFactorForm.markAllAsTouched();
|
|
|
|
// Show specific error message
|
|
const codeControl = this.twoFactorForm.get('verificationCode');
|
|
if (!codeControl?.value) {
|
|
this.snackBar.open('Please enter the verification code', 'Close', {
|
|
duration: 3000,
|
|
panelClass: ['error-snackbar']
|
|
});
|
|
} else if (codeControl.value.length !== 6) {
|
|
this.snackBar.open('Verification code must be 6 digits', 'Close', {
|
|
duration: 3000,
|
|
panelClass: ['error-snackbar']
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.isVerifying = true;
|
|
const code = this.twoFactorForm.get('verificationCode')?.value;
|
|
|
|
this.authService.verifyTwoFactor(code, this.twoFactorResponse!.verificationId, this.data.userId, this.data.tempToken || undefined)
|
|
.pipe(
|
|
takeUntil(this.destroy$),
|
|
finalize(() => {
|
|
this.isVerifying = false;
|
|
})
|
|
)
|
|
.subscribe({
|
|
next: (response) => {
|
|
if (response.success) {
|
|
// Clear sensitive data from sessionStorage on success
|
|
sessionStorage.removeItem('twoFactorData');
|
|
|
|
if (this.data.flowType === 'device_registration') {
|
|
this.handleDeviceRegistrationSuccess(response);
|
|
} else if (this.data.flowType === 'code_required' && this.data.tempToken) {
|
|
this.handleLoginCompletionSuccess(response);
|
|
}
|
|
} else {
|
|
this.handleVerificationError(response.message || 'Verification failed');
|
|
}
|
|
},
|
|
error: (error) => {
|
|
this.handleVerificationError(error.error?.message || 'Verification failed. Please try again.');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle successful device registration
|
|
*/
|
|
private handleDeviceRegistrationSuccess(response: any): void {
|
|
this.snackBar.open('Device registered successfully! You can now login.', 'Close', {
|
|
duration: 5000,
|
|
panelClass: ['success-snackbar']
|
|
});
|
|
|
|
// Navigate back to login after successful device registration
|
|
this.router.navigate(['/login'], {
|
|
queryParams: {
|
|
twoFactorComplete: 'true',
|
|
email: this.data.userEmail
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle successful login completion
|
|
*/
|
|
private handleLoginCompletionSuccess(response: any): void {
|
|
// Check if authentication was completed by backend
|
|
if (response.authCompleted && response.accessToken) {
|
|
// Store the access token using the correct key that AuthService expects
|
|
localStorage.setItem('david_auth_access_token', response.accessToken);
|
|
|
|
// Store user data if available using the correct key
|
|
if (response.user) {
|
|
const userData = {
|
|
id: response.user.id,
|
|
email: response.user.email,
|
|
firstName: response.user.firstName || '',
|
|
lastName: response.user.lastName || ''
|
|
};
|
|
localStorage.setItem('david_auth_user_data', JSON.stringify(userData));
|
|
}
|
|
|
|
this.snackBar.open('Authentication successful! Redirecting to dashboard...', 'Close', {
|
|
duration: 3000,
|
|
panelClass: ['success-snackbar']
|
|
});
|
|
|
|
this.authService.refreshAuthState();
|
|
|
|
// Navigate to dashboard
|
|
setTimeout(() => {
|
|
this.router.navigate(['/dashboard']);
|
|
}, 1000);
|
|
} else {
|
|
// Authentication not completed - redirect to login
|
|
this.snackBar.open('2FA verification successful. Please complete login.', 'Close', {
|
|
duration: 3000,
|
|
panelClass: ['success-snackbar']
|
|
});
|
|
|
|
this.router.navigate(['/login'], {
|
|
queryParams: {
|
|
twoFactorVerified: 'true',
|
|
email: this.data.userEmail
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle verification error
|
|
*/
|
|
private handleVerificationError(message: string): void {
|
|
this.snackBar.open(message, 'Close', {
|
|
duration: 4000,
|
|
panelClass: ['error-snackbar']
|
|
});
|
|
|
|
// Clear the form to let user try again
|
|
this.twoFactorForm.get('verificationCode')?.setValue('');
|
|
this.twoFactorForm.get('verificationCode')?.markAsUntouched();
|
|
}
|
|
|
|
/**
|
|
* Auto-fill demo code if available
|
|
*/
|
|
fillDemoCode(): void {
|
|
if (this.twoFactorResponse?.verificationCode) {
|
|
this.twoFactorForm.patchValue({
|
|
verificationCode: this.twoFactorResponse.verificationCode
|
|
});
|
|
|
|
this.snackBar.open('Demo verification code filled', 'Close', {
|
|
duration: 2000,
|
|
panelClass: ['info-snackbar']
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start countdown timer for code expiration
|
|
*/
|
|
private startCountdown(seconds: number): void {
|
|
this.countdown = seconds;
|
|
|
|
timer(0, 1000)
|
|
.pipe(
|
|
takeUntil(this.countdownTimer$),
|
|
takeUntil(this.destroy$),
|
|
tap(() => {
|
|
this.countdown--;
|
|
if (this.countdown <= 0) {
|
|
this.countdownTimer$.next();
|
|
}
|
|
})
|
|
)
|
|
.subscribe();
|
|
}
|
|
|
|
/**
|
|
* Format countdown time as MM:SS
|
|
*/
|
|
getFormattedCountdown(): string {
|
|
const minutes = Math.floor(this.countdown / 60);
|
|
const seconds = this.countdown % 60;
|
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Navigate back to login
|
|
*/
|
|
navigateToLogin(): void {
|
|
this.router.navigate(['/login']);
|
|
}
|
|
|
|
/**
|
|
* Get field error message
|
|
*/
|
|
getFieldError(fieldName: string): string {
|
|
const field = this.twoFactorForm.get(fieldName);
|
|
if (!field || !field.errors || !field.touched) return '';
|
|
|
|
const errors = field.errors;
|
|
|
|
if (errors['required']) return 'Verification code is required';
|
|
if (errors['pattern']) return 'Please enter a 6-digit code';
|
|
|
|
return 'Invalid input';
|
|
}
|
|
|
|
/**
|
|
* Check if field has error
|
|
*/
|
|
hasFieldError(fieldName: string): boolean {
|
|
const field = this.twoFactorForm.get(fieldName);
|
|
return !!(field && field.invalid && field.touched);
|
|
}
|
|
|
|
/**
|
|
* Check if demo mode is enabled
|
|
*/
|
|
isDemoMode(): boolean {
|
|
return this.twoFactorResponse?.environmentInfo.demoModeEnabled || false;
|
|
}
|
|
}
|