diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 61046f8..c618edb 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -36,6 +36,9 @@ export class AppModule { public configure(consumer: MiddlewareConsumer): void { consumer // TODO Redirect via Reverse Proxy all HTTP requests to HTTPS + // TODO use env.dev and end.prod + // TODO Implement helmet module + // TODO Implement CSRF protection -> csurf module .apply( CspMiddleware, SecurityHeadersMiddleware, diff --git a/backend/src/cron/clear-expired-sesstions.cron.ts b/backend/src/cron/clear-expired-sesstions.cron.ts index 8d5a56e..5c4b4e1 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_5_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/entities/email-verification.entity.ts b/backend/src/entities/email-verification.entity.ts index f49655f..3fdc7cd 100644 --- a/backend/src/entities/email-verification.entity.ts +++ b/backend/src/entities/email-verification.entity.ts @@ -21,6 +21,12 @@ export class EmailVerification { @Column() public expiresAt: Date; + @Column() + public email: string; + + @Column({ nullable: true }) + public userAgent: string; + @OneToOne(() => UserCredentials) @JoinColumn({ name: 'userCredentialsId' }) public user: UserCredentials; diff --git a/backend/src/middleware/security-middleware/security.middleware.ts b/backend/src/middleware/security-middleware/security.middleware.ts index 6d66420..fa168eb 100644 --- a/backend/src/middleware/security-middleware/security.middleware.ts +++ b/backend/src/middleware/security-middleware/security.middleware.ts @@ -13,6 +13,13 @@ export class SecurityHeadersMiddleware implements NestMiddleware { 'max-age=63072000; includeSubDomains; preload' ); } + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader( + 'Permissions-Policy', + 'geolocation=(), microphone=(), camera=()' + ); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); next(); diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index 5b0baa2..d00da2f 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -15,7 +15,12 @@ import { SuccessDto } from 'src/shared'; import { Public } from 'src/shared/decorator'; import { LocalAuthGuard } from '../guard'; -import { SigninResponseDto, UserCredentialsDto } from '../models/dto'; +import { + MagicLinkDto, + MagicLinkSigninDto, + SigninResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { AuthService } from '../services/auth.service'; @ApiTags('Authentication') @@ -23,6 +28,23 @@ 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, + @Req() request: Request + ): Promise { + const userAgent = request.headers['user-agent'] || 'Unknown'; + + return this.authService.sendMagicLink(magicLinkDto, userAgent); + } + @ApiCreatedResponse({ description: 'User signed up successfully', type: SuccessDto, @@ -31,21 +53,26 @@ 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({ 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/guard/index.ts b/backend/src/modules/auth-module/guard/index.ts index cbc8888..520934f 100644 --- a/backend/src/modules/auth-module/guard/index.ts +++ b/backend/src/modules/auth-module/guard/index.ts @@ -1 +1,2 @@ export * from './local.auth.guard'; +export * from './is-authenticated.guard'; diff --git a/backend/src/modules/auth-module/guard/is-authenticated.guard.ts b/backend/src/modules/auth-module/guard/is-authenticated.guard.ts new file mode 100644 index 0000000..cdabdc7 --- /dev/null +++ b/backend/src/modules/auth-module/guard/is-authenticated.guard.ts @@ -0,0 +1,9 @@ +import { CanActivate, ExecutionContext } from '@nestjs/common'; + +export class IsAuthenticatedGuard implements CanActivate { + public canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + return request.isAuthenticated(); + } +} diff --git a/backend/src/modules/auth-module/models/dto/index.ts b/backend/src/modules/auth-module/models/dto/index.ts index e0a3cb5..7393172 100644 --- a/backend/src/modules/auth-module/models/dto/index.ts +++ b/backend/src/modules/auth-module/models/dto/index.ts @@ -1,2 +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/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/models/dto/signin-response.dto.ts b/backend/src/modules/auth-module/models/dto/signin-response.dto.ts index ea21c77..6e4b5c7 100644 --- a/backend/src/modules/auth-module/models/dto/signin-response.dto.ts +++ b/backend/src/modules/auth-module/models/dto/signin-response.dto.ts @@ -12,12 +12,13 @@ export class SigninResponseDto { @IsEmail() public email: string; - @ApiProperty({ - title: 'User ID', - description: 'User ID', - }) - @IsNotEmpty() - @IsString() - @IsEmail() - public id: string; + // TODO: ID is saved in the session, so it is not needed here + // @ApiProperty({ + // title: 'User ID', + // description: 'User ID', + // }) + // @IsNotEmpty() + // @IsString() + // @IsEmail() + // public id: 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..45f7400 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 { 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'; @@ -11,7 +15,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,8 +32,55 @@ export class AuthService { private readonly sessionService: SessionService ) {} + public async sendMagicLink( + magiclink: MagicLinkDto, + userAgent: string + ): Promise { + try { + const existingUser = await this.userCredentialsRepository.findUserByEmail( + magiclink.email + ); + + if (existingUser) { + const token = + await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( + magiclink.email, + userAgent, + existingUser.id + ); + + // TODO: Add OTP or 2FA here as an additional security measure + await this.passwordConfirmationMailService.sendLoginLinkEmail( + magiclink.email, + token + ); + } else { + const token = + await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( + magiclink.email, + userAgent + ); + + await this.passwordConfirmationMailService.sendRegistrationLinkEmail( + magiclink.email, + token + ); + } + + return { success: true }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('MAGIC_LINK_ERROR', { + cause: error, + }); + } + } + public async signup( - userCredentials: UserCredentialsDto + userCredentials: UserCredentialsDto, + userAgent: string ): Promise { try { const existingUser = await this.userCredentialsRepository.findUserByEmail( @@ -45,18 +100,10 @@ export class AuthService { passwordHashed ); + await this.sendMagicLink({ email: user.email }, userAgent); + await this.userDataRepository.createInitialUserData(user); - const token = - await this.emailVerificationService.generateEmailVerificationToken( - user.id - ); - - await this.passwordConfirmationMailService.sendPasswordConfirmationMail( - user.email, - token - ); - return { success: true, }; @@ -72,28 +119,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', { @@ -103,6 +153,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); @@ -149,8 +203,8 @@ export class AuthService { public getLoginResponse( user: SigninResponseDto & { userAgent: string } ): SigninResponseDto { - const { id, email }: SigninResponseDto = user; - const responseData: SigninResponseDto = { id, email }; + const { email }: SigninResponseDto = user; + const responseData: SigninResponseDto = { email }; return responseData; } diff --git a/backend/src/modules/auth-module/strategies/local.strategy.ts b/backend/src/modules/auth-module/strategies/local.strategy.ts index a77367a..66bc285 100644 --- a/backend/src/modules/auth-module/strategies/local.strategy.ts +++ b/backend/src/modules/auth-module/strategies/local.strategy.ts @@ -1,34 +1,56 @@ +/* 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'] + ); + + this.emailVerificationService.removeEmailVerificationByTokenAndEmail( + token as string, + email as string + ); + + 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 1f19474..939dd70 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 @@ -10,6 +10,7 @@ import { TemplateConfigService } from './template-config.service'; export class PasswordConfirmationMailService extends BaseMailService { private readonly PASSWORD_CONFIRMATION_EMAIL: string = 'PASSWORD_CONFIRMATION_EMAIL'; + private readonly REGISTER_EMAIL: string = 'REGISTER_EMAIL'; public constructor( @Inject('SEND_GRID_API_KEY') protected readonly sendGridApiKey: string, @@ -41,4 +42,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`; + const templateId: string = this.templateConfigService.getTemplateId( + this.REGISTER_EMAIL + ); + + const mailoptions: SendGridMailApi.MailDataRequired = { + to, + from: { email: 'info@igor-propisnov.com', name: 'Ticket App' }, + templateId: templateId, + dynamicTemplateData: { + buttonUrl: registrationLink, + }, + }; + + await this.sendMail(mailoptions); + } } 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/session/services/session-init.service.ts b/backend/src/modules/session/services/session-init.service.ts index 9a4fb7b..6187316 100644 --- a/backend/src/modules/session/services/session-init.service.ts +++ b/backend/src/modules/session/services/session-init.service.ts @@ -24,6 +24,7 @@ export class SessionInitService { ttl: 86400, }).connect(this.dataSource.getRepository(Session)), cookie: { + // TODO: Check sameSite strict configuration on production maxAge: 86400000, httpOnly: true, secure: @@ -34,7 +35,7 @@ export class SessionInitService { sameSite: this.configService.get('NODE_ENV') === 'development' ? 'strict' - : 'none', + : 'strict', }, }); } diff --git a/backend/src/modules/verify-module/controller/verify.controller.ts b/backend/src/modules/verify-module/controller/verify.controller.ts index d10f070..d1648d2 100644 --- a/backend/src/modules/verify-module/controller/verify.controller.ts +++ b/backend/src/modules/verify-module/controller/verify.controller.ts @@ -1,16 +1,13 @@ import { Controller, - Get, - Req, HttpCode, HttpStatus, Query, - UseGuards, Post, + Req, } 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 +21,22 @@ 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, + @Req() request: Request + ): Promise { + const userAgent = request.headers['user-agent'] || 'Unknown'; - @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); + return this.emailVerificationService.verifyEmail( + tokenToVerify, + emailToVerify, + userAgent + ); } } 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..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 { MoreThan, Repository } from 'typeorm'; +import { LessThan, Repository } from 'typeorm'; @Injectable() export class EmailVerifyRepository { @@ -13,36 +13,45 @@ export class EmailVerifyRepository { public async createEmailVerification( token: string, expiresAt: Date, - userId: string + email: string, + userId: string | null, + userAgent: string ): Promise { + await this.repository.delete({ email }); + await this.repository.save({ token, expiresAt, - user: { id: userId }, + email, + user: userId ? { id: userId } : null, + userAgent, }); } - 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 deleteEmailVerificationByToken( - tokenToDelete: string - ): Promise { - const emailVerification = await this.repository.findOne({ - where: { token: tokenToDelete }, - relations: ['user'], + public async removeEmailVerificationByTokenAndEmail( + token: string, + email: string + ): Promise { + await this.repository.delete({ token, email }); + } + + public async deleteAllExpiredTokens(): Promise { + const currentDate = new Date(); + + await this.repository.delete({ + expiresAt: LessThan(currentDate), }); - - 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 c0e9214..3fbf474 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -1,31 +1,36 @@ 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 { InternalServerErrorException } from 'src/shared/exceptions'; +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 { + public async generateEmailVerificationTokenForMagicLink( + email: string, + userAgent: string, + userid?: string + ): Promise { try { const verificationToken = await this.createVerificationToken(); - const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); await this.emailVerifyRepository.createEmailVerification( verificationToken, - expiration, - userId + expiresAt, + email, + userid || null, + userAgent ); return verificationToken; @@ -40,55 +45,60 @@ export class EmailVerificationService { } } - public async verifyEmail(tokenToVerify: string): Promise { + public async verifyEmail( + tokenToVerify: string, + emailToVerify: string, + userAgent: string + ): Promise { try { - const emailVerification = - await this.emailVerifyRepository.findEmailVerificationByToken( - tokenToVerify - ); + const token = await this.emailVerifyRepository.findByTokenAndEmail( + tokenToVerify, + emailToVerify + ); - if (!emailVerification) { - return false; + if (!token) { + throw new TokenExpiredException(); } - const deletedVerification = - await this.deleteEmailVerificationToken(tokenToVerify); - - if (deletedVerification && deletedVerification.user) { - const isStatusUpdated = - await this.userDataRepository.updateEmailVerificationStatus( - deletedVerification.user.id - ); - - return isStatusUpdated; + if (token.userAgent !== userAgent) { + throw new UserAgentMismatchException({ + message: + 'The User Agent does not match the one used to generate the token.', + }); } - return false; + const currentDate = new Date(); + + if (token.expiresAt.getTime() < currentDate.getTime()) { + throw new TokenExpiredException(); + } + + return { success: true }; } catch (error) { + 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); + public async removeEmailVerificationByTokenAndEmail( + token: string, + email: string + ): Promise { + await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail( + token, + email + ); + } - 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.', - }); - } + public async deleteAllExpiredTokens(): Promise { + await this.emailVerifyRepository.deleteAllExpiredTokens(); } private async createVerificationToken(): Promise { @@ -96,12 +106,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 c43a40a..9c55f89 100644 --- a/backend/src/shared/exceptions/index.ts +++ b/backend/src/shared/exceptions/index.ts @@ -2,3 +2,6 @@ export * from './conflict.exception'; 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/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 + ); + } +} 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 d507bb8..7fce13a 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/app.routes.ts b/frontend/src/app/app.routes.ts index f0d58b1..16ba15f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -10,13 +10,6 @@ const simpleLayoutRoutes: Routes = [ (m) => m.WelcomeRootComponent ), }, - { - path: 'verify', - loadComponent: () => - import('./pages/email-verify-root/email-verify-root.component').then( - (m) => m.EmailVerifyRootComponent - ), - }, ]; const protectedRoutes: Routes = [ @@ -46,6 +39,11 @@ const protectedRoutes: Routes = [ }, ], }, + { + path: 'foo', + loadComponent: () => + import('./pages/foo-root/foo.component').then((m) => m.FooComponent), + }, ]; export const routes: Routes = [ diff --git a/frontend/src/app/layout/main-layout/layout.component.html b/frontend/src/app/layout/main-layout/layout.component.html index 2ba8ef2..ba8cda2 100644 --- a/frontend/src/app/layout/main-layout/layout.component.html +++ b/frontend/src/app/layout/main-layout/layout.component.html @@ -1,156 +1,399 @@ -
-
-
-
-
- @if (!isCollapsed && !isDesktopCollapsed) { -
LOGO
- } - - @if (!isCollapsed && !showMobileMenu) { - - } -
-
-
-
    -
  • -
    - -
    - -
    -
    - - {{ item.name }} -
    -
    -
  • -
-
    -
  • -
    - -
    - -
    -
    - - {{ item.name }} -
    -
    -
  • -
-
- -
+
+
+
-
- + class="w-full navbar bg-primary text-primary-content z-40"> + + +
+ +
+ + +
-
- + +
+ +
+ +
+ + +
+ + +
+
+ +
+
- -
diff --git a/frontend/src/app/layout/main-layout/layout.component.ts b/frontend/src/app/layout/main-layout/layout.component.ts index 080481c..f153160 100644 --- a/frontend/src/app/layout/main-layout/layout.component.ts +++ b/frontend/src/app/layout/main-layout/layout.component.ts @@ -1,13 +1,29 @@ +import { + animate, + state, + style, + transition, + trigger, +} from '@angular/animations'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, HostListener, + OnDestroy, OnInit, } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { Router, RouterOutlet } from '@angular/router'; +import { + ActivatedRoute, + NavigationEnd, + Router, + RouterModule, + RouterOutlet, +} from '@angular/router'; + +import { filter, Subject, takeUntil } from 'rxjs'; import { SuccessDtoApiModel } from '../../api'; import { BackgroundPatternService, ThemeService } from '../../shared/service'; @@ -16,8 +32,17 @@ import { AuthService } from '../../shared/service/auth.service'; interface TopMenuItem { name: string; icon: SafeHtml; + route?: string; + active?: boolean; + subitems?: SubMenuItem[]; + isOpen?: boolean; +} + +interface SubMenuItem { + name: string; route: string; active?: boolean; + icon?: SafeHtml; } interface BottomMenuItem { @@ -30,14 +55,99 @@ interface BottomMenuItem { selector: 'app-layout', standalone: true, providers: [], - imports: [RouterOutlet, CommonModule], + imports: [RouterOutlet, CommonModule, RouterModule], templateUrl: './layout.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, + changeDetection: ChangeDetectionStrategy.Default, + animations: [ + trigger('compactAnimation', [ + state( + 'normal', + style({ + transform: 'scaleX(1)', + }) + ), + state( + 'compact', + style({ + transform: 'scaleX(1)', + }) + ), + transition('normal <=> compact', [animate('300ms ease-in-out')]), + ]), + trigger('burgerAnimation', [ + state('closed', style({ transform: 'rotate(0deg)' })), + state('open', style({ transform: 'rotate(180deg)' })), + transition('closed <=> open', [animate('0.3s ease-in-out')]), + ]), + trigger('submenuAnimation', [ + state( + 'closed', + style({ + height: '0', + opacity: '0', + }) + ), + state( + 'open', + style({ + height: '*', + opacity: '1', + }) + ), + transition('closed <=> open', [animate('200ms ease-in-out')]), + ]), + trigger('drawerAnimation', [ + state( + 'open', + style({ + width: '16rem', + }) + ), + state( + 'compact', + style({ + width: '4rem', + }) + ), + state( + 'closed', + style({ + width: '0', + }) + ), + transition('closed <=> open', [animate('200ms ease-in-out')]), + transition('open <=> compact', [animate('200ms ease-in-out')]), + transition('compact <=> closed', [animate('200ms ease-in-out')]), + ]), + trigger('backdropAnimation', [ + state('visible', style({ opacity: 1 })), + state('hidden', style({ opacity: 0 })), + transition('hidden <=> visible', [animate('200ms ease-in-out')]), + ]), + trigger('mainContentAnimation', [ + state( + 'shifted', + style({ + marginLeft: '4rem', + }) + ), + state( + 'normal', + style({ + marginLeft: '0', + }) + ), + transition('shifted <=> normal', [animate('200ms ease-in-out')]), + ]), + ], }) -export class LayoutComponent implements OnInit { +export class LayoutComponent implements OnInit, OnDestroy { public isCollapsed: boolean = false; public isDesktopCollapsed: boolean = false; public showMobileMenu: boolean = false; + public isDrawerOpen: boolean = true; + public isCompact: boolean = false; + public isMobile: boolean = window.innerWidth < 1024; public menuItems: TopMenuItem[] = [ { name: 'Dashboard', @@ -49,11 +159,41 @@ export class LayoutComponent implements OnInit { }, { name: 'Event', - route: '/event', icon: this.sanitizer .bypassSecurityTrustHtml(` `), + route: '/event', + }, + { + name: 'Profile', + route: '/profile', + icon: this.sanitizer + .bypassSecurityTrustHtml(` + + `), + subitems: [ + { + name: 'Edit Profile', + route: '/foo', + icon: this.sanitizer.bypassSecurityTrustHtml(` + + + + + `), + }, + { + name: 'Delete Profile', + route: '/foo-1', + icon: this.sanitizer.bypassSecurityTrustHtml(` + + + + `), + }, + ], + isOpen: false, }, ]; public bottomMenuItems: BottomMenuItem[] = [ @@ -68,37 +208,93 @@ export class LayoutComponent implements OnInit { ]; public mainContent: { 'background-image': string } | null = null; public navigation: { 'background-image': string } | null = null; + private destroy$: Subject = new Subject(); public constructor( private readonly sanitizer: DomSanitizer, private readonly router: Router, private readonly backgroundPatternService: BackgroundPatternService, + private readonly route: ActivatedRoute, private readonly themeService: ThemeService, private readonly el: ElementRef, private readonly authService: AuthService ) {} - public ngOnInit(): void { - this.setActiveItemBasedOnRoute(); - this.router.events.subscribe(() => { - this.setActiveItemBasedOnRoute(); - }); - this.setBackground(); - this.onResize(); + @HostListener('window:resize', ['$event']) + public onResize(event: Event): void { + this.isMobile = (event.target as Window).innerWidth < 1024; + this.adjustDrawerState((event.target as Window).innerWidth); } - @HostListener('window:resize', ['$event']) - public onResize(): void { - if (window.innerWidth >= 768) { - this.showMobileMenu = false; - this.isCollapsed = false; - } else { - this.isDesktopCollapsed = false; - this.isCollapsed = true; - this.showMobileMenu = false; + public ngOnInit(): void { + this.setBackground(); + this.adjustDrawerState(window.innerWidth); + + window.addEventListener('resize', this.onResize.bind(this)); + + this.updateMenuState(this.router.url); + + this.router.events + .pipe( + filter( + (event): event is NavigationEnd => event instanceof NavigationEnd + ), + takeUntil(this.destroy$) + ) + .subscribe((event: NavigationEnd) => { + this.updateMenuState(event.urlAfterRedirects); + }); + } + + public ngOnDestroy(): void { + window.removeEventListener('resize', this.onResize.bind(this)); + this.destroy$.next(); + this.destroy$.complete(); + } + + public onLinkClick(): void { + if (window.innerWidth < 1024 && this.isDrawerOpen) { + this.isDrawerOpen = false; + this.isCompact = true; } } + public toggleDrawer(): void { + this.isDrawerOpen = !this.isDrawerOpen; + this.isCompact = !this.isDrawerOpen; + } + + public toggleCompactMode(): void { + this.isCompact = !this.isCompact; + this.isDrawerOpen = !this.isCompact; + } + + public adjustDrawerState(width: number): void { + if (width < 1024) { + this.isCompact = true; + this.isDrawerOpen = false; + } else { + this.isCompact = false; + this.isDrawerOpen = true; + } + } + + public navigateTo(route: string): void { + this.router.navigate([route]); + } + + public executeAction(item: BottomMenuItem): void { + if (item.action) { + item.action(); + } + } + + public toggleSubmenu(item: TopMenuItem, event: Event): void { + event.preventDefault(); + event.stopPropagation(); + item.isOpen = !item.isOpen; + } + public setBackground(): void { const theme = this.themeService.getTheme(); let opacity: number; @@ -134,34 +330,29 @@ export class LayoutComponent implements OnInit { }; } - public toggleSidebar(): void { - if (window.innerWidth < 768) { - this.showMobileMenu = !this.showMobileMenu; - this.isCollapsed = !this.showMobileMenu; - } else { - this.isDesktopCollapsed = !this.isDesktopCollapsed; - } - } - - public toggleDesktopSidebar(): void { - this.isDesktopCollapsed = !this.isDesktopCollapsed; - } - - public setActive(item: TopMenuItem): void { - this.menuItems.forEach((menu: TopMenuItem) => { - menu.active = false; - }); - this.router.navigate([item.route]); - if (!this.isCollapsed && this.showMobileMenu) { - this.toggleSidebar(); - } - } - - private setActiveItemBasedOnRoute(): void { - const url = this.router.url; - + private updateMenuState(currentRoute: string): void { this.menuItems.forEach((item: TopMenuItem) => { - item.active = url.startsWith(item.route); + // Set top-level items active state + if (item.route) { + item.active = currentRoute.startsWith(item.route); + } + + // Handle subitems + if (item.subitems) { + // Check if any subitem matches the current route + const activeSubItem = item.subitems.some((subItem) => + currentRoute.startsWith(subItem.route) + ); + + // Set the parent item and subitem active state + item.active = activeSubItem; + item.isOpen = activeSubItem; + + // Set active states for all subitems + item.subitems.forEach((subItem) => { + subItem.active = currentRoute.startsWith(subItem.route); + }); + } }); } 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 deleted file mode 100644 index e5bc844..0000000 --- a/frontend/src/app/pages/email-verify-root/email-verify-root.component.html +++ /dev/null @@ -1,39 +0,0 @@ -
- -
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 deleted file mode 100644 index e69de29..0000000 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 deleted file mode 100644 index 48af55f..0000000 --- a/frontend/src/app/pages/email-verify-root/email-verify-root.component.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - ElementRef, - InputSignal, - OnInit, - WritableSignal, - input, - signal, -} from '@angular/core'; -import { Router } from '@angular/router'; - -import { delay, filter, tap } from 'rxjs'; - -import { VerifyApiService } from '../../api'; -import { BackgroundPatternService, ThemeService } from '../../shared/service'; - -@Component({ - selector: 'app-email-verify-root', - standalone: true, - imports: [CommonModule], - 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 email: WritableSignal = signal(''); - public backgroundStyle: { 'background-image': string } | null = null; - public verifyStatus: WritableSignal = signal( - null - ); - public showRedirectMessage: WritableSignal = signal(false); - - public constructor( - private readonly api: VerifyApiService, - private readonly router: Router, - private readonly el: ElementRef, - private readonly backgroundPatternService: BackgroundPatternService, - private readonly themeService: ThemeService - ) {} - - public ngOnInit(): void { - this.verifyEmail(); - this.setBackground(); - } - - 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 svgUrl = this.backgroundPatternService.getWigglePattern( - colorPrimary, - opacity - ); - - this.backgroundStyle = { 'background-image': `url("${svgUrl}")` }; - } - - public navigateToWelcomeScreen(): void { - const email: string = this.extractEmail(); - - this.router.navigate(['/welcome'], { - queryParams: { verified: true, email: email }, - }); - } - - private extractVerifyToken(): string { - const [verifyToken]: string[] = this.token().split('|'); - - return verifyToken; - } - - private extractEmail(): string { - const [, email]: string[] = this.token().split('|'); - - return email; - } - - private verifyEmail(): void { - const verifyToken: string = this.extractVerifyToken(); - const email: string = this.extractEmail(); - - if (verifyToken && email) { - 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(); - }); - } -} diff --git a/frontend/src/app/pages/event-root/create-event/create-event.component.html b/frontend/src/app/pages/event-root/create-event/create-event.component.html index be43da3..ae29cf6 100644 --- a/frontend/src/app/pages/event-root/create-event/create-event.component.html +++ b/frontend/src/app/pages/event-root/create-event/create-event.component.html @@ -1,7 +1,6 @@
-
-
+
+
-
+
@if (currentStep() === 0) { } @@ -24,9 +23,8 @@
-
-
+
+
+
+ @if (displaySkeleton()) { +
+
+
+
+ @if (isRegistrationMode()) { +
} - @if (isSigninSignal()) { - @if (displaySkeleton()) { -
+
+
+
+ } @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 (isSignupSignal()) { -
-

Create an Account

-

- Enter your email below to create your Account

-
-
+ +
-
- -
- -

- By clicking continue, you agree to our - Terms of Service - and - Privacy Policy - . -

-
-
- } - @if (isSigninSignal()) { -
- @if (displaySkeleton()) { -
-
-
-
-
-
-
- } @else { -

Login

-
-
- +
+ + {{ getErrorMessage('email') }} +
-
+ + + @if (isRegistrationMode()) { +
-
-
- - - Forgot password? - +
+ + {{ getErrorMessage('password') }} +
-
- -
- Not registered yet? - - Create An Account - -
- - } + + } + + + +
} -
-
-

Made with ♥️ in Germany

-
-
+ + @if (!isRegistrationMode() && !displaySkeleton()) { +
+
+

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 (displaySkeleton()) { +
+
+
+
+
+
+
+
+
+
+
+
+ } +
+ +
+

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

-} @else { -
- -
-} +
-