diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f5e4cf4..aea7d79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,7 @@ model User { verification Verification? RevokedToken RevokedToken[] RefreshToken RefreshToken[] + ResetPassword ResetPassword? @@fulltext([name, email]) } @@ -66,6 +67,17 @@ model RefreshToken { @@index([userId]) } +model ResetPassword { + id String @id @default(cuid()) + userId String @unique + resetPasswordToken String? @unique @default(uuid()) + resetPasswordTokenExpiry DateTime? + resetPasswordEmailsSent Int @default(0) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + model Card { id String @id @default(cuid()) deckId String diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index cb6f0db..bc8bd1c 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { Get, HttpCode, HttpStatus, + Param, Post, Request, Res, @@ -24,6 +25,8 @@ import { LogoutCommand, RefreshTokenCommand, ResendVerificationEmailCommand, + ResetPasswordCommand, + SendPasswordRecoveryEmailCommand, VerifyEmailCommand, } from './use-cases' @@ -97,4 +100,14 @@ export class AuthController { accessToken: newTokens.accessToken, } } + + @Post('recover-password') + async recoverPassword(@Body('email') email: string) { + return await this.commandBus.execute(new SendPasswordRecoveryEmailCommand(email)) + } + + @Post('reset-password/:token') + async resetPassword(@Body('password') password: string, @Param('token') token: string) { + return await this.commandBus.execute(new ResetPasswordCommand(token, password)) + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1a15187..b62ec5d 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -10,6 +10,8 @@ import { LogoutHandler, RefreshTokenHandler, ResendVerificationEmailHandler, + ResetPasswordHandler, + SendPasswordRecoveryEmailHandler, VerifyEmailHandler, } from './use-cases' import { AuthRepository } from './infrastructure/auth.repository' @@ -20,6 +22,8 @@ const commandHandlers = [ LogoutHandler, RefreshTokenHandler, ResendVerificationEmailHandler, + ResetPasswordHandler, + SendPasswordRecoveryEmailHandler, VerifyEmailHandler, ] diff --git a/src/modules/auth/strategies/local.strategy.ts b/src/modules/auth/strategies/local.strategy.ts index 893fa6c..f249581 100644 --- a/src/modules/auth/strategies/local.strategy.ts +++ b/src/modules/auth/strategies/local.strategy.ts @@ -14,7 +14,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate(email: string, password: string): Promise { const newCredentials = await this.authService.checkCredentials(email, password) if (newCredentials.resultCode === 1) { - throw new UnauthorizedException() + throw new UnauthorizedException('Invalid credentials') } return newCredentials } diff --git a/src/modules/auth/tests/integration/auth.e2e.spec.ts b/src/modules/auth/tests/integration/auth.e2e.spec.ts new file mode 100644 index 0000000..5fc7eb0 --- /dev/null +++ b/src/modules/auth/tests/integration/auth.e2e.spec.ts @@ -0,0 +1,143 @@ +import { Test, TestingModule } from '@nestjs/testing' +import * as request from 'supertest' +import { HttpStatus } from '@nestjs/common' +import { AppModule } from '../../../../app.module' +import { RegistrationDto } from '../../dto/registration.dto' + +describe('AuthController (e2e)', () => { + let app + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile() + + app = moduleFixture.createNestApplication() + await app.init() + }) + + it('/POST sign-up', () => { + const registrationData: RegistrationDto = { + name: 'John', + email: 'john@gmail.com', + password: 'secret', + } + + return request(app.getHttpServer()) + .post('/auth/sign-up') + .send(registrationData) + .expect(HttpStatus.CREATED) + }) + + it('/POST sign-up (duplicate)', () => { + const registrationData: RegistrationDto = { + name: 'John', + email: 'john@gmail.com', + password: 'secret', + } + + return request(app.getHttpServer()) + .post('/auth/sign-up') + .send(registrationData) + .expect(HttpStatus.BAD_REQUEST) + }) + + it('/POST login', () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'john@gmail.com', password: 'secret' }) + .expect(HttpStatus.OK) + .then(response => { + expect(response.body.accessToken).toBeDefined() + }) + }) + + it('/POST login (invalid)', () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'john@gmail.com', password: 'wrong_password' }) + .expect(HttpStatus.UNAUTHORIZED) + }) + + it('/GET me', async () => { + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'john@gmail.com', password: 'secret' }) + .expect(HttpStatus.OK) + + return request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${loginResponse.body.accessToken}`) + .expect(HttpStatus.OK) + }) + it('/POST verify-email', async () => { + // Assuming "john@gmail.com" has a verification code "123456" + return request(app.getHttpServer()) + .post('/auth/verify-email') + .send({ code: '123456' }) + .expect(HttpStatus.OK) + }) + + it('/POST verify-email (invalid)', () => { + return request(app.getHttpServer()) + .post('/auth/verify-email') + .send({ code: 'wrong_code' }) + .expect(HttpStatus.UNAUTHORIZED) + }) + + it('/POST resend-verification-email', async () => { + // Assuming "john@gmail.com" has a user ID "1" + return request(app.getHttpServer()) + .post('/auth/resend-verification-email') + .send({ userId: '1' }) + .expect(HttpStatus.OK) + }) + + it('/POST logout', async () => { + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'john@gmail.com', password: 'secret' }) + .expect(HttpStatus.OK) + + return request(app.getHttpServer()) + .post('/auth/logout') + .set('Cookie', [`refreshToken=${loginResponse.body.refreshToken}`]) + .expect(HttpStatus.OK) + }) + + it('/GET refresh-token', async () => { + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'john@gmail.com', password: 'secret' }) + .expect(HttpStatus.OK) + + return request(app.getHttpServer()) + .get('/auth/refresh-token') + .set('Cookie', [`refreshToken=${loginResponse.body.refreshToken}`]) + .expect(HttpStatus.OK) + .then(response => { + expect(response.body.accessToken).toBeDefined() + }) + }) + + it('/POST recover-password', () => { + return request(app.getHttpServer()) + .post('/auth/recover-password') + .send({ email: 'john@gmail.com' }) + .expect(HttpStatus.OK) + }) + + it('/POST reset-password/:token', () => { + // Assuming "john@gmail.com" has a password reset token "abcdef" + return request(app.getHttpServer()) + .post('/auth/reset-password/abcdef') + .send({ password: 'new_password' }) + .expect(HttpStatus.OK) + }) + + // Add more tests for the other endpoints in a similar way + + afterAll(async () => { + await app.close() + }) +}) diff --git a/src/modules/auth/use-cases/index.ts b/src/modules/auth/use-cases/index.ts index c9b4135..28a28db 100644 --- a/src/modules/auth/use-cases/index.ts +++ b/src/modules/auth/use-cases/index.ts @@ -4,3 +4,5 @@ export * from './logout-use-case' export * from './resend-verification-email-use-case' export * from './refresh-token-use-case' export * from './verify-email-use-case' +export * from './send-password-recovery-email-use-case' +export * from './reset-password-use-case' diff --git a/src/modules/auth/use-cases/reset-password-use-case.ts b/src/modules/auth/use-cases/reset-password-use-case.ts new file mode 100644 index 0000000..c55ee70 --- /dev/null +++ b/src/modules/auth/use-cases/reset-password-use-case.ts @@ -0,0 +1,40 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { UsersRepository } from '../../users/infrastructure/users.repository' +import { UsersService } from '../../users/services/users.service' + +export class ResetPasswordCommand { + constructor(public readonly resetPasswordToken: string, public readonly newPassword: string) {} +} + +@CommandHandler(ResetPasswordCommand) +export class ResetPasswordHandler implements ICommandHandler { + constructor( + private readonly usersRepository: UsersRepository, + private readonly usersService: UsersService + ) {} + + async execute(command: ResetPasswordCommand) { + const user = await this.usersRepository.findUserByPasswordResetToken(command.resetPasswordToken) + + if (!user) { + throw new NotFoundException('Incorrect or expired password reset token') + } + if (!command.newPassword) { + throw new BadRequestException('Password is required') + } + + const newPasswordHash = await this.usersService.generateHash(command.newPassword) + + const updatedUser = await this.usersRepository.resetPasswordAndDeleteToken( + user.id, + newPasswordHash + ) + + if (!updatedUser) { + throw new NotFoundException('Incorrect or expired password reset token') + } + + return null + } +} diff --git a/src/modules/auth/use-cases/send-password-recovery-email-use-case.ts b/src/modules/auth/use-cases/send-password-recovery-email-use-case.ts new file mode 100644 index 0000000..af91971 --- /dev/null +++ b/src/modules/auth/use-cases/send-password-recovery-email-use-case.ts @@ -0,0 +1,40 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { NotFoundException } from '@nestjs/common' +import { UsersRepository } from '../../users/infrastructure/users.repository' +import { UsersService } from '../../users/services/users.service' +import { v4 as uuidv4 } from 'uuid' + +export class SendPasswordRecoveryEmailCommand { + constructor(public readonly email: string) {} +} + +@CommandHandler(SendPasswordRecoveryEmailCommand) +export class SendPasswordRecoveryEmailHandler + implements ICommandHandler +{ + constructor( + private readonly usersRepository: UsersRepository, + private readonly usersService: UsersService + ) {} + + async execute(command: SendPasswordRecoveryEmailCommand) { + const user = await this.usersRepository.findUserByEmail(command.email) + + if (!user) { + throw new NotFoundException('User not found') + } + const token = uuidv4() + const updatedUser = await this.usersRepository.createPasswordResetToken(user.id, token) + + await this.usersService.sendPasswordRecoveryEmail({ + email: updatedUser.user.email, + name: updatedUser.user.name, + passwordRecoveryToken: updatedUser.resetPasswordToken, + }) + if (!updatedUser) { + throw new NotFoundException('User not found') + } + + return null + } +} diff --git a/src/modules/users/infrastructure/users.repository.ts b/src/modules/users/infrastructure/users.repository.ts index 7c14e00..d72560c 100644 --- a/src/modules/users/infrastructure/users.repository.ts +++ b/src/modules/users/infrastructure/users.repository.ts @@ -5,7 +5,12 @@ import { UserViewType, VerificationWithUser, } from '../../../types/types' -import { Injectable } from '@nestjs/common' +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common' import { addHours } from 'date-fns' import { v4 as uuidv4 } from 'uuid' import { PrismaService } from '../../../prisma.service' @@ -16,157 +21,329 @@ import { Prisma } from '@prisma/client' export class UsersRepository { constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(UsersRepository.name) + async getUsers( currentPage: number, itemsPerPage: number, searchNameTerm: string, searchEmailTerm: string ): Promise> { - const where = { - name: { - search: searchNameTerm || undefined, - }, - email: { - search: searchEmailTerm || undefined, - }, - } - const [totalItems, users] = await this.prisma.$transaction([ - this.prisma.user.count({ where }), - this.prisma.user.findMany({ - where, - skip: (currentPage - 1) * itemsPerPage, - take: itemsPerPage, - }), - ]) - - const totalPages = Math.ceil(totalItems / itemsPerPage) - const usersView = users.map(u => pick(u, ['id', 'name', 'email', 'isEmailVerified'])) - return { - totalPages, - currentPage, - itemsPerPage, - totalItems, - items: usersView, + try { + const where = { + name: { + search: searchNameTerm || undefined, + }, + email: { + search: searchEmailTerm || undefined, + }, + } + const [totalItems, users] = await this.prisma.$transaction([ + this.prisma.user.count({ where }), + this.prisma.user.findMany({ + where, + skip: (currentPage - 1) * itemsPerPage, + take: itemsPerPage, + }), + ]) + const totalPages = Math.ceil(totalItems / itemsPerPage) + const usersView = users.map(u => pick(u, ['id', 'name', 'email', 'isEmailVerified'])) + return { + totalPages, + currentPage, + itemsPerPage, + totalItems, + items: usersView, + } + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2025') { + throw new BadRequestException({ message: 'Invalid page number', field: 'page' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) } } async createUser(newUser: CreateUserInput): Promise { - return await this.prisma.user.create({ - data: { - email: newUser.email, - password: newUser.password, - name: newUser.name, - verification: { - create: { - verificationToken: newUser.verificationToken, - verificationTokenExpiry: newUser.verificationTokenExpiry, + try { + return await this.prisma.user.create({ + data: { + email: newUser.email, + password: newUser.password, + name: newUser.name, + verification: { + create: { + verificationToken: newUser.verificationToken, + verificationTokenExpiry: newUser.verificationTokenExpiry, + }, }, }, - }, - include: { - verification: true, - }, - }) + include: { + verification: true, + }, + }) + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2002') { + throw new BadRequestException({ message: 'Email already exists', field: 'email' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } } async deleteUserById(id: string): Promise { - const result = await this.prisma.user.delete({ - where: { - id, - }, - }) - return result.isDeleted + try { + const result = await this.prisma.user.delete({ + where: { + id, + }, + }) + return result.isDeleted + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2015') { + throw new BadRequestException({ message: 'User not found', field: 'id' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } } - async deleteAllUsers(): Promise { - const result = await this.prisma.user.deleteMany() - return result.count > 0 + async deleteAllUsers(): Promise { + try { + const result = await this.prisma.user.deleteMany() + return result.count + } catch (e) { + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } } async findUserById(id: string, include?: Prisma.UserInclude) { - const user = await this.prisma.user.findUnique({ - where: { id }, - include, - }) - if (!user) { - return null - } + try { + const user = await this.prisma.user.findUnique({ + where: { id }, + include, + }) + if (!user) { + return null + } - return user as Prisma.UserGetPayload<{ include: typeof include }> + return user as Prisma.UserGetPayload<{ include: typeof include }> + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2015') { + throw new BadRequestException({ message: 'User not found', field: 'id' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } } async findUserByEmail(email: string): Promise { - const user = await this.prisma.user.findUnique({ - where: { email }, - include: { verification: true }, - }) + try { + const user = await this.prisma.user.findUnique({ + where: { email }, + include: { verification: true }, + }) - if (!user) { - return null + if (!user) { + return null + } + return user + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2015') { + throw new BadRequestException({ message: 'User not found', field: 'email' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) } - return user } async findUserByVerificationToken(token: string): Promise { - const verification = await this.prisma.verification.findUnique({ - where: { - verificationToken: token, - }, - include: { - user: true, - }, - }) - if (!verification) { - return null + try { + const verification = await this.prisma.verification.findUnique({ + where: { + verificationToken: token, + }, + include: { + user: true, + }, + }) + if (!verification) { + return null + } + return verification + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2015') { + throw new BadRequestException({ message: 'Verification not found', field: 'token' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) } - return verification } async updateEmailVerification(id: string) { - const result = await this.prisma.verification.update({ - where: { - userId: id, - }, - data: { - isEmailVerified: true, - user: { - update: { - isEmailVerified: true, + try { + const result = await this.prisma.verification.update({ + where: { + userId: id, + }, + data: { + isEmailVerified: true, + user: { + update: { + isEmailVerified: true, + }, }, }, - }, - }) - return result.isEmailVerified + }) + return result.isEmailVerified + } catch (e) { + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } } async updateVerificationToken(id: string) { - return await this.prisma.verification.update({ - where: { - userId: id, - }, - data: { - verificationToken: uuidv4(), - verificationTokenExpiry: addHours(new Date(), 24), - }, - include: { - user: true, - }, - }) + try { + return await this.prisma.verification.update({ + where: { + userId: id, + }, + data: { + verificationToken: uuidv4(), + verificationTokenExpiry: addHours(new Date(), 24), + }, + include: { + user: true, + }, + }) + } catch (e) { + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } + } + + async createPasswordResetToken(id: string, token: string) { + try { + return await this.prisma.resetPassword.upsert({ + where: { + userId: id, + }, + update: { + resetPasswordToken: token, + resetPasswordTokenExpiry: addHours(new Date(), 1), + resetPasswordEmailsSent: { + increment: 1, + }, + }, + create: { + resetPasswordToken: token, + resetPasswordTokenExpiry: addHours(new Date(), 1), + resetPasswordEmailsSent: 1, + user: { + connect: { + id, + }, + }, + }, + include: { + user: true, + }, + }) + } catch (e) { + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } + } + + async findUserByPasswordResetToken(token: string): Promise { + try { + const resetPassword = await this.prisma.resetPassword.findUnique({ + where: { + resetPasswordToken: token, + }, + include: { + user: true, + }, + }) + if (!resetPassword) { + return null + } + return resetPassword.user + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2015') { + throw new BadRequestException({ message: 'Invalid token', field: 'token' }) + } + } + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } + } + + async resetPasswordAndDeleteToken(userId: string, password: string) { + try { + return await this.prisma.$transaction([ + this.prisma.resetPassword.update({ + where: { + userId: userId, + }, + data: { + resetPasswordToken: null, + resetPasswordTokenExpiry: null, + resetPasswordEmailsSent: { + increment: 1, + }, + }, + include: { + user: true, + }, + }), + this.prisma.user.update({ + where: { + id: userId, + }, + data: { + password, + }, + }), + ]) + } catch (e) { + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) + } } async revokeToken(id: string, token: string): Promise { - const revokedToken = await this.prisma.revokedToken.create({ - data: { - token: token, - userId: id, - }, - include: { - user: true, - }, - }) - if (!revokedToken.user) { - return null + try { + const revokedToken = await this.prisma.revokedToken.create({ + data: { + token: token, + userId: id, + }, + include: { + user: true, + }, + }) + if (!revokedToken.user) { + return null + } + return revokedToken.user + } catch (e) { + this.logger.error(e?.message || e) + throw new InternalServerErrorException(e) } - return revokedToken.user } } diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index e19b25c..6fe6eac 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -21,8 +21,9 @@ export class UsersService { return await this.usersRepository.deleteUserById(id) } - async deleteAllUsers(): Promise { - return await this.usersRepository.deleteAllUsers() + async deleteAllUsers(): Promise<{ deleted: number }> { + const deleted = await this.usersRepository.deleteAllUsers() + return { deleted } } public async sendConfirmationEmail({ @@ -42,6 +43,27 @@ export class UsersService { html: `Hello ${name}!
Please confirm your email by clicking on the link below:
Confirm email`, subject: 'E-mail confirmation', }) + } catch (e) { + this.logger.error(e?.message || e) + } + } + + public async sendPasswordRecoveryEmail({ + email, + name, + passwordRecoveryToken, + }: { + email: string + name: string + passwordRecoveryToken: string + }) { + try { + await this.emailService.sendMail({ + from: 'Andrii ', + to: email, + html: `Hello ${name}!
To recover your password follow this link:
Confirm email. If it doesn't work, copy and paste the following link in your browser:
http://localhost:3000/confirm-email/${passwordRecoveryToken} `, + subject: 'Password recovery', + }) } catch (e) { this.logger.error(e) } diff --git a/src/prisma.service.ts b/src/prisma.service.ts index 4a215a9..7422d9d 100644 --- a/src/prisma.service.ts +++ b/src/prisma.service.ts @@ -3,6 +3,29 @@ import { PrismaClient } from '@prisma/client' @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { + // constructor() { + // super({ + // log: [ + // { + // emit: 'stdout', + // level: 'query', + // }, + // { + // emit: 'stdout', + // level: 'error', + // }, + // { + // emit: 'stdout', + // level: 'info', + // }, + // { + // emit: 'stdout', + // level: 'warn', + // }, + // ], + // }) + // } + async onModuleInit() { await this.$connect() }