From fba474bd7c8cea9c714cb78b83226dc7b073d47c Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Wed, 29 May 2024 19:53:15 +0200 Subject: [PATCH] send verify mail to user and handle db logic --- .../src/modules/auth-module/auth.module.ts | 2 - .../auth-module/common/decorators/index.ts | 1 - .../auth-module/controller/auth.controller.ts | 3 +- .../auth-module/services/auth.service.ts | 12 ++-- .../password-confirmation.mail.service.ts | 11 ++-- .../repositories/user-data.repository.ts | 7 +++ .../controller/verify.controller.ts | 26 ++++++++ .../repositories/email-verify.repository.ts | 26 +++++++- .../services/email-verification.service.ts | 50 ++++++++++++++-- .../modules/verify-module/verify.module.ts | 11 +++- backend/src/shared/decorator/index.ts | 1 + .../decorator}/public.decorator.ts | 0 backend/src/shared/index.ts | 2 + .../utils}/encryption.service.ts | 11 ++-- backend/src/shared/utils/index.ts | 2 + .../src/shared/utils/uri-encoder.service.ts | 13 ++++ frontend/src/app/app.routes.ts | 7 +++ .../email-verify-root.component.html | 16 +++++ .../email-verify-root.component.scss | 28 +++++++++ .../email-verify-root.component.ts | 60 +++++++++++++++++++ .../register-root/register-root.component.ts | 34 ++++++++++- .../src/app/shared/service/auth.service.ts | 3 +- 22 files changed, 297 insertions(+), 29 deletions(-) create mode 100644 backend/src/modules/verify-module/controller/verify.controller.ts create mode 100644 backend/src/shared/decorator/index.ts rename backend/src/{modules/auth-module/common/decorators => shared/decorator}/public.decorator.ts (100%) create mode 100644 backend/src/shared/index.ts rename backend/src/{modules/auth-module/services => shared/utils}/encryption.service.ts (57%) create mode 100644 backend/src/shared/utils/index.ts create mode 100644 backend/src/shared/utils/uri-encoder.service.ts create mode 100644 frontend/src/app/pages/email-verify-root/email-verify-root.component.html create mode 100644 frontend/src/app/pages/email-verify-root/email-verify-root.component.scss create mode 100644 frontend/src/app/pages/email-verify-root/email-verify-root.component.ts diff --git a/backend/src/modules/auth-module/auth.module.ts b/backend/src/modules/auth-module/auth.module.ts index 5688f34..5a1dccb 100644 --- a/backend/src/modules/auth-module/auth.module.ts +++ b/backend/src/modules/auth-module/auth.module.ts @@ -10,7 +10,6 @@ import { VerifyModule } from '../verify-module/verify.module'; import { AuthController } from './controller/auth.controller'; import { UserCredentialsRepository } from './repositories/user-credentials.repository'; import { AuthService } from './services/auth.service'; -import { EncryptionService } from './services/encryption.service'; import { TokenManagementService } from './services/token-management.service'; import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; @@ -25,7 +24,6 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; providers: [ AuthService, TokenManagementService, - EncryptionService, UserCredentialsRepository, AccessTokenStrategy, RefreshTokenStrategy, diff --git a/backend/src/modules/auth-module/common/decorators/index.ts b/backend/src/modules/auth-module/common/decorators/index.ts index 61c88bb..2a36025 100644 --- a/backend/src/modules/auth-module/common/decorators/index.ts +++ b/backend/src/modules/auth-module/common/decorators/index.ts @@ -1,3 +1,2 @@ export * from './get-user-id.decorator'; export * from './get-user.decorator'; -export * from './public.decorator'; diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index b52e273..bc48206 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -7,8 +7,9 @@ import { UseGuards, } from '@nestjs/common'; import { ApiCreatedResponse, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/shared/decorator'; -import { GetCurrentUser, GetCurrentUserId, Public } from '../common/decorators'; +import { GetCurrentUser, GetCurrentUserId } from '../common/decorators'; import { RefreshTokenGuard } from '../common/guards'; import { TokensDto, UserCredentialsDto } from '../models/dto'; import { AuthService } from '../services/auth.service'; diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index 5bc70eb..414ecca 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -1,4 +1,5 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; +import { EncryptionService } from 'src/shared'; import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; @@ -6,7 +7,6 @@ import { EmailVerificationService } from '../../verify-module/services/email-ver import { TokensDto, UserCredentialsDto } from '../models/dto'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; -import { EncryptionService } from './encryption.service'; import { TokenManagementService } from './token-management.service'; @Injectable() @@ -15,15 +15,15 @@ export class AuthService { private readonly userCredentialsRepository: UserCredentialsRepository, private readonly userDataRepository: UserDataRepository, private readonly tokenManagementService: TokenManagementService, - private readonly encryptionService: EncryptionService, private readonly passwordConfirmationMailService: PasswordConfirmationMailService, private readonly emailVerificationService: EmailVerificationService ) {} public async signup(userCredentials: UserCredentialsDto): Promise { - const passwordHashed = await this.encryptionService.hashData( + const passwordHashed = await EncryptionService.hashData( userCredentials.password ); + const user = await this.userCredentialsRepository.createUser( userCredentials.email, passwordHashed @@ -53,7 +53,7 @@ export class AuthService { throw new ForbiddenException('Access Denied'); } - const passwordMatch = await this.encryptionService.compareHash( + const passwordMatch = await EncryptionService.compareHash( userCredentials.password, user.hash ); @@ -75,7 +75,7 @@ export class AuthService { throw new ForbiddenException('Access Denied'); } - const refreshTokenMatch = await this.encryptionService.compareHash( + const refreshTokenMatch = await EncryptionService.compareHash( refreshToken, user.hashedRt ); @@ -104,7 +104,7 @@ export class AuthService { userId, email ); - const hashedRefreshToken = await this.encryptionService.hashData( + const hashedRefreshToken = await EncryptionService.hashData( tokens.refresh_token ); 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 de78836..1f19474 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 @@ -1,5 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import * as SendGridMailApi from '@sendgrid/mail'; +import { UriEncoderService } from 'src/shared'; import { BaseMailService } from './base.mail.service'; import { TemplateConfigService } from './template-config.service'; @@ -11,20 +13,21 @@ export class PasswordConfirmationMailService extends BaseMailService { public constructor( @Inject('SEND_GRID_API_KEY') protected readonly sendGridApiKey: string, - private readonly templateConfigService: TemplateConfigService + private readonly templateConfigService: TemplateConfigService, + private readonly configService: ConfigService ) { super(sendGridApiKey); } public async sendPasswordConfirmationMail( to: string, - token: string + verificationToken: string ): Promise { const templateId: string = this.templateConfigService.getTemplateId( this.PASSWORD_CONFIRMATION_EMAIL ); - const encodedToken = encodeURIComponent(token); + const token = `${verificationToken}|${UriEncoderService.encodeBase64(to)}`; const mailoptions: SendGridMailApi.MailDataRequired = { to, @@ -32,7 +35,7 @@ export class PasswordConfirmationMailService extends BaseMailService { templateId: templateId, dynamicTemplateData: { name: 'Mara', - buttonUrl: `http://localhost:4200/?token=${encodedToken}`, + buttonUrl: `${this.configService.get('APP_URL')}/verify/?token=${token}`, }, }; diff --git a/backend/src/modules/user-module/repositories/user-data.repository.ts b/backend/src/modules/user-module/repositories/user-data.repository.ts index d8a57b1..767a91d 100644 --- a/backend/src/modules/user-module/repositories/user-data.repository.ts +++ b/backend/src/modules/user-module/repositories/user-data.repository.ts @@ -20,4 +20,11 @@ export class UserDataRepository { return this.repository.save(userData); } + + public async updateEmailVerificationStatus(userId: string): Promise { + await this.repository.update( + { user: { id: userId } }, + { isEmailConfirmed: true } + ); + } } diff --git a/backend/src/modules/verify-module/controller/verify.controller.ts b/backend/src/modules/verify-module/controller/verify.controller.ts new file mode 100644 index 0000000..76f9180 --- /dev/null +++ b/backend/src/modules/verify-module/controller/verify.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/shared/decorator'; + +import { EmailVerificationService } from '../services/email-verification.service'; + +@ApiTags('Verify') +@Controller('verify') +export class VerifyController { + public constructor( + private readonly emailVerificationService: EmailVerificationService + ) {} + + @ApiCreatedResponse({ + description: 'Email verified successfully', + type: Boolean, + }) + @Public() + @Get() + @HttpCode(HttpStatus.OK) + public async verifyEmail( + @Query('token') tokenToVerify: string + ): Promise { + return this.emailVerificationService.verifyEmail(tokenToVerify); + } +} 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 5fd075e..9c540c8 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 { MoreThan, Repository } from 'typeorm'; @Injectable() export class EmailVerifyRepository { @@ -21,4 +21,28 @@ export class EmailVerifyRepository { user: { id: userId }, }); } + + public async findEmailVerificationByToken(token: string): Promise { + const result = await this.repository.findOne({ + where: { token, expiresAt: MoreThan(new Date()) }, + }); + + return result !== null; + } + + 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 80b93e1..272b065 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -1,27 +1,69 @@ import { randomBytes } from 'crypto'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EmailVerification } from 'src/entities'; +import { UriEncoderService } from 'src/shared'; +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 emailVerifyRepository: EmailVerifyRepository, + private readonly userDataRepository: UserDataRepository, + private readonly configService: ConfigService ) {} public async generateEmailVerificationToken(userId: string): Promise { - const token = randomBytes(32).toString('hex'); + const verificationToken = await this.createVerificationToken(); // TODO Check users local time zone and set expiration time accordingly const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); this.emailVerifyRepository.createEmailVerification( - token, + verificationToken, expiration, userId ); - return token; + return verificationToken; + } + + public async verifyEmail(tokenToVerify: string): Promise { + const isTokenVerified = + await this.emailVerifyRepository.findEmailVerificationByToken( + tokenToVerify + ); + + if (isTokenVerified) { + const emailVerification = + await this.deleteEmailVerificationToken(tokenToVerify); + + if (emailVerification && emailVerification.user) { + await this.userDataRepository.updateEmailVerificationStatus( + emailVerification.user.id + ); + return true; + } else { + return false; + } + } + return false; + } + + private async createVerificationToken(): Promise { + const verifyToken = randomBytes(32).toString('hex'); + + return UriEncoderService.encodeUri(verifyToken); + } + + private async deleteEmailVerificationToken( + tokenToDelete: string + ): Promise { + return await this.emailVerifyRepository.deleteEmailVerificationByToken( + tokenToDelete + ); } } diff --git a/backend/src/modules/verify-module/verify.module.ts b/backend/src/modules/verify-module/verify.module.ts index f506909..8c90fc8 100644 --- a/backend/src/modules/verify-module/verify.module.ts +++ b/backend/src/modules/verify-module/verify.module.ts @@ -3,13 +3,20 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EmailVerification } from 'src/entities'; +import { UserModule } from '../user-module/user.module'; + +import { VerifyController } from './controller/verify.controller'; import { EmailVerifyRepository } from './repositories'; import { EmailVerificationService } from './services/email-verification.service'; @Module({ - imports: [ConfigModule, TypeOrmModule.forFeature([EmailVerification])], + imports: [ + ConfigModule, + UserModule, + TypeOrmModule.forFeature([EmailVerification]), + ], providers: [EmailVerifyRepository, EmailVerificationService], - controllers: [], + controllers: [VerifyController], exports: [EmailVerificationService], }) export class VerifyModule {} diff --git a/backend/src/shared/decorator/index.ts b/backend/src/shared/decorator/index.ts new file mode 100644 index 0000000..3f75d99 --- /dev/null +++ b/backend/src/shared/decorator/index.ts @@ -0,0 +1 @@ +export * from './public.decorator'; diff --git a/backend/src/modules/auth-module/common/decorators/public.decorator.ts b/backend/src/shared/decorator/public.decorator.ts similarity index 100% rename from backend/src/modules/auth-module/common/decorators/public.decorator.ts rename to backend/src/shared/decorator/public.decorator.ts diff --git a/backend/src/shared/index.ts b/backend/src/shared/index.ts new file mode 100644 index 0000000..054a5f9 --- /dev/null +++ b/backend/src/shared/index.ts @@ -0,0 +1,2 @@ +export * from './utils/index'; +export * from './decorator/index'; diff --git a/backend/src/modules/auth-module/services/encryption.service.ts b/backend/src/shared/utils/encryption.service.ts similarity index 57% rename from backend/src/modules/auth-module/services/encryption.service.ts rename to backend/src/shared/utils/encryption.service.ts index 79182e7..5e3a3b8 100644 --- a/backend/src/modules/auth-module/services/encryption.service.ts +++ b/backend/src/shared/utils/encryption.service.ts @@ -1,21 +1,22 @@ -import { Injectable } from '@nestjs/common'; import * as argon2 from 'argon2'; import { Options } from 'argon2'; -@Injectable() export class EncryptionService { - private hashOptions: Options = { + private static hashOptions: Options = { type: argon2.argon2id, memoryCost: 2 ** 16, timeCost: 3, parallelism: 1, }; - public async hashData(data: string): Promise { + public static async hashData(data: string): Promise { return await argon2.hash(data, this.hashOptions); } - public async compareHash(data: string, encrypted: string): Promise { + public static async compareHash( + data: string, + encrypted: string + ): Promise { return await argon2.verify(encrypted, data); } } diff --git a/backend/src/shared/utils/index.ts b/backend/src/shared/utils/index.ts new file mode 100644 index 0000000..bce2e01 --- /dev/null +++ b/backend/src/shared/utils/index.ts @@ -0,0 +1,2 @@ +export * from './uri-encoder.service'; +export * from './encryption.service'; diff --git a/backend/src/shared/utils/uri-encoder.service.ts b/backend/src/shared/utils/uri-encoder.service.ts new file mode 100644 index 0000000..ad9eba0 --- /dev/null +++ b/backend/src/shared/utils/uri-encoder.service.ts @@ -0,0 +1,13 @@ +export class UriEncoderService { + public static encodeUri(uri: string): string { + return encodeURIComponent(uri); + } + + public static decodeUri(uri: string): string { + return decodeURIComponent(uri); + } + + public static encodeBase64(uri: string): string { + return btoa(UriEncoderService.encodeUri(uri)); + } +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 1a9c7a0..5488cff 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -9,4 +9,11 @@ export const routes: Routes = [ (m) => m.RegisterRootComponent ), }, + { + path: 'verify', + loadComponent: () => + import('./pages/email-verify-root/email-verify-root.component').then( + (m) => m.EmailVerifyRootComponent + ), + }, ]; diff --git a/frontend/src/app/pages/email-verify-root/email-verify-root.component.html b/frontend/src/app/pages/email-verify-root/email-verify-root.component.html new file mode 100644 index 0000000..9862d41 --- /dev/null +++ b/frontend/src/app/pages/email-verify-root/email-verify-root.component.html @@ -0,0 +1,16 @@ +
+
+
+ @if (showRedirectMessage()) { +

Es geht gleich los!

+

+ Danke für das bestätigen der E-Mail - Wir leiten dich zum Login + weiter! +

+ } @else { +

Oops, da ist etwas schief gelaufen!

+

Der Link ist nicht mehr gültig

+ } +
+
+
diff --git a/frontend/src/app/pages/email-verify-root/email-verify-root.component.scss b/frontend/src/app/pages/email-verify-root/email-verify-root.component.scss new file mode 100644 index 0000000..eae00e9 --- /dev/null +++ b/frontend/src/app/pages/email-verify-root/email-verify-root.component.scss @@ -0,0 +1,28 @@ +#background { + display: flex; + height: 100%; +} + +.wrapper { + flex: 1; + background-color: lightsalmon; + display: flex; + + .content{ + flex-direction: column; + display: flex; + align-items: flex-start; + justify-content: center; + h1 { + font-size: 4rem; + margin-left: 3rem; + line-height: 1rem; + } + h2 { + margin-left: 3rem; + } + p { + margin-left: 3rem; + } + } +} 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 new file mode 100644 index 0000000..301c2e5 --- /dev/null +++ b/frontend/src/app/pages/email-verify-root/email-verify-root.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + InputSignal, + OnInit, + WritableSignal, + input, + signal, +} from '@angular/core'; +import { Router } from '@angular/router'; + +import { delay, filter, tap } from 'rxjs'; + +import { VerifyApiService } from '../../api'; + +@Component({ + selector: 'app-email-verify-root', + standalone: true, + imports: [], + providers: [], + templateUrl: './email-verify-root.component.html', + styleUrl: './email-verify-root.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailVerifyRootComponent implements OnInit { + public token: InputSignal = input(''); + public verifyStatus: WritableSignal = signal(false); + public showRedirectMessage: WritableSignal = signal(false); + + public constructor( + private readonly api: VerifyApiService, + private readonly router: Router + ) {} + + public ngOnInit(): void { + this.verifyEmail(); + } + + private verifyEmail(): void { + const [verifyToken, email]: string[] = this.token().split('|'); + + this.api + .verifyControllerVerifyEmail(verifyToken) + .pipe( + tap((isVerified: boolean) => { + this.verifyStatus.set(isVerified); + }), + filter((isVerified) => isVerified), + tap(() => { + this.showRedirectMessage.set(true); + }), + delay(10000) + ) + .subscribe(() => { + this.router.navigate(['/signup'], { + queryParams: { verified: true, email: email }, + }); + }); + } +} diff --git a/frontend/src/app/pages/register-root/register-root.component.ts b/frontend/src/app/pages/register-root/register-root.component.ts index d25274f..ccf8788 100644 --- a/frontend/src/app/pages/register-root/register-root.component.ts +++ b/frontend/src/app/pages/register-root/register-root.component.ts @@ -7,6 +7,8 @@ import { WritableSignal, signal, effect, + InputSignal, + input, } from '@angular/core'; import { FormBuilder, @@ -16,6 +18,7 @@ import { ReactiveFormsModule, Validators, } from '@angular/forms'; +import { Router } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { CheckboxModule } from 'primeng/checkbox'; @@ -50,6 +53,8 @@ type AuthAction = 'register' | 'signup'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegisterRootComponent implements OnInit { + public verified: InputSignal = input(false); + public email: InputSignal = input(''); public form: FormGroup | undefined; public isRegisterSignal: WritableSignal = signal(false); public isSignupSignal: WritableSignal = signal(false); @@ -57,10 +62,12 @@ export class RegisterRootComponent implements OnInit { public emailInvalid: WritableSignal = signal(null); public passwordInvalid: WritableSignal = signal(null); public termsInvalid: WritableSignal = signal(null); + private removeQueryParams: WritableSignal = signal(false); public constructor( private readonly formBuilder: FormBuilder, - private readonly authService: AuthService + private readonly authService: AuthService, + private readonly router: Router ) { effect(() => { if (this.form) { @@ -73,12 +80,21 @@ export class RegisterRootComponent implements OnInit { this.form.removeControl('terms'); } } + + if (this.removeQueryParams()) { + this.clearRouteParams(); + } }); } public ngOnInit(): void { this.initializeForm(); this.setupValueChanges(); + + if (this.email() || this.verified()) { + this.handleRedirect(); + this.removeQueryParams.set(true); + } } public toggleAction(action: AuthAction): void { @@ -133,6 +149,22 @@ export class RegisterRootComponent implements OnInit { }); } + private handleRedirect(): void { + console.log('handleRedirect'); + if (this.verified()) { + this.isDisplayButtons.set(false); + this.isRegisterSignal.set(false); + this.isSignupSignal.set(true); + } + if (this.email()) { + this.form?.get('email')?.setValue(decodeURIComponent(atob(this.email()))); + } + } + + private clearRouteParams(): void { + this.router.navigate([], { queryParams: {} }); + } + private setupValueChanges(): void { this.setupEmailValueChanges(); this.setupPasswordValueChanges(); diff --git a/frontend/src/app/shared/service/auth.service.ts b/frontend/src/app/shared/service/auth.service.ts index f1d97e0..c7a6ea3 100644 --- a/frontend/src/app/shared/service/auth.service.ts +++ b/frontend/src/app/shared/service/auth.service.ts @@ -13,7 +13,6 @@ import { SessionStorageService } from './session-storage.service'; providedIn: 'root', }) export class AuthService { - private readonly _path: string = '/api/auth'; private _access_token: string | null = null; private _refresh_token: string | null = null; private _isAuthenticated$: BehaviorSubject = @@ -29,7 +28,7 @@ export class AuthService { private readonly sessionStorageService: SessionStorageService, private readonly authenticationApiService: AuthenticationApiService ) { - this.autoLogin(); + //this.autoLogin(); } public signin(credentials: LoginCredentials): void {