From 786e4a59b8cd35bd12307b2fdf17fdf3afa90c9d Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 02:27:42 +0200 Subject: [PATCH 01/26] W.I.P --- .../src/entities/email-verification.entity.ts | 3 + .../auth-module/controller/auth.controller.ts | 20 +- .../modules/auth-module/models/dto/index.ts | 1 + .../auth-module/models/dto/magic-link.dto.ts | 12 + .../auth-module/services/auth.service.ts | 73 +- .../password-confirmation.mail.service.ts | 40 + .../controller/verify.controller.ts | 46 +- .../repositories/email-verify.repository.ts | 33 +- .../services/email-verification.service.ts | 75 +- .../shared/filters/http-exception.filter.ts | 2 + .../email-verify-root.component.ts | 34 +- .../event-empty-state.component.ts | 22 +- .../welcome-root/welcome-root.component.html | 955 +++++++++++++++++- .../welcome-root/welcome-root.component.ts | 364 ++++++- .../src/app/shared/service/auth.service.ts | 9 + 15 files changed, 1592 insertions(+), 97 deletions(-) create mode 100644 backend/src/modules/auth-module/models/dto/magic-link.dto.ts diff --git a/backend/src/entities/email-verification.entity.ts b/backend/src/entities/email-verification.entity.ts index f49655f..36b6b0d 100644 --- a/backend/src/entities/email-verification.entity.ts +++ b/backend/src/entities/email-verification.entity.ts @@ -21,6 +21,9 @@ export class EmailVerification { @Column() public expiresAt: Date; + @Column() + public email: string; + @OneToOne(() => UserCredentials) @JoinColumn({ name: 'userCredentialsId' }) public user: UserCredentials; diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index 5b0baa2..b3829c6 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -15,7 +15,11 @@ import { SuccessDto } from 'src/shared'; import { Public } from 'src/shared/decorator'; import { LocalAuthGuard } from '../guard'; -import { SigninResponseDto, UserCredentialsDto } from '../models/dto'; +import { + MagicLinkDto, + SigninResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { AuthService } from '../services/auth.service'; @ApiTags('Authentication') @@ -23,6 +27,20 @@ import { AuthService } from '../services/auth.service'; export class AuthController { public constructor(private readonly authService: AuthService) {} + @ApiCreatedResponse({ + description: 'Magic link sent successfully', + type: SuccessDto, + }) + @ApiBody({ type: MagicLinkDto }) + @Post('send-magic-link') + @HttpCode(HttpStatus.OK) + @Public() + public async sendMagicLink( + @Body() magicLinkDto: MagicLinkDto + ): Promise { + return this.authService.sendMagicLink(magicLinkDto); + } + @ApiCreatedResponse({ description: 'User signed up successfully', type: SuccessDto, diff --git a/backend/src/modules/auth-module/models/dto/index.ts b/backend/src/modules/auth-module/models/dto/index.ts index e0a3cb5..e8eeec3 100644 --- a/backend/src/modules/auth-module/models/dto/index.ts +++ b/backend/src/modules/auth-module/models/dto/index.ts @@ -1,2 +1,3 @@ export * from './user-credentials.dto'; export * from './signin-response.dto'; +export * from './magic-link.dto'; diff --git a/backend/src/modules/auth-module/models/dto/magic-link.dto.ts b/backend/src/modules/auth-module/models/dto/magic-link.dto.ts new file mode 100644 index 0000000..d059252 --- /dev/null +++ b/backend/src/modules/auth-module/models/dto/magic-link.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsEmail } from 'class-validator'; + +export class MagicLinkDto { + @ApiProperty({ + description: 'User email', + example: 'foo@bar.com', + }) + @IsNotEmpty() + @IsEmail() + public email: string; +} diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index 53e70f4..e2d4a1a 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { UserCredentials } from 'src/entities'; import { SessionService } from 'src/modules/session/services/session.service'; import { EncryptionService, SuccessDto } from 'src/shared'; @@ -11,7 +11,11 @@ import { import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { EmailVerificationService } from '../../verify-module/services/email-verification.service'; -import { SigninResponseDto, UserCredentialsDto } from '../models/dto'; +import { + MagicLinkDto, + SigninResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; @Injectable() @@ -24,6 +28,54 @@ export class AuthService { private readonly sessionService: SessionService ) {} + public async sendMagicLink(magiclink: MagicLinkDto): Promise { + try { + const existingUser = await this.userCredentialsRepository.findUserByEmail( + magiclink.email + ); + + if (existingUser) { + const token = + await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( + magiclink.email + ); + + await this.passwordConfirmationMailService.sendLoginLinkEmail( + magiclink.email, + token + ); + } else { + const isEmailSubmitted: boolean = + await this.emailVerificationService.isEmailSubmitted(magiclink.email); + + if (!isEmailSubmitted) { + const token = + await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( + magiclink.email + ); + + await this.passwordConfirmationMailService.sendRegistrationLinkEmail( + magiclink.email, + token + ); + } else { + throw new ConflictException('EMAIL_ALREADY_SUBMITTED', { + message: 'This email has already been submitted for registration.', + }); + } + } + + return { success: true }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('MAGIC_LINK_ERROR', { + cause: error, + }); + } + } + public async signup( userCredentials: UserCredentialsDto ): Promise { @@ -47,15 +99,16 @@ export class AuthService { await this.userDataRepository.createInitialUserData(user); - const token = - await this.emailVerificationService.generateEmailVerificationToken( - user.id - ); + // TODO: Send Welcome Mail + // const token = + // await this.emailVerificationService.generateEmailVerificationToken( + // user.id + // ); - await this.passwordConfirmationMailService.sendPasswordConfirmationMail( - user.email, - token - ); + // await this.passwordConfirmationMailService.sendPasswordConfirmationMail( + // user.email, + // token + // ); return { success: true, diff --git a/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts b/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts index 1f19474..819793e 100644 --- a/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts +++ b/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts @@ -41,4 +41,44 @@ export class PasswordConfirmationMailService extends BaseMailService { await this.sendMail(mailoptions); } + + public async sendLoginLinkEmail( + to: string, + loginToken: string + ): Promise { + const token = `${loginToken}|${UriEncoderService.encodeBase64(to)}`; + const loginLink = `${this.configService.get('APP_URL')}/?token=${token}&signin=true`; + + const mailoptions: SendGridMailApi.MailDataRequired = { + to, + from: { email: 'info@igor-propisnov.com', name: 'Ticket App' }, + subject: 'Login to Your Account', + text: `Hi ${to}, Click this link to log in to your account: ${loginLink}`, + html: `

Click here to log in to your account.

`, + }; + + await this.sendMail(mailoptions); + } + + public async sendRegistrationLinkEmail( + to: string, + registrationToken: string + ): Promise { + const token = `${registrationToken}|${UriEncoderService.encodeBase64(to)}`; + const registrationLink = `${this.configService.get('APP_URL')}/?token=${token}&signup=true`; + + console.log('##############'); + console.log(registrationLink); + console.log('##############'); + + const mailoptions: SendGridMailApi.MailDataRequired = { + to, + from: { email: 'info@igor-propisnov.com', name: 'Ticket App' }, + subject: 'Complete Your Registration', + text: `Click this link to complete your registration: ${registrationLink}`, + html: `

Click here to complete your registration.

`, + }; + + await this.sendMail(mailoptions); + } } diff --git a/backend/src/modules/verify-module/controller/verify.controller.ts b/backend/src/modules/verify-module/controller/verify.controller.ts index d10f070..c4273e3 100644 --- a/backend/src/modules/verify-module/controller/verify.controller.ts +++ b/backend/src/modules/verify-module/controller/verify.controller.ts @@ -1,16 +1,6 @@ -import { - Controller, - Get, - Req, - HttpCode, - HttpStatus, - Query, - UseGuards, - Post, -} from '@nestjs/common'; +import { Controller, HttpCode, HttpStatus, Query, Post } from '@nestjs/common'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; -import { Request } from 'express'; -import { SessionGuard } from 'src/modules/session/guard'; +import { SuccessDto } from 'src/shared'; import { Public } from 'src/shared/decorator'; import { EmailVerificationService } from '../services/email-verification.service'; @@ -24,25 +14,29 @@ export class VerifyController { @ApiCreatedResponse({ description: 'Verify email', - type: Boolean, + type: SuccessDto, }) @Public() @Post() @HttpCode(HttpStatus.OK) public async verifyEmail( - @Query('token') tokenToVerify: string - ): Promise { - return this.emailVerificationService.verifyEmail(tokenToVerify); + @Query('token') tokenToVerify: string, + @Query('email') emailToVerify: string + ): Promise { + return this.emailVerificationService.verifyEmail( + tokenToVerify, + emailToVerify + ); } - @ApiCreatedResponse({ - description: 'Check if email is verified', - type: Boolean, - }) - @Get('check') - @HttpCode(HttpStatus.OK) - @UseGuards(SessionGuard) - public async isEmailVerified(@Req() request: Request): Promise { - return this.emailVerificationService.isEmailVerified(request.sessionID); - } + // @ApiCreatedResponse({ + // description: 'Check if email is verified', + // type: Boolean, + // }) + // @Get('check') + // @HttpCode(HttpStatus.OK) + // @UseGuards(SessionGuard) + // public async isEmailVerified(@Req() request: Request): Promise { + // return this.emailVerificationService.isEmailVerified(request.sessionID); + // } } diff --git a/backend/src/modules/verify-module/repositories/email-verify.repository.ts b/backend/src/modules/verify-module/repositories/email-verify.repository.ts index 9c540c8..a87546a 100644 --- a/backend/src/modules/verify-module/repositories/email-verify.repository.ts +++ b/backend/src/modules/verify-module/repositories/email-verify.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EmailVerification } from 'src/entities'; -import { MoreThan, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; @Injectable() export class EmailVerifyRepository { @@ -13,21 +13,40 @@ export class EmailVerifyRepository { public async createEmailVerification( token: string, expiresAt: Date, - userId: string + email: string, + userId: string | null ): Promise { await this.repository.save({ token, expiresAt, - user: { id: userId }, + email, + user: userId ? { id: userId } : null, }); } - public async findEmailVerificationByToken(token: string): Promise { - const result = await this.repository.findOne({ - where: { token, expiresAt: MoreThan(new Date()) }, + public async findByTokenAndEmail( + token: string, + email: string + ): Promise { + return await this.repository.findOne({ + where: { + token, + email, + }, }); + } - return result !== null; + public async removeEmailVerificationByTokenAndEmail( + token: string, + email: string + ): Promise { + await this.repository.delete({ token, email }); + } + + public async findItemByEmail( + email: string + ): Promise { + return this.repository.findOne({ where: { email } }); } public async deleteEmailVerificationByToken( diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index c0e9214..dfbb8ac 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'crypto'; import { Injectable } from '@nestjs/common'; import { EmailVerification } from 'src/entities'; import { SessionService } from 'src/modules/session/services/session.service'; -import { UriEncoderService } from 'src/shared'; +import { SuccessDto, UriEncoderService } from 'src/shared'; import { InternalServerErrorException } from 'src/shared/exceptions'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; @@ -25,7 +25,8 @@ export class EmailVerificationService { await this.emailVerifyRepository.createEmailVerification( verificationToken, expiration, - userId + userId, + null ); return verificationToken; @@ -40,30 +41,66 @@ export class EmailVerificationService { } } - public async verifyEmail(tokenToVerify: string): Promise { + public async isEmailSubmitted(email: string): Promise { try { const emailVerification = - await this.emailVerifyRepository.findEmailVerificationByToken( - tokenToVerify + await this.emailVerifyRepository.findItemByEmail(email); + + return !!emailVerification; + } catch (error) { + throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { + message: 'An error occurred while verifying the email.', + }); + } + } + + public async generateEmailVerificationTokenForMagicLink( + email: string + ): Promise { + try { + const verificationToken = await this.createVerificationToken(); + const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await this.emailVerifyRepository.createEmailVerification( + verificationToken, + expiration, + email, + null + ); + + return verificationToken; + } catch (error) { + throw new InternalServerErrorException( + 'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR', + { + message: + 'An error occurred while generating the email verification token.', + } + ); + } + } + + public async verifyEmail( + tokenToVerify: string, + emailToVerify: string + ): Promise { + try { + const findTokenAndEmail: EmailVerification = + await this.emailVerifyRepository.findByTokenAndEmail( + tokenToVerify, + emailToVerify ); - if (!emailVerification) { - return false; + if (!findTokenAndEmail) { + return { success: false }; } - const deletedVerification = - await this.deleteEmailVerificationToken(tokenToVerify); + await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail( + tokenToVerify, + emailToVerify + ); - if (deletedVerification && deletedVerification.user) { - const isStatusUpdated = - await this.userDataRepository.updateEmailVerificationStatus( - deletedVerification.user.id - ); - - return isStatusUpdated; - } - - return false; + return { success: true }; } catch (error) { throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { message: 'An error occurred while verifying the email.', diff --git a/backend/src/shared/filters/http-exception.filter.ts b/backend/src/shared/filters/http-exception.filter.ts index d507bb8..7a8915d 100644 --- a/backend/src/shared/filters/http-exception.filter.ts +++ b/backend/src/shared/filters/http-exception.filter.ts @@ -19,6 +19,8 @@ export class HttpExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); + //console.error('Exception caught:', exception); + let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; let message: string = 'Internal server error'; let error: string = 'INTERNAL_SERVER_ERROR'; diff --git a/frontend/src/app/pages/email-verify-root/email-verify-root.component.ts b/frontend/src/app/pages/email-verify-root/email-verify-root.component.ts index 48af55f..b2bf3a0 100644 --- a/frontend/src/app/pages/email-verify-root/email-verify-root.component.ts +++ b/frontend/src/app/pages/email-verify-root/email-verify-root.component.ts @@ -11,8 +11,6 @@ import { } from '@angular/core'; import { Router } from '@angular/router'; -import { delay, filter, tap } from 'rxjs'; - import { VerifyApiService } from '../../api'; import { BackgroundPatternService, ThemeService } from '../../shared/service'; @@ -97,21 +95,21 @@ export class EmailVerifyRootComponent implements OnInit { this.email.set(decodeURIComponent(atob(email))); } - this.api - .verifyControllerVerifyEmail(verifyToken) - .pipe( - delay(1500), - tap((isVerified: boolean) => { - this.verifyStatus.set(isVerified); - }), - filter((isVerified) => isVerified), - tap(() => { - this.showRedirectMessage.set(true); - }), - delay(10000) - ) - .subscribe(() => { - this.navigateToWelcomeScreen(); - }); + // this.api + // .verifyControllerVerifyEmail(verifyToken) + // .pipe( + // delay(1500), + // tap((isVerified: boolean) => { + // this.verifyStatus.set(isVerified); + // }), + // filter((isVerified) => isVerified), + // tap(() => { + // this.showRedirectMessage.set(true); + // }), + // delay(10000) + // ) + // .subscribe(() => { + // this.navigateToWelcomeScreen(); + // }); } } diff --git a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts index 59f79bc..ab65b0b 100644 --- a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts +++ b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts @@ -24,17 +24,17 @@ export class EventEmptyStateComponent { private readonly verifyApi: VerifyApiService ) {} - public navigateToCreateEvent(): void { - this.verifyApi - .verifyControllerIsEmailVerified() - .subscribe((isVerified: boolean) => { - if (!isVerified) { - this.openEmailVerificationModal(); - } else { - this.router.navigate(['/event/create']); - } - }); - } + // public navigateToCreateEvent(): void { + // this.verifyApi + // .verifyControllerIsEmailVerified() + // .subscribe((isVerified: boolean) => { + // if (!isVerified) { + // this.openEmailVerificationModal(); + // } else { + // this.router.navigate(['/event/create']); + // } + // }); + // } public closeEmailVerificationModal(): void { (this.emailVerificationModal.nativeElement as HTMLDialogElement).close(); diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.html b/frontend/src/app/pages/welcome-root/welcome-root.component.html index d50117f..d001089 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.html +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.html @@ -1,4 +1,4 @@ -@if (!userSignupSuccess()) { +
@@ -29,7 +28,7 @@ (change)="toggleTheme()" [checked]="isDarkMode" /> - + - +
+
--> + + + +
+ + + +
+
+ +
+ +
+ @if (isTokenVerifing()) { + +
+
+
+
+ @if (isRegistrationMode()) { +
+ } +
+
+
+ } @else { +
+

+ @if (isRegistrationMode()) { + Complete Your Registration + } @else { + Welcome to APP-NAME + } +

+

+ @if (isRegistrationMode()) { + You're one step away from unlocking powerful event management + tools + } @else { + Enter your email to access your account or get started + } +

+
+ + + @if (isRegistrationMode()) { + + } + + +
+ +
+ } + + @if (!isRegistrationMode() && !isTokenVerifing()) { +
+
+

What happens next?

+
    +
  • We'll send a magic link to your email
  • +
  • Click the link in the email to securely log in
  • +
  • If you're new, you'll be prompted to create a password
  • +
  • Existing users will be logged in instantly
  • +
  • The magic link expires in 10 minutes for security
  • +
+
+
+ } @else if (isTokenVerifing()) { +
+
+
+
+
+
+
+
+
+
+
+
+ } +
+ +
+

+ By continuing, you agree to APP-NAME's + Terms of Service + and + Privacy Policy + . +

+
+
+
+ + + + + diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.ts b/frontend/src/app/pages/welcome-root/welcome-root.component.ts index 7fd00a2..aa94260 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.ts +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.ts @@ -1,4 +1,4 @@ -import { CommonModule } from '@angular/common'; +/* import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -398,3 +398,365 @@ export class WelcomeRootComponent implements OnInit { }); } } + */ +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + WritableSignal, + signal, + ElementRef, + InputSignal, + input, + effect, + ViewChild, +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Router } from '@angular/router'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { delay, finalize, tap, timer } from 'rxjs'; + +import { + Configuration, + MagicLinkDtoApiModel, + SuccessDtoApiModel, + UserCredentialsDtoApiModel, + VerifyApiService, +} from '../../api'; +import { ApiConfiguration } from '../../config/api-configuration'; +import { + AuthService, + BackgroundPatternService, + ThemeService, +} from '../../shared/service'; +import { customEmailValidator } from '../../shared/validator'; + +@Component({ + selector: 'app-unified-login', + standalone: true, + imports: [ + CommonModule, + FormsModule, + InputTextModule, + ReactiveFormsModule, + ButtonModule, + HttpClientModule, + ], + providers: [ + { + provide: Configuration, + useFactory: (): unknown => + new ApiConfiguration({ withCredentials: true }), + }, + ], + templateUrl: './welcome-root.component.html', + styleUrl: './welcome-root.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WelcomeRootComponent implements OnInit { + @ViewChild('passwordInput') public passwordInput!: ElementRef; + public token: InputSignal = input(''); + public signup: InputSignal = input(false); + public dialogBackgroundStyle: { 'background-image': string } | null = null; + public leftBackgroundStyle: { 'background-image': string } | null = null; + public rightBackgroundStyle: { 'background-image': string } | null = null; + public form!: FormGroup; + public isLoading: WritableSignal = signal(false); + public isEmailSent: WritableSignal = signal(false); + public isTokenVerifing: WritableSignal = signal(false); + public isTokenVerified: WritableSignal = signal(false); + public errorReasons: WritableSignal = signal([]); + public verificationError: WritableSignal = signal< + string | null + >(null); + public isRegistrationMode: WritableSignal = signal(false); + private removeQueryParams: WritableSignal = signal(false); + + public get isDarkMode(): boolean { + return this.themeService.getTheme() === 'dark'; + } + + public constructor( + private readonly formBuilder: FormBuilder, + private readonly authService: AuthService, + private readonly verifyApiService: VerifyApiService, + private readonly router: Router, + private readonly themeService: ThemeService, + private readonly el: ElementRef, + private readonly backgroundPatternService: BackgroundPatternService + ) { + effect(() => { + if (this.removeQueryParams()) { + this.clearRouteParams(); + } + }); + } + + public ngOnInit(): void { + this.setBackground(); + this.initializeForm(); + this.verifySignupMagicLink(); + } + + public verifySignupMagicLink(): void { + if (this.token() && this.signup()) { + const token: string = this.extractVerifyToken(); + const email: string = this.extractEmail(); + + this.removeQueryParams.set(true); + + if (token && email) { + this.isTokenVerifing.set(true); + this.verificationError.set(null); + this.errorReasons.set([]); + this.addPasswordFieldToForm(); + this.isRegistrationMode.set(true); + const decodedEmail = decodeURIComponent(atob(email)); + + this.verifyApiService + .verifyControllerVerifyEmail(token, decodedEmail) + .pipe( + delay(2000), + finalize(() => { + if (!this.verificationError()) { + this.isTokenVerifing.set(false); + this.isTokenVerified.set(true); + // Warte 3 Sekunden, dann schließe das Modal und fokussiere das Passwort-Feld + timer(3000).subscribe(() => { + this.isTokenVerified.set(false); + this.focusPasswordField(); + }); + } + }) + ) + .subscribe({ + next: (response: SuccessDtoApiModel) => { + if (response.success) { + this.isTokenVerifing.set(false); + console.log('Verification successful'); + } else { + console.error('Verification failed'); + this.verificationError.set( + 'Verification failed. Please check the reasons below:' + ); + this.errorReasons.set([ + 'The verification token may have expired.', + 'The email address may not match our records.', + 'The verification link may have been used already.', + ]); + } + }, + error: (error) => { + console.error('Verification failed', error); + this.verificationError.set( + 'An error occurred during verification. Please check the reasons below:' + ); + this.errorReasons.set([ + 'There might be a problem with your internet connection.', + 'Our servers might be experiencing issues.', + 'The verification service might be temporarily unavailable.', + ]); + }, + }); + + this.form.patchValue({ email: decodedEmail }); + const emailControl = this.form.get('email'); + + if (emailControl) { + emailControl.setValue(decodedEmail); + emailControl.disable(); + emailControl.markAsTouched(); + emailControl.setErrors(null); + } + } + } + } + + public getInputClass(controlName: string): string { + const control = this.form.get(controlName); + + if (controlName === 'email' && this.isRegistrationMode()) { + return 'input-success'; + } + + if (control?.touched) { + return control.valid ? 'input-success' : 'input-error'; + } + return ''; + } + + public getErrorMessage(controlName: string): string { + const control = this.form.get(controlName); + + if (control?.touched && control.errors) { + if (control.errors['required']) { + return 'This field is required.'; + } + if (control.errors['email']) { + return 'Please enter a valid email address.'; + } + } + return ''; + } + + public setBackground(): void { + const theme = this.themeService.getTheme(); + let opacity: number; + + if (theme === 'dark') { + opacity = 0.05; + } else { + opacity = 0.1; + } + + const colorPrimary = getComputedStyle( + this.el.nativeElement + ).getPropertyValue('--p'); + + const colorPrimaryC = getComputedStyle( + this.el.nativeElement + ).getPropertyValue('--pc'); + + const svgUrlforDialog = this.backgroundPatternService.getWigglePattern( + colorPrimary, + opacity + ); + const svgUrlForLeft = this.backgroundPatternService.getBankNotePattern( + colorPrimaryC, + opacity + ); + const svgUrlForRight = this.backgroundPatternService.getHideoutPattern( + colorPrimary, + opacity + ); + + this.dialogBackgroundStyle = { + 'background-image': `url("${svgUrlforDialog}")`, + }; + this.leftBackgroundStyle = { + 'background-image': `url("${svgUrlForLeft}")`, + }; + this.rightBackgroundStyle = { + 'background-image': `url("${svgUrlForRight}")`, + }; + } + + public toggleTheme(): void { + this.themeService.toggleTheme(); + this.setBackground(); + } + + public onSubmit(): void { + if (this.form.invalid) { + Object.keys(this.form.controls).forEach((key) => { + const control = this.form.get(key); + + control?.markAsTouched(); + }); + return; + } + + if (this.isRegistrationMode()) { + const signupCredentials: UserCredentialsDtoApiModel = { + email: this.form.value.email, + password: this.form.value.password, + }; + + this.signupNewUser(signupCredentials); + } else { + this.sendLoginEmail(this.form.value.email); + } + } + + private focusPasswordField(): void { + if (this.passwordInput) { + this.passwordInput.nativeElement.focus(); + } + } + + private clearRouteParams(): void { + this.router.navigate([], { queryParams: {} }); + } + + private extractVerifyToken(): string { + const [verifyToken]: string[] = this.token().split('|'); + + return verifyToken; + } + + private extractEmail(): string { + const [, email]: string[] = this.token().split('|'); + + return email; + } + + private initializeForm(): void { + this.form = this.formBuilder.group({ + email: new FormControl( + { value: '', disabled: false }, + { + validators: [Validators.required, customEmailValidator()], + updateOn: 'change', + } + ), + }); + } + + private addPasswordFieldToForm(): void { + this.form.addControl( + 'password', + new FormControl('', { + validators: [Validators.required, Validators.minLength(8)], + updateOn: 'change', + }) + ); + } + + private signupNewUser(signupCredentials: UserCredentialsDtoApiModel): void { + this.isLoading.set(true); + this.authService + .signup(signupCredentials) + .pipe( + delay(1000), + tap(() => this.isLoading.set(true)), + finalize(() => this.isLoading.set(false)) + ) + .subscribe((response: SuccessDtoApiModel) => { + if (response.success) { + console.log('User signed up successfully'); + // TODO: Redirect to Dashbord + } + }); + } + + private sendLoginEmail(email: string): void { + this.isLoading.set(true); + const magiclink: MagicLinkDtoApiModel = { + email: email, + }; + + this.authService + .sendMagicLink(magiclink) + .pipe( + delay(1000), + tap(() => this.isLoading.set(true)), + finalize(() => this.isLoading.set(false)) + ) + .subscribe((response: SuccessDtoApiModel) => { + if (response.success) { + this.isEmailSent.set(true); + } + }); + } +} diff --git a/frontend/src/app/shared/service/auth.service.ts b/frontend/src/app/shared/service/auth.service.ts index 36a4e9e..c0ba5f4 100644 --- a/frontend/src/app/shared/service/auth.service.ts +++ b/frontend/src/app/shared/service/auth.service.ts @@ -6,6 +6,7 @@ import { catchError, shareReplay, tap } from 'rxjs/operators'; import { AuthenticationApiService, + MagicLinkDtoApiModel, SigninResponseDtoApiModel, SuccessDtoApiModel, UserCredentialsDtoApiModel, @@ -27,6 +28,12 @@ export class AuthService { this.statusCheck$ = this.initializeStatusCheck(); } + public sendMagicLink( + email: MagicLinkDtoApiModel + ): Observable { + return this.authenticationApiService.authControllerSendMagicLink(email); + } + public signup( credentials: UserCredentialsDtoApiModel ): Observable { @@ -49,6 +56,7 @@ export class AuthService { .pipe(tap(() => this.isAuthenticatedSignal.set(false))); } + // TODO: Later for Autologin public status(): Observable { if (this.isAuthenticatedSignal()) { return of({ success: true }); @@ -56,6 +64,7 @@ export class AuthService { return this.statusCheck$; } + // TODO Later for AutoLogin private initializeStatusCheck(): Observable { return this.authenticationApiService.authControllerStatus().pipe( tap((response) => this.isAuthenticatedSignal.set(response.success)), From 8a1089ce9d89ac4c61555b530afafca45c3239ad Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 09:18:15 +0200 Subject: [PATCH 02/26] delete tokens after 10 minutes --- .../src/cron/clear-expired-sesstions.cron.ts | 16 +++++++++- .../repositories/email-verify.repository.ts | 27 +++++++++++++++- .../services/email-verification.service.ts | 32 ++++++++++++++++--- backend/src/shared/exceptions/index.ts | 1 + .../exceptions/token-expired.exception.ts | 14 ++++++++ 5 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 backend/src/shared/exceptions/token-expired.exception.ts diff --git a/backend/src/cron/clear-expired-sesstions.cron.ts b/backend/src/cron/clear-expired-sesstions.cron.ts index 8d5a56e..6cc0e4a 100644 --- a/backend/src/cron/clear-expired-sesstions.cron.ts +++ b/backend/src/cron/clear-expired-sesstions.cron.ts @@ -1,12 +1,16 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { SessionService } from 'src/modules/session/services/session.service'; +import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service'; @Injectable() export class ClearExpiredSessionsCron { private readonly logger: Logger = new Logger(ClearExpiredSessionsCron.name); - public constructor(private readonly sessionService: SessionService) {} + public constructor( + private readonly sessionService: SessionService, + private readonly emailVerificationService: EmailVerificationService + ) {} @Cron(CronExpression.EVERY_12_HOURS, { name: 'Clear-Expired-Sessions', @@ -17,4 +21,14 @@ export class ClearExpiredSessionsCron { this.sessionService.deleteAllExpiredSessions(); this.logger.log('-------------------------------------------'); } + + @Cron(CronExpression.EVERY_10_MINUTES, { + name: 'Clear-Expired-Tokens', + timeZone: 'Europe/Berlin', + }) + public handleClearExpiredTokens(): void { + this.logger.log('-Cronjob Executed: Delete-Expired-Tokens-'); + this.emailVerificationService.deleteAllExpiredTokens(); + this.logger.log('-------------------------------------------'); + } } diff --git a/backend/src/modules/verify-module/repositories/email-verify.repository.ts b/backend/src/modules/verify-module/repositories/email-verify.repository.ts index a87546a..f809161 100644 --- a/backend/src/modules/verify-module/repositories/email-verify.repository.ts +++ b/backend/src/modules/verify-module/repositories/email-verify.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EmailVerification } from 'src/entities'; -import { Repository } from 'typeorm'; +import { LessThan, MoreThan, Repository } from 'typeorm'; @Injectable() export class EmailVerifyRepository { @@ -24,6 +24,23 @@ export class EmailVerifyRepository { }); } + public async findValidVerification( + token: string, + email: string + ): Promise { + const currentDate = new Date(); + const tenMinutesAgo = new Date(currentDate.getTime() - 10 * 60 * 1000); + + return await this.repository.findOne({ + where: { + token, + email, + createdAt: MoreThan(tenMinutesAgo), + expiresAt: MoreThan(currentDate), + }, + }); + } + public async findByTokenAndEmail( token: string, email: string @@ -49,6 +66,14 @@ export class EmailVerifyRepository { return this.repository.findOne({ where: { email } }); } + public async deleteAllExpiredTokens(): Promise { + const currentDate = new Date(); + + await this.repository.delete({ + expiresAt: LessThan(currentDate), + }); + } + public async deleteEmailVerificationByToken( tokenToDelete: string ): Promise { diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index dfbb8ac..1a7b603 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -4,7 +4,10 @@ import { Injectable } from '@nestjs/common'; import { EmailVerification } from 'src/entities'; import { SessionService } from 'src/modules/session/services/session.service'; import { SuccessDto, UriEncoderService } from 'src/shared'; -import { InternalServerErrorException } from 'src/shared/exceptions'; +import { + InternalServerErrorException, + TokenExpiredException, +} from 'src/shared/exceptions'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { EmailVerifyRepository } from '../repositories'; @@ -59,11 +62,11 @@ export class EmailVerificationService { ): Promise { try { const verificationToken = await this.createVerificationToken(); - const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); await this.emailVerifyRepository.createEmailVerification( verificationToken, - expiration, + expiresAt, email, null ); @@ -85,13 +88,23 @@ export class EmailVerificationService { emailToVerify: string ): Promise { try { - const findTokenAndEmail: EmailVerification = - await this.emailVerifyRepository.findByTokenAndEmail( + const findTokenAndEmail: EmailVerification | null = + await this.emailVerifyRepository.findValidVerification( tokenToVerify, emailToVerify ); if (!findTokenAndEmail) { + const expiredToken = + await this.emailVerifyRepository.findByTokenAndEmail( + tokenToVerify, + emailToVerify + ); + + if (expiredToken) { + throw new TokenExpiredException(); + } + return { success: false }; } @@ -102,6 +115,9 @@ export class EmailVerificationService { return { success: true }; } catch (error) { + if (error instanceof TokenExpiredException) { + throw error; + } throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { message: 'An error occurred while verifying the email.', }); @@ -128,6 +144,12 @@ export class EmailVerificationService { } } + async deleteAllExpiredTokens(): Promise { + const currentDate = new Date(); + + await this.emailVerifyRepository.deleteAllExpiredTokens(); + } + private async createVerificationToken(): Promise { const verifyToken = randomBytes(32).toString('hex'); diff --git a/backend/src/shared/exceptions/index.ts b/backend/src/shared/exceptions/index.ts index c43a40a..f1bb424 100644 --- a/backend/src/shared/exceptions/index.ts +++ b/backend/src/shared/exceptions/index.ts @@ -2,3 +2,4 @@ export * from './conflict.exception'; export * from './forbidden.exception'; export * from './internal-server-error.exception'; export * from './not-found.exception'; +export * from './token-expired.exception'; diff --git a/backend/src/shared/exceptions/token-expired.exception.ts b/backend/src/shared/exceptions/token-expired.exception.ts new file mode 100644 index 0000000..82542eb --- /dev/null +++ b/backend/src/shared/exceptions/token-expired.exception.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; + +import { BaseException } from './base.exception'; + +export class TokenExpiredException extends BaseException { + public constructor(details?: unknown) { + super( + 'The verification token has expired. Please request a new one.', + HttpStatus.BAD_REQUEST, + 'TOKEN_EXPIRED', + details + ); + } +} From c9a8e9967af7c39c1f205fb8db3a2d50b94bc915 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 09:32:43 +0200 Subject: [PATCH 03/26] fix token issue --- .../services/email-verification.service.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index 1a7b603..9d5ca88 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -88,24 +88,19 @@ export class EmailVerificationService { emailToVerify: string ): Promise { try { - const findTokenAndEmail: EmailVerification | null = - await this.emailVerifyRepository.findValidVerification( - tokenToVerify, - emailToVerify - ); + const token = await this.emailVerifyRepository.findByTokenAndEmail( + tokenToVerify, + emailToVerify + ); - if (!findTokenAndEmail) { - const expiredToken = - await this.emailVerifyRepository.findByTokenAndEmail( - tokenToVerify, - emailToVerify - ); + if (!token) { + throw new TokenExpiredException(); + } - if (expiredToken) { - throw new TokenExpiredException(); - } + const currentDate = new Date(); - return { success: false }; + if (token.expiresAt.getTime() < currentDate.getTime()) { + throw new TokenExpiredException(); } await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail( From d59d41e1ee2ba310133583dd8aa45bac4b3b90a4 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 14:40:55 +0200 Subject: [PATCH 04/26] work in progress --- .../src/entities/email-verification.entity.ts | 3 + .../auth-module/controller/auth.controller.ts | 16 +- .../modules/auth-module/models/dto/index.ts | 1 + .../models/dto/magic-link-signin.dto.ts | 12 + .../auth-module/services/auth.service.ts | 75 +- .../auth-module/strategies/local.strategy.ts | 39 +- .../password-confirmation.mail.service.ts | 4 - .../modules/session/guard/session.guard.ts | 14 +- .../controller/verify.controller.ts | 28 +- .../repositories/email-verify.repository.ts | 47 +- .../services/email-verification.service.ts | 97 +-- backend/src/shared/exceptions/index.ts | 2 + .../shared/exceptions/session.exception.ts | 12 + .../useragent-mismatch-exception.ts | 14 + .../shared/filters/http-exception.filter.ts | 2 +- .../event-empty-state.component.ts | 20 +- .../welcome-root/welcome-root.component.html | 717 +----------------- .../welcome-root/welcome-root.component.ts | 658 +++++----------- .../src/app/shared/service/auth.service.ts | 23 +- 19 files changed, 369 insertions(+), 1415 deletions(-) create mode 100644 backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts create mode 100644 backend/src/shared/exceptions/session.exception.ts create mode 100644 backend/src/shared/exceptions/useragent-mismatch-exception.ts diff --git a/backend/src/entities/email-verification.entity.ts b/backend/src/entities/email-verification.entity.ts index 36b6b0d..3fdc7cd 100644 --- a/backend/src/entities/email-verification.entity.ts +++ b/backend/src/entities/email-verification.entity.ts @@ -24,6 +24,9 @@ export class EmailVerification { @Column() public email: string; + @Column({ nullable: true }) + public userAgent: string; + @OneToOne(() => UserCredentials) @JoinColumn({ name: 'userCredentialsId' }) public user: UserCredentials; diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index b3829c6..182440e 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -17,6 +17,7 @@ import { Public } from 'src/shared/decorator'; import { LocalAuthGuard } from '../guard'; import { MagicLinkDto, + MagicLinkSigninDto, SigninResponseDto, UserCredentialsDto, } from '../models/dto'; @@ -36,9 +37,12 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Public() public async sendMagicLink( - @Body() magicLinkDto: MagicLinkDto + @Body() magicLinkDto: MagicLinkDto, + @Req() request: Request ): Promise { - return this.authService.sendMagicLink(magicLinkDto); + const userAgent = request.headers['user-agent'] || 'Unknown'; + + return this.authService.sendMagicLink(magicLinkDto, userAgent); } @ApiCreatedResponse({ @@ -58,12 +62,14 @@ export class AuthController { description: 'User signin successfully', type: SigninResponseDto, }) - @ApiBody({ type: UserCredentialsDto }) + @ApiBody({ type: MagicLinkSigninDto }) @HttpCode(HttpStatus.OK) @UseGuards(LocalAuthGuard) @Public() - @Post('signin') - public async signin(@Req() request: Request): Promise { + @Post('magic-link-signin') + public async magicLinkSignin( + @Req() request: Request + ): Promise { return this.authService.getLoginResponse( request.user as SigninResponseDto & { userAgent: string } ); diff --git a/backend/src/modules/auth-module/models/dto/index.ts b/backend/src/modules/auth-module/models/dto/index.ts index e8eeec3..7393172 100644 --- a/backend/src/modules/auth-module/models/dto/index.ts +++ b/backend/src/modules/auth-module/models/dto/index.ts @@ -1,3 +1,4 @@ export * from './user-credentials.dto'; export * from './signin-response.dto'; export * from './magic-link.dto'; +export * from './magic-link-signin.dto'; diff --git a/backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts b/backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts new file mode 100644 index 0000000..25c71a9 --- /dev/null +++ b/backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString } from 'class-validator'; + +export class MagicLinkSigninDto { + @ApiProperty() + @IsString() + public token: string; + + @ApiProperty() + @IsEmail() + public email: string; +} diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index e2d4a1a..a4b9f78 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { UserCredentials } from 'src/entities'; import { SessionService } from 'src/modules/session/services/session.service'; import { EncryptionService, SuccessDto } from 'src/shared'; @@ -28,7 +32,10 @@ export class AuthService { private readonly sessionService: SessionService ) {} - public async sendMagicLink(magiclink: MagicLinkDto): Promise { + public async sendMagicLink( + magiclink: MagicLinkDto, + userAgent: string + ): Promise { try { const existingUser = await this.userCredentialsRepository.findUserByEmail( magiclink.email @@ -37,7 +44,9 @@ export class AuthService { if (existingUser) { const token = await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( - magiclink.email + magiclink.email, + userAgent, + existingUser.id ); await this.passwordConfirmationMailService.sendLoginLinkEmail( @@ -45,24 +54,16 @@ export class AuthService { token ); } else { - const isEmailSubmitted: boolean = - await this.emailVerificationService.isEmailSubmitted(magiclink.email); - - if (!isEmailSubmitted) { - const token = - await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( - magiclink.email - ); - - await this.passwordConfirmationMailService.sendRegistrationLinkEmail( + const token = + await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( magiclink.email, - token + userAgent ); - } else { - throw new ConflictException('EMAIL_ALREADY_SUBMITTED', { - message: 'This email has already been submitted for registration.', - }); - } + + await this.passwordConfirmationMailService.sendRegistrationLinkEmail( + magiclink.email, + token + ); } return { success: true }; @@ -105,11 +106,6 @@ export class AuthService { // user.id // ); - // await this.passwordConfirmationMailService.sendPasswordConfirmationMail( - // user.email, - // token - // ); - return { success: true, }; @@ -125,28 +121,31 @@ export class AuthService { } public async validateUser( + token: string, email: string, - password: string + userAgent: string ): Promise { try { + const verificationResult = + await this.emailVerificationService.verifyEmail( + token, + email, + userAgent + ); + + if (!verificationResult.success) { + throw new UnauthorizedException('Invalid or expired token'); + } + const user = await this.userCredentialsRepository.findUserByEmail(email); if (!user) { - throw new ForbiddenException('INVALID_CREDENTIALS'); - } - - const passwordMatch = await EncryptionService.compareHash( - password, - user.hashedPassword - ); - - if (!passwordMatch) { - throw new ForbiddenException('INVALID_CREDENTIALS'); + throw new UnauthorizedException('User not found'); } return user; } catch (error) { - if (error instanceof ForbiddenException) { + if (error instanceof UnauthorizedException) { throw error; } else { throw new InternalServerErrorException('VALIDATION_ERROR', { @@ -156,6 +155,10 @@ export class AuthService { } } + public async getUserByEmail(email: string): Promise { + return this.userCredentialsRepository.findUserByEmail(email); + } + public async signout(sessionId: string): Promise { try { await this.sessionService.deleteSessionBySessionId(sessionId); diff --git a/backend/src/modules/auth-module/strategies/local.strategy.ts b/backend/src/modules/auth-module/strategies/local.strategy.ts index a77367a..fac77af 100644 --- a/backend/src/modules/auth-module/strategies/local.strategy.ts +++ b/backend/src/modules/auth-module/strategies/local.strategy.ts @@ -1,34 +1,51 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Request } from 'express'; import { Strategy } from 'passport-local'; +import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service'; -import { SigninResponseDto } from '../models/dto'; +import { MagicLinkSigninDto } from '../models/dto'; import { AuthService } from '../services/auth.service'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { - public constructor(private readonly authService: AuthService) { + public constructor( + private authService: AuthService, + private emailVerificationService: EmailVerificationService + ) { super({ usernameField: 'email', - passwordField: 'password', + passwordField: 'token', passReqToCallback: true, }); } - public async validate( - request: Request, - email: string, - password: string - ): Promise { - const user = await this.authService.validateUser(email, password); + public async validate(request: Request): Promise { + const { token, email }: MagicLinkSigninDto = request.body; + + if (!token || !email) { + throw new UnauthorizedException('Missing token or email'); + } + + const verificationResult = await this.emailVerificationService.verifyEmail( + token as string, + email as string, + request.headers['user-agent'] + ); + + if (!verificationResult.success) { + throw new UnauthorizedException('Invalid or expired token'); + } + + const user = await this.authService.getUserByEmail(email as string); if (!user) { - throw new UnauthorizedException(); + throw new UnauthorizedException('User not found'); } const userAgent = request.headers['user-agent']; - return { id: user.id, email: user.email, userAgent: userAgent }; + return { id: user.id, email: user.email, userAgent }; } } diff --git a/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts b/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts index 819793e..79db592 100644 --- a/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts +++ b/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts @@ -67,10 +67,6 @@ export class PasswordConfirmationMailService extends BaseMailService { const token = `${registrationToken}|${UriEncoderService.encodeBase64(to)}`; const registrationLink = `${this.configService.get('APP_URL')}/?token=${token}&signup=true`; - console.log('##############'); - console.log(registrationLink); - console.log('##############'); - const mailoptions: SendGridMailApi.MailDataRequired = { to, from: { email: 'info@igor-propisnov.com', name: 'Ticket App' }, diff --git a/backend/src/modules/session/guard/session.guard.ts b/backend/src/modules/session/guard/session.guard.ts index 06e55e0..fe4712b 100644 --- a/backend/src/modules/session/guard/session.guard.ts +++ b/backend/src/modules/session/guard/session.guard.ts @@ -1,9 +1,5 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { SessionException } from 'src/shared/exceptions'; import { SessionService } from '../services/session.service'; @@ -19,20 +15,20 @@ export class SessionGuard implements CanActivate { const session = await this.sessionService.findSessionBySessionId(sessionId); if (!session) { - throw new UnauthorizedException('Session not found.'); + throw new SessionException('Session not found.'); } const isExpired = await this.sessionService.isSessioExpired(session); if (isExpired) { - throw new UnauthorizedException('Session expired.'); + throw new SessionException('Session expired.'); } const userAgentInSession = JSON.parse(session.json).passport.user .userAgent as string; if (userAgentInSession !== currentAgent) { - throw new UnauthorizedException('User agent mismatch.'); + throw new SessionException('User agent mismatch.'); } return true; diff --git a/backend/src/modules/verify-module/controller/verify.controller.ts b/backend/src/modules/verify-module/controller/verify.controller.ts index c4273e3..d1648d2 100644 --- a/backend/src/modules/verify-module/controller/verify.controller.ts +++ b/backend/src/modules/verify-module/controller/verify.controller.ts @@ -1,4 +1,11 @@ -import { Controller, HttpCode, HttpStatus, Query, Post } from '@nestjs/common'; +import { + Controller, + HttpCode, + HttpStatus, + Query, + Post, + Req, +} from '@nestjs/common'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; import { SuccessDto } from 'src/shared'; import { Public } from 'src/shared/decorator'; @@ -21,22 +28,15 @@ export class VerifyController { @HttpCode(HttpStatus.OK) public async verifyEmail( @Query('token') tokenToVerify: string, - @Query('email') emailToVerify: string + @Query('email') emailToVerify: string, + @Req() request: Request ): Promise { + const userAgent = request.headers['user-agent'] || 'Unknown'; + return this.emailVerificationService.verifyEmail( tokenToVerify, - emailToVerify + emailToVerify, + userAgent ); } - - // @ApiCreatedResponse({ - // description: 'Check if email is verified', - // type: Boolean, - // }) - // @Get('check') - // @HttpCode(HttpStatus.OK) - // @UseGuards(SessionGuard) - // public async isEmailVerified(@Req() request: Request): Promise { - // return this.emailVerificationService.isEmailVerified(request.sessionID); - // } } diff --git a/backend/src/modules/verify-module/repositories/email-verify.repository.ts b/backend/src/modules/verify-module/repositories/email-verify.repository.ts index f809161..13bf27f 100644 --- a/backend/src/modules/verify-module/repositories/email-verify.repository.ts +++ b/backend/src/modules/verify-module/repositories/email-verify.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EmailVerification } from 'src/entities'; -import { LessThan, MoreThan, Repository } from 'typeorm'; +import { LessThan, Repository } from 'typeorm'; @Injectable() export class EmailVerifyRepository { @@ -14,30 +14,17 @@ export class EmailVerifyRepository { token: string, expiresAt: Date, email: string, - userId: string | null + userId: string | null, + userAgent: string ): Promise { + await this.repository.delete({ email }); + await this.repository.save({ token, expiresAt, email, user: userId ? { id: userId } : null, - }); - } - - public async findValidVerification( - token: string, - email: string - ): Promise { - const currentDate = new Date(); - const tenMinutesAgo = new Date(currentDate.getTime() - 10 * 60 * 1000); - - return await this.repository.findOne({ - where: { - token, - email, - createdAt: MoreThan(tenMinutesAgo), - expiresAt: MoreThan(currentDate), - }, + userAgent, }); } @@ -60,12 +47,6 @@ export class EmailVerifyRepository { await this.repository.delete({ token, email }); } - public async findItemByEmail( - email: string - ): Promise { - return this.repository.findOne({ where: { email } }); - } - public async deleteAllExpiredTokens(): Promise { const currentDate = new Date(); @@ -73,20 +54,4 @@ export class EmailVerifyRepository { expiresAt: LessThan(currentDate), }); } - - public async deleteEmailVerificationByToken( - tokenToDelete: string - ): Promise { - const emailVerification = await this.repository.findOne({ - where: { token: tokenToDelete }, - relations: ['user'], - }); - - if (emailVerification) { - await this.repository.delete({ token: tokenToDelete }); - return emailVerification; - } - - return null; - } } diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index 9d5ca88..cd15c73 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -1,64 +1,25 @@ import { randomBytes } from 'crypto'; import { Injectable } from '@nestjs/common'; -import { EmailVerification } from 'src/entities'; -import { SessionService } from 'src/modules/session/services/session.service'; import { SuccessDto, UriEncoderService } from 'src/shared'; import { InternalServerErrorException, TokenExpiredException, + UserAgentMismatchException, } from 'src/shared/exceptions'; -import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { EmailVerifyRepository } from '../repositories'; @Injectable() export class EmailVerificationService { public constructor( - private readonly emailVerifyRepository: EmailVerifyRepository, - private readonly userDataRepository: UserDataRepository, - private readonly sessionService: SessionService + private readonly emailVerifyRepository: EmailVerifyRepository ) {} - public async generateEmailVerificationToken(userId: string): Promise { - try { - const verificationToken = await this.createVerificationToken(); - const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); - - await this.emailVerifyRepository.createEmailVerification( - verificationToken, - expiration, - userId, - null - ); - - return verificationToken; - } catch (error) { - throw new InternalServerErrorException( - 'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR', - { - message: - 'An error occurred while generating the email verification token.', - } - ); - } - } - - public async isEmailSubmitted(email: string): Promise { - try { - const emailVerification = - await this.emailVerifyRepository.findItemByEmail(email); - - return !!emailVerification; - } catch (error) { - throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { - message: 'An error occurred while verifying the email.', - }); - } - } - public async generateEmailVerificationTokenForMagicLink( - email: string + email: string, + userAgent: string, + userid?: string ): Promise { try { const verificationToken = await this.createVerificationToken(); @@ -68,7 +29,8 @@ export class EmailVerificationService { verificationToken, expiresAt, email, - null + userid || null, + userAgent ); return verificationToken; @@ -85,7 +47,8 @@ export class EmailVerificationService { public async verifyEmail( tokenToVerify: string, - emailToVerify: string + emailToVerify: string, + userAgent: string ): Promise { try { const token = await this.emailVerifyRepository.findByTokenAndEmail( @@ -97,6 +60,13 @@ export class EmailVerificationService { throw new TokenExpiredException(); } + if (token.userAgent !== userAgent) { + throw new UserAgentMismatchException({ + message: + 'The User Agent does not match the one used to generate the token.', + }); + } + const currentDate = new Date(); if (token.expiresAt.getTime() < currentDate.getTime()) { @@ -113,35 +83,16 @@ export class EmailVerificationService { if (error instanceof TokenExpiredException) { throw error; } + if (error instanceof UserAgentMismatchException) { + throw error; + } throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { message: 'An error occurred while verifying the email.', }); } } - public async isEmailVerified(sessionID: string): Promise { - try { - const userId = await this.sessionService.getUserIdBySessionId(sessionID); - - if (!userId) { - return false; - } - - const isVerified = - await this.userDataRepository.isEmailConfirmedByUserId(userId); - - return isVerified; - } catch (error) { - throw new InternalServerErrorException('EMAIL_VERIFICATION_CHECK_ERROR', { - message: - 'An error occurred while checking the email verification status.', - }); - } - } - - async deleteAllExpiredTokens(): Promise { - const currentDate = new Date(); - + public async deleteAllExpiredTokens(): Promise { await this.emailVerifyRepository.deleteAllExpiredTokens(); } @@ -150,12 +101,4 @@ export class EmailVerificationService { return UriEncoderService.encodeUri(verifyToken); } - - private async deleteEmailVerificationToken( - tokenToDelete: string - ): Promise { - return await this.emailVerifyRepository.deleteEmailVerificationByToken( - tokenToDelete - ); - } } diff --git a/backend/src/shared/exceptions/index.ts b/backend/src/shared/exceptions/index.ts index f1bb424..9c55f89 100644 --- a/backend/src/shared/exceptions/index.ts +++ b/backend/src/shared/exceptions/index.ts @@ -3,3 +3,5 @@ export * from './forbidden.exception'; export * from './internal-server-error.exception'; export * from './not-found.exception'; export * from './token-expired.exception'; +export * from './useragent-mismatch-exception'; +export * from './session.exception'; diff --git a/backend/src/shared/exceptions/session.exception.ts b/backend/src/shared/exceptions/session.exception.ts new file mode 100644 index 0000000..ea4872e --- /dev/null +++ b/backend/src/shared/exceptions/session.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; + +import { BaseException } from './base.exception'; + +export class SessionException extends BaseException { + public constructor(message: string, details?: unknown) { + super('Session Error', HttpStatus.UNAUTHORIZED, 'SESSION_ERROR', { + message, + details, + }); + } +} diff --git a/backend/src/shared/exceptions/useragent-mismatch-exception.ts b/backend/src/shared/exceptions/useragent-mismatch-exception.ts new file mode 100644 index 0000000..08b6e71 --- /dev/null +++ b/backend/src/shared/exceptions/useragent-mismatch-exception.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; + +import { BaseException } from './base.exception'; + +export class UserAgentMismatchException extends BaseException { + public constructor(details?: unknown) { + super( + 'User Agent Mismatch', + HttpStatus.UNAUTHORIZED, + 'USER_AGENT_MISMATCH', + details + ); + } +} diff --git a/backend/src/shared/filters/http-exception.filter.ts b/backend/src/shared/filters/http-exception.filter.ts index 7a8915d..7fce13a 100644 --- a/backend/src/shared/filters/http-exception.filter.ts +++ b/backend/src/shared/filters/http-exception.filter.ts @@ -19,7 +19,7 @@ export class HttpExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - //console.error('Exception caught:', exception); + console.error('Exception caught:', exception); let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; let message: string = 'Internal server error'; diff --git a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts index ab65b0b..09f49b6 100644 --- a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts +++ b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts @@ -24,25 +24,11 @@ export class EventEmptyStateComponent { private readonly verifyApi: VerifyApiService ) {} - // public navigateToCreateEvent(): void { - // this.verifyApi - // .verifyControllerIsEmailVerified() - // .subscribe((isVerified: boolean) => { - // if (!isVerified) { - // this.openEmailVerificationModal(); - // } else { - // this.router.navigate(['/event/create']); - // } - // }); - // } + public navigateToCreateEvent(): void { + this.router.navigate(['/event/create']); + } public closeEmailVerificationModal(): void { (this.emailVerificationModal.nativeElement as HTMLDialogElement).close(); } - - private openEmailVerificationModal(): void { - ( - this.emailVerificationModal.nativeElement as HTMLDialogElement - ).showModal(); - } } diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.html b/frontend/src/app/pages/welcome-root/welcome-root.component.html index d001089..820dd04 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.html +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.html @@ -1,646 +1,4 @@ - - - -
-
- @if (isTokenVerifing()) { + @if (displaySkeleton()) {
@@ -823,8 +181,7 @@ }
- @if (isTokenVerifing()) { - + @if (displaySkeleton()) {
@@ -888,8 +245,7 @@
- @if (isTokenVerifing()) { - + @if (displaySkeleton()) {
@@ -1012,7 +368,7 @@
} - @if (!isRegistrationMode() && !isTokenVerifing()) { + @if (!isRegistrationMode() && !displaySkeleton()) {

What happens next?

@@ -1025,7 +381,7 @@
- } @else if (isTokenVerifing()) { + } @else if (displaySkeleton()) {
@@ -1101,7 +457,7 @@
- diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.ts b/frontend/src/app/pages/welcome-root/welcome-root.component.ts index aa94260..9e8ba53 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.ts +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.ts @@ -1,404 +1,3 @@ -/* import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; -import { - ChangeDetectionStrategy, - Component, - OnInit, - WritableSignal, - signal, - effect, - InputSignal, - input, - ElementRef, -} from '@angular/core'; -import { - FormBuilder, - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { Router } from '@angular/router'; - -import { ButtonModule } from 'primeng/button'; -import { CheckboxModule } from 'primeng/checkbox'; -import { InputTextModule } from 'primeng/inputtext'; -import { PasswordModule } from 'primeng/password'; -import { delay, finalize, takeWhile, tap } from 'rxjs'; - -import { - Configuration, - SigninResponseDtoApiModel, - SuccessDtoApiModel, - UserCredentialsDtoApiModel, -} from '../../api'; -import { ApiConfiguration } from '../../config/api-configuration'; -import { - AuthService, - BackgroundPatternService, - ThemeService, -} from '../../shared/service'; -import { LocalStorageService } from '../../shared/service/local-storage.service'; -import { - customEmailValidator, - customPasswordValidator, -} from '../../shared/validator'; - -type AuthAction = 'signin' | 'signup'; - -@Component({ - selector: 'app-register-root', - standalone: true, - imports: [ - CommonModule, - FormsModule, - InputTextModule, - ReactiveFormsModule, - ButtonModule, - CheckboxModule, - PasswordModule, - HttpClientModule, - ], - providers: [ - { - provide: Configuration, - useFactory: (): unknown => - new ApiConfiguration({ withCredentials: true }), - }, - ], - templateUrl: './welcome-root.component.html', - styleUrl: './welcome-root.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class WelcomeRootComponent implements OnInit { - public dialogBackgroundStyle: { 'background-image': string } | null = null; - public leftBackgroundStyle: { 'background-image': string } | null = null; - public rightBackgroundStyle: { 'background-image': string } | null = null; - public verified: InputSignal = input(false); - public login: InputSignal = input(false); - public email: InputSignal = input(''); - public signedOut: InputSignal = input(true); - public form!: FormGroup; - public rememberMe: FormControl = new FormControl(false); - public isSigninSignal: WritableSignal = signal(false); - public isSignupSignal: WritableSignal = signal(true); - public isSignUpSuccess: WritableSignal = signal(false); - public userSignupSuccess: WritableSignal = signal(false); - public isDialogOpen: WritableSignal = signal(false); - public isLoading: WritableSignal = signal(false); - public displaySkeleton: WritableSignal = signal(true); - private removeQueryParams: WritableSignal = signal(false); - - public get isDarkMode(): boolean { - return this.themeService.getTheme() === 'dark'; - } - - public constructor( - private readonly formBuilder: FormBuilder, - private readonly authService: AuthService, - private readonly router: Router, - private readonly themeService: ThemeService, - private readonly el: ElementRef, - private readonly backgroundPatternService: BackgroundPatternService, - private readonly localStorageService: LocalStorageService - ) { - effect(() => { - if (this.removeQueryParams()) { - this.clearRouteParams(); - } - }); - } - - public ngOnInit(): void { - this.autologin(); - this.setBackground(); - this.initializeForm(); - this.setupValueChanges(); - - if ((this.email() && this.verified()) || this.login()) { - this.handleRedirect(); - this.removeQueryParams.set(true); - } - } - - public autologin(): void { - const rememberMe = this.localStorageService.getItem('remember-me'); - - if (rememberMe && !this.signedOut()) { - this.authService - .status() - .pipe( - delay(1500), - takeWhile((response: SuccessDtoApiModel) => response.success, true), - tap({ - next: (response: SuccessDtoApiModel) => { - if (response.success) { - this.router.navigate(['/dashboard']); - } - }, - finalize: () => this.displaySkeleton.set(false), - }) - ) - .subscribe(); - } else { - this.displaySkeleton.set(false); - } - } - - public setBackground(): void { - const theme = this.themeService.getTheme(); - let opacity: number; - - if (theme === 'dark') { - opacity = 0.05; - } else { - opacity = 0.1; - } - - const colorPrimary = getComputedStyle( - this.el.nativeElement - ).getPropertyValue('--p'); - - const colorPrimaryC = getComputedStyle( - this.el.nativeElement - ).getPropertyValue('--pc'); - - const svgUrlforDialog = this.backgroundPatternService.getWigglePattern( - colorPrimary, - opacity - ); - const svgUrlForLeft = this.backgroundPatternService.getBankNotePattern( - colorPrimaryC, - opacity - ); - const svgUrlForRight = this.backgroundPatternService.getHideoutPattern( - colorPrimary, - opacity - ); - - this.dialogBackgroundStyle = { - 'background-image': `url("${svgUrlforDialog}")`, - }; - this.leftBackgroundStyle = { - 'background-image': `url("${svgUrlForLeft}")`, - }; - this.rightBackgroundStyle = { - 'background-image': `url("${svgUrlForRight}")`, - }; - } - - public openModal(): void { - this.isDialogOpen.set(true); - } - - public closeModal(): void { - this.isDialogOpen.set(false); - } - - public toggleTheme(): void { - this.themeService.toggleTheme(); - this.setBackground(); - } - - public toggleAction(action: AuthAction): void { - this.resetFormValidation(); - - if (action === 'signin') { - this.handlePreselect(); - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } else { - this.isSigninSignal.set(false); - this.isSignupSignal.set(true); - } - } - - public onSubmit(): void { - this.markControlsAsTouchedAndDirty(['email', 'password']); - - if (this.form?.valid) { - if (this.isSigninSignal()) { - this.signin(this.form.value); - } else { - this.signup(this.form.value); - } - } - } - - private handlePreselect(): void { - const rememberMe = this.localStorageService.getItem('remember-me'); - const email = this.localStorageService.getItem('email'); - - if (rememberMe) { - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } - - if (email) { - this.form?.get('email')?.setValue(email); - } - - this.rememberMe.setValue(rememberMe); - } - - private initializeForm(): void { - const rememberMeValue = - this.localStorageService.getItem('remember-me'); - const email = this.localStorageService.getItem('email'); - - if (rememberMeValue) { - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } - - const emailValue = rememberMeValue && email ? email : ''; - - this.form = this.formBuilder.group({ - email: new FormControl(emailValue, { - validators: [Validators.required, customEmailValidator()], - updateOn: 'change', - }), - password: new FormControl('', { - validators: [Validators.required, customPasswordValidator()], - updateOn: 'change', - }), - }); - - this.rememberMe.setValue(rememberMeValue); - } - - private handleRedirect(): void { - if (this.verified()) { - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } - if (this.email()) { - this.form?.get('email')?.setValue(decodeURIComponent(atob(this.email()))); - } - - if (this.login()) { - this.isSignupSignal.set(true); - this.isSigninSignal.set(false); - } - } - - private clearRouteParams(): void { - this.router.navigate([], { queryParams: {} }); - } - - private setupValueChanges(): void { - this.setupEmailValueChanges(); - this.setupPasswordValueChanges(); - } - - private setupEmailValueChanges(): void { - const emailControl = this.form?.get('email'); - - emailControl?.valueChanges.subscribe((value: string) => { - if (value?.length >= 4) { - emailControl.setValidators([ - Validators.required, - customEmailValidator(), - ]); - } else { - emailControl.setValidators([ - Validators.required, - Validators.minLength(4), - ]); - } - emailControl.updateValueAndValidity({ emitEvent: false }); - }); - } - - private setupPasswordValueChanges(): void { - const passwordControl = this.form?.get('password'); - - passwordControl?.valueChanges.subscribe((value: string) => { - if (value?.length >= 8) { - passwordControl.setValidators([ - Validators.required, - customPasswordValidator(), - ]); - } else { - passwordControl.setValidators([ - Validators.required, - Validators.minLength(8), - ]); - } - passwordControl.updateValueAndValidity({ emitEvent: false }); - }); - } - - private markControlsAsTouchedAndDirty(controlNames: string[]): void { - controlNames.forEach((controlName: string) => { - const control = this.form?.get(controlName); - - if (control) { - control.markAsTouched(); - control.markAsDirty(); - control.updateValueAndValidity(); - } - }); - } - - private resetFormValidation(): void { - ['email', 'password'].forEach((controlName: string) => { - this.resetControlValidation(controlName); - }); - } - - private resetControlValidation(controlName: string): void { - const control = this.form?.get(controlName); - - if (control) { - control.reset(); - control.markAsPristine(); - control.markAsUntouched(); - control.updateValueAndValidity(); - } - } - - private signin(logiCredentials: UserCredentialsDtoApiModel): void { - const rememberMe: boolean = this.rememberMe.value; - - if (rememberMe) { - this.localStorageService.setItem('email', logiCredentials.email); - this.localStorageService.setItem('remember-me', rememberMe); - } - - this.authService - .signin(logiCredentials) - .pipe( - tap(() => this.isLoading.set(true)), - delay(1000), - finalize(() => this.isLoading.set(false)) - ) - .subscribe((response: SigninResponseDtoApiModel) => { - if (response) { - this.router.navigate(['/dashboard']); - } - }); - } - - private signup(logiCredentials: UserCredentialsDtoApiModel): void { - this.isLoading.set(true); - this.authService - .signup(logiCredentials) - .pipe( - delay(1000), - tap(() => this.isLoading.set(true)), - finalize(() => this.isLoading.set(false)) - ) - .subscribe((response: SuccessDtoApiModel) => { - if (response.success) { - this.openModal(); - this.userSignupSuccess.set(true); - } - }); - } -} - */ import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { @@ -425,11 +24,12 @@ import { Router } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; -import { delay, finalize, tap, timer } from 'rxjs'; +import { delay, finalize, switchMap, takeWhile, tap, timer } from 'rxjs'; import { Configuration, MagicLinkDtoApiModel, + SigninResponseDtoApiModel, SuccessDtoApiModel, UserCredentialsDtoApiModel, VerifyApiService, @@ -467,20 +67,24 @@ import { customEmailValidator } from '../../shared/validator'; export class WelcomeRootComponent implements OnInit { @ViewChild('passwordInput') public passwordInput!: ElementRef; public token: InputSignal = input(''); + public signedOut: InputSignal = input(false); public signup: InputSignal = input(false); + public signin: InputSignal = input(false); public dialogBackgroundStyle: { 'background-image': string } | null = null; public leftBackgroundStyle: { 'background-image': string } | null = null; public rightBackgroundStyle: { 'background-image': string } | null = null; public form!: FormGroup; public isLoading: WritableSignal = signal(false); public isEmailSent: WritableSignal = signal(false); - public isTokenVerifing: WritableSignal = signal(false); + public displaySkeleton: WritableSignal = signal(false); + public isVerifying: WritableSignal = signal(false); public isTokenVerified: WritableSignal = signal(false); public errorReasons: WritableSignal = signal([]); public verificationError: WritableSignal = signal< string | null >(null); public isRegistrationMode: WritableSignal = signal(false); + public isAutoLoginInProgress: WritableSignal = signal(false); private removeQueryParams: WritableSignal = signal(false); public get isDarkMode(): boolean { @@ -498,89 +102,54 @@ export class WelcomeRootComponent implements OnInit { ) { effect(() => { if (this.removeQueryParams()) { - this.clearRouteParams(); + //this.clearRouteParams(); } }); } public ngOnInit(): void { + this.autologin(); this.setBackground(); this.initializeForm(); this.verifySignupMagicLink(); + this.verifySigninMagicLink(); + } + + public autologin(): void { + if ( + !this.token() && + (!this.signin() || !this.signup()) && + !this.signedOut() + ) { + this.isAutoLoginInProgress.set(true); + this.displaySkeleton.set(true); + + timer(2000) + .pipe( + switchMap(() => this.authService.status()), + takeWhile((response: SuccessDtoApiModel) => response.success, true), + tap({ + next: (response: SuccessDtoApiModel) => { + if (response.success) { + this.router.navigate(['/dashboard']); + } + }, + }), + finalize(() => { + this.isAutoLoginInProgress.set(false); + this.displaySkeleton.set(false); + }) + ) + .subscribe(); + } + } + + public verifySigninMagicLink(): void { + this.verifyMagicLink(false); } public verifySignupMagicLink(): void { - if (this.token() && this.signup()) { - const token: string = this.extractVerifyToken(); - const email: string = this.extractEmail(); - - this.removeQueryParams.set(true); - - if (token && email) { - this.isTokenVerifing.set(true); - this.verificationError.set(null); - this.errorReasons.set([]); - this.addPasswordFieldToForm(); - this.isRegistrationMode.set(true); - const decodedEmail = decodeURIComponent(atob(email)); - - this.verifyApiService - .verifyControllerVerifyEmail(token, decodedEmail) - .pipe( - delay(2000), - finalize(() => { - if (!this.verificationError()) { - this.isTokenVerifing.set(false); - this.isTokenVerified.set(true); - // Warte 3 Sekunden, dann schließe das Modal und fokussiere das Passwort-Feld - timer(3000).subscribe(() => { - this.isTokenVerified.set(false); - this.focusPasswordField(); - }); - } - }) - ) - .subscribe({ - next: (response: SuccessDtoApiModel) => { - if (response.success) { - this.isTokenVerifing.set(false); - console.log('Verification successful'); - } else { - console.error('Verification failed'); - this.verificationError.set( - 'Verification failed. Please check the reasons below:' - ); - this.errorReasons.set([ - 'The verification token may have expired.', - 'The email address may not match our records.', - 'The verification link may have been used already.', - ]); - } - }, - error: (error) => { - console.error('Verification failed', error); - this.verificationError.set( - 'An error occurred during verification. Please check the reasons below:' - ); - this.errorReasons.set([ - 'There might be a problem with your internet connection.', - 'Our servers might be experiencing issues.', - 'The verification service might be temporarily unavailable.', - ]); - }, - }); - - this.form.patchValue({ email: decodedEmail }); - const emailControl = this.form.get('email'); - - if (emailControl) { - emailControl.setValue(decodedEmail); - emailControl.disable(); - emailControl.markAsTouched(); - emailControl.setErrors(null); - } - } - } + this.verifyMagicLink(true); } public getInputClass(controlName: string): string { @@ -669,8 +238,8 @@ export class WelcomeRootComponent implements OnInit { if (this.isRegistrationMode()) { const signupCredentials: UserCredentialsDtoApiModel = { - email: this.form.value.email, - password: this.form.value.password, + email: this.form.getRawValue().email.trim(), + password: this.form.getRawValue().password.trim(), }; this.signupNewUser(signupCredentials); @@ -685,6 +254,127 @@ export class WelcomeRootComponent implements OnInit { } } + private verifyMagicLink(isSignup: boolean): void { + if (this.token() && (isSignup ? this.signup() : this.signin())) { + const token: string = this.extractVerifyToken(); + const email: string = this.extractEmail(); + const decodedEmail: string = decodeURIComponent(atob(email)); + + if (token && email) { + if (isSignup) { + this.setupEmailField(decodedEmail); + this.removeQueryParams.set(true); + this.addPasswordFieldToForm(); + this.isRegistrationMode.set(true); + } + + this.isVerifying.set(true); + this.verificationError.set(null); + this.errorReasons.set([]); + + timer(2500) + .pipe( + tap(() => { + this.isVerifying.set(false); + }), + switchMap(() => + this.verifyApiService.verifyControllerVerifyEmail( + token, + decodedEmail + ) + ), + tap((response: SuccessDtoApiModel) => { + if (response.success) { + this.isTokenVerified.set(true); + } + }), + delay(1000), + finalize(() => this.handleVerificationFinalize()) + ) + .subscribe({ + next: (response: SuccessDtoApiModel) => + this.handleVerificationResponse( + response, + isSignup, + decodedEmail, + token + ), + error: () => this.handleVerificationError(), + }); + } + } + } + + private handleVerificationFinalize(): void { + if (!this.verificationError()) { + this.displaySkeleton.set(false); + this.isTokenVerified.set(true); + timer(2000).subscribe(() => { + this.isTokenVerified.set(false); + this.focusPasswordField(); + }); + } + } + + private handleVerificationResponse( + response: SuccessDtoApiModel, + isSignup: boolean, + email: string, + token: string + ): void { + if (response.success) { + this.displaySkeleton.set(false); + this.isTokenVerified.set(true); + if (!isSignup) { + timer(2000).subscribe(() => { + this.authService + .signinMagicLink({ email, token }) + .subscribe((response: SigninResponseDtoApiModel) => { + if (response) { + this.router.navigate(['/dashboard']); + } + }); + }); + } + } else { + this.handleVerificationFailure( + 'Verification failed. Please check the reasons below:' + ); + } + } + + private handleVerificationError(): void { + this.isVerifying.set(false); + this.handleVerificationFailure( + 'An error occurred during verification. Please check the reasons below:' + ); + } + + private handleVerificationFailure(message: string): void { + this.verificationError.set(message); + this.errorReasons.set([ + 'The verification token may have expired.', + 'The device you are using may not match the one used to generate the token.', + 'The email address may not match our records.', + 'The verification link may have been used already.', + 'There might be a problem with your internet connection.', + 'Our servers might be experiencing issues.', + 'The verification service might be temporarily unavailable.', + ]); + } + + private setupEmailField(email: string): void { + this.form.patchValue({ email }); + this.form.get('email')?.setValue(email); + const emailControl = this.form.get('email'); + + if (emailControl) { + emailControl.disable({ onlySelf: true, emitEvent: false }); + emailControl.markAsTouched(); + emailControl.setErrors(null); + } + } + private clearRouteParams(): void { this.router.navigate([], { queryParams: {} }); } @@ -703,13 +393,10 @@ export class WelcomeRootComponent implements OnInit { private initializeForm(): void { this.form = this.formBuilder.group({ - email: new FormControl( - { value: '', disabled: false }, - { - validators: [Validators.required, customEmailValidator()], - updateOn: 'change', - } - ), + email: new FormControl('', { + validators: [Validators.required, customEmailValidator()], + updateOn: 'change', + }), }); } @@ -734,8 +421,7 @@ export class WelcomeRootComponent implements OnInit { ) .subscribe((response: SuccessDtoApiModel) => { if (response.success) { - console.log('User signed up successfully'); - // TODO: Redirect to Dashbord + // Display Modal // You have successfully signed up. Please check your email for the magic link. } }); } diff --git a/frontend/src/app/shared/service/auth.service.ts b/frontend/src/app/shared/service/auth.service.ts index c0ba5f4..5519be5 100644 --- a/frontend/src/app/shared/service/auth.service.ts +++ b/frontend/src/app/shared/service/auth.service.ts @@ -7,6 +7,7 @@ import { catchError, shareReplay, tap } from 'rxjs/operators'; import { AuthenticationApiService, MagicLinkDtoApiModel, + MagicLinkSigninDtoApiModel, SigninResponseDtoApiModel, SuccessDtoApiModel, UserCredentialsDtoApiModel, @@ -28,6 +29,14 @@ export class AuthService { this.statusCheck$ = this.initializeStatusCheck(); } + public signinMagicLink( + credentials: MagicLinkSigninDtoApiModel + ): Observable { + return this.authenticationApiService + .authControllerMagicLinkSignin(credentials) + .pipe(tap(() => this.isAuthenticatedSignal.set(true))); + } + public sendMagicLink( email: MagicLinkDtoApiModel ): Observable { @@ -42,13 +51,13 @@ export class AuthService { .pipe(tap(() => this.isAuthenticatedSignal.set(true))); } - public signin( - credentials: UserCredentialsDtoApiModel - ): Observable { - return this.authenticationApiService - .authControllerSignin(credentials) - .pipe(tap(() => this.isAuthenticatedSignal.set(true))); - } + // public signin( + // credentials: UserCredentialsDtoApiModel + // ): Observable { + // return this.authenticationApiService + // .authControllerSignin(credentials) + // .pipe(tap(() => this.isAuthenticatedSignal.set(true))); + // } public signout(): Observable { return this.authenticationApiService From 1532daa061682421a6fb2302600938ca5d132d33 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 14:51:50 +0200 Subject: [PATCH 05/26] prefil email --- .../welcome-root/welcome-root.component.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.ts b/frontend/src/app/pages/welcome-root/welcome-root.component.ts index 9e8ba53..a864200 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.ts +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.ts @@ -42,6 +42,8 @@ import { } from '../../shared/service'; import { customEmailValidator } from '../../shared/validator'; +import { LocalStorageService } from './../../shared/service/local-storage.service'; + @Component({ selector: 'app-unified-login', standalone: true, @@ -98,11 +100,12 @@ export class WelcomeRootComponent implements OnInit { private readonly router: Router, private readonly themeService: ThemeService, private readonly el: ElementRef, - private readonly backgroundPatternService: BackgroundPatternService + private readonly backgroundPatternService: BackgroundPatternService, + private readonly localStorageService: LocalStorageService ) { effect(() => { if (this.removeQueryParams()) { - //this.clearRouteParams(); + this.clearRouteParams(); } }); } @@ -111,6 +114,7 @@ export class WelcomeRootComponent implements OnInit { this.autologin(); this.setBackground(); this.initializeForm(); + this.prefillEmail(); this.verifySignupMagicLink(); this.verifySigninMagicLink(); } @@ -260,10 +264,11 @@ export class WelcomeRootComponent implements OnInit { const email: string = this.extractEmail(); const decodedEmail: string = decodeURIComponent(atob(email)); + this.removeQueryParams.set(true); + if (token && email) { if (isSignup) { this.setupEmailField(decodedEmail); - this.removeQueryParams.set(true); this.addPasswordFieldToForm(); this.isRegistrationMode.set(true); } @@ -421,11 +426,24 @@ export class WelcomeRootComponent implements OnInit { ) .subscribe((response: SuccessDtoApiModel) => { if (response.success) { + this.remeberUserMail(signupCredentials.email); // Display Modal // You have successfully signed up. Please check your email for the magic link. } }); } + private prefillEmail(): void { + const email = this.localStorageService.getItem('email'); + + if (email) { + this.form.get('email')?.setValue(email); + } + } + + private remeberUserMail(email: string): void { + this.localStorageService.setItem('email', email); + } + private sendLoginEmail(email: string): void { this.isLoading.set(true); const magiclink: MagicLinkDtoApiModel = { @@ -442,6 +460,7 @@ export class WelcomeRootComponent implements OnInit { .subscribe((response: SuccessDtoApiModel) => { if (response.success) { this.isEmailSent.set(true); + this.remeberUserMail(email); } }); } From 9b87258d2d8566d9481ec38032483211a96289f7 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 15:58:53 +0200 Subject: [PATCH 06/26] w.i.p --- .../auth-module/controller/auth.controller.ts | 7 ++- .../auth-module/services/auth.service.ts | 11 ++-- .../welcome-root/welcome-root.component.html | 58 ++++++++++++++++++- .../welcome-root/welcome-root.component.ts | 20 ++++--- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index 182440e..d00da2f 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -53,9 +53,12 @@ export class AuthController { @HttpCode(HttpStatus.CREATED) @Public() public async signup( - @Body() userCredentials: UserCredentialsDto + @Body() userCredentials: UserCredentialsDto, + @Req() request: Request ): Promise { - return this.authService.signup(userCredentials); + const userAgent = request.headers['user-agent'] || 'Unknown'; + + return this.authService.signup(userCredentials, userAgent); } @ApiCreatedResponse({ diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index a4b9f78..23886fb 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -78,7 +78,8 @@ export class AuthService { } public async signup( - userCredentials: UserCredentialsDto + userCredentials: UserCredentialsDto, + userAgent: string ): Promise { try { const existingUser = await this.userCredentialsRepository.findUserByEmail( @@ -98,13 +99,9 @@ export class AuthService { passwordHashed ); - await this.userDataRepository.createInitialUserData(user); + await this.sendMagicLink({ email: user.email }, userAgent); - // TODO: Send Welcome Mail - // const token = - // await this.emailVerificationService.generateEmailVerificationToken( - // user.id - // ); + await this.userDataRepository.createInitialUserData(user); return { success: true, diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.html b/frontend/src/app/pages/welcome-root/welcome-root.component.html index 820dd04..8dd92a7 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.html +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.html @@ -422,7 +422,7 @@
@@ -549,3 +549,59 @@
+ + + +
+
+ +
+
diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.ts b/frontend/src/app/pages/welcome-root/welcome-root.component.ts index a864200..c435ae8 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.ts +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.ts @@ -24,7 +24,7 @@ import { Router } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; -import { delay, finalize, switchMap, takeWhile, tap, timer } from 'rxjs'; +import { delay, finalize, of, switchMap, takeWhile, tap, timer } from 'rxjs'; import { Configuration, @@ -80,6 +80,7 @@ export class WelcomeRootComponent implements OnInit { public isEmailSent: WritableSignal = signal(false); public displaySkeleton: WritableSignal = signal(false); public isVerifying: WritableSignal = signal(false); + public isUserSignupSuccessfully: WritableSignal = signal(false); public isTokenVerified: WritableSignal = signal(false); public errorReasons: WritableSignal = signal([]); public verificationError: WritableSignal = signal< @@ -132,16 +133,17 @@ export class WelcomeRootComponent implements OnInit { .pipe( switchMap(() => this.authService.status()), takeWhile((response: SuccessDtoApiModel) => response.success, true), - tap({ - next: (response: SuccessDtoApiModel) => { - if (response.success) { - this.router.navigate(['/dashboard']); - } - }, + switchMap((response: SuccessDtoApiModel) => { + if (response.success) { + return this.router.navigate(['/dashboard']).then(() => response); + } + return of(response); }), finalize(() => { this.isAutoLoginInProgress.set(false); - this.displaySkeleton.set(false); + setTimeout(() => { + this.displaySkeleton.set(false); + }, 100); }) ) .subscribe(); @@ -427,7 +429,7 @@ export class WelcomeRootComponent implements OnInit { .subscribe((response: SuccessDtoApiModel) => { if (response.success) { this.remeberUserMail(signupCredentials.email); - // Display Modal // You have successfully signed up. Please check your email for the magic link. + this.isUserSignupSuccessfully.set(true); } }); } From 53311438aef3379c640714a5f642f4bac32b76d3 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 16:56:12 +0200 Subject: [PATCH 07/26] improve login --- .../src/cron/clear-expired-sesstions.cron.ts | 2 +- .../auth-module/strategies/local.strategy.ts | 5 +++ .../services/email-verification.service.ts | 17 +++++--- .../welcome-root/welcome-root.component.html | 41 +++++++++++++------ .../welcome-root/welcome-root.component.ts | 4 ++ .../src/app/shared/service/theme.service.ts | 2 +- frontend/tailwind.config.js | 4 +- 7 files changed, 52 insertions(+), 23 deletions(-) diff --git a/backend/src/cron/clear-expired-sesstions.cron.ts b/backend/src/cron/clear-expired-sesstions.cron.ts index 6cc0e4a..5c4b4e1 100644 --- a/backend/src/cron/clear-expired-sesstions.cron.ts +++ b/backend/src/cron/clear-expired-sesstions.cron.ts @@ -22,7 +22,7 @@ export class ClearExpiredSessionsCron { this.logger.log('-------------------------------------------'); } - @Cron(CronExpression.EVERY_10_MINUTES, { + @Cron(CronExpression.EVERY_5_MINUTES, { name: 'Clear-Expired-Tokens', timeZone: 'Europe/Berlin', }) diff --git a/backend/src/modules/auth-module/strategies/local.strategy.ts b/backend/src/modules/auth-module/strategies/local.strategy.ts index fac77af..66bc285 100644 --- a/backend/src/modules/auth-module/strategies/local.strategy.ts +++ b/backend/src/modules/auth-module/strategies/local.strategy.ts @@ -34,6 +34,11 @@ export class LocalStrategy extends PassportStrategy(Strategy) { request.headers['user-agent'] ); + this.emailVerificationService.removeEmailVerificationByTokenAndEmail( + token as string, + email as string + ); + if (!verificationResult.success) { throw new UnauthorizedException('Invalid or expired token'); } diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index cd15c73..3fbf474 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -23,7 +23,7 @@ export class EmailVerificationService { ): Promise { try { const verificationToken = await this.createVerificationToken(); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); await this.emailVerifyRepository.createEmailVerification( verificationToken, @@ -73,11 +73,6 @@ export class EmailVerificationService { throw new TokenExpiredException(); } - await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail( - tokenToVerify, - emailToVerify - ); - return { success: true }; } catch (error) { if (error instanceof TokenExpiredException) { @@ -92,6 +87,16 @@ export class EmailVerificationService { } } + public async removeEmailVerificationByTokenAndEmail( + token: string, + email: string + ): Promise { + await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail( + token, + email + ); + } + public async deleteAllExpiredTokens(): Promise { await this.emailVerifyRepository.deleteAllExpiredTokens(); } diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.html b/frontend/src/app/pages/welcome-root/welcome-root.component.html index 8dd92a7..eca24b6 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.html +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.html @@ -457,7 +457,12 @@