diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0c0df14..e5a4a27 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -40,6 +40,7 @@ model Token { enum TokenType { EMAIL_VERIFY + PASSWORD_RESET @@map("token_types") } diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 377cc93..e151419 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -1,5 +1,6 @@ import { getGraphQLConfig } from '@/src/core/config/graphql.config' import { AccountModule } from '@/src/module/auth/account/account.module' +import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module' import { SessionModule } from '@/src/module/auth/session/session.module' import { VerificationModule } from '@/src/module/auth/verification/verification.module' import { MailModule } from '@/src/module/libs/mail/mail.module' @@ -29,6 +30,7 @@ import { RedisModule } from './redis/redis.module' AccountModule, SessionModule, VerificationModule, + PasswordRecoveryModule, ], }) export class CoreModule {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index 6ee5eda..fe3b2e2 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -37,15 +37,27 @@ type Mutation { loginUser(data: LoginInput!): UserModel! logoutUser: Boolean! removeSession(id: String!): Boolean! + resetPassword(data: ResetPasswordInput!): Boolean! + setNewPassword(data: NewPasswordInput!): Boolean! verifyAccount(data: VerificationInput!): UserModel! } +input NewPasswordInput { + password: String! + passwordRepeat: String! + token: String! +} + type Query { findCurrentSession: SessionModel! findProfile: UserModel! findSessionsByUser: [SessionModel!]! } +input ResetPasswordInput { + email: String! +} + type SessionMetadataModel { device: DeviceModel! ip: String! diff --git a/backend/src/module/auth/account/inputs/create-user.input.ts b/backend/src/module/auth/account/inputs/create-user.input.ts index 890609f..b1befbb 100644 --- a/backend/src/module/auth/account/inputs/create-user.input.ts +++ b/backend/src/module/auth/account/inputs/create-user.input.ts @@ -3,19 +3,19 @@ import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validat @InputType() export class CreateUserInput { - @Field() + @Field(() => String) @IsString() @IsNotEmpty() @Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/) name: string - @Field() + @Field(() => String) @IsString() @IsNotEmpty() @IsEmail() email: string - @Field() + @Field(() => String) @IsString() @IsNotEmpty() @MinLength(8) diff --git a/backend/src/module/auth/password-recovery/input/new-password.input.ts b/backend/src/module/auth/password-recovery/input/new-password.input.ts new file mode 100644 index 0000000..81b34ae --- /dev/null +++ b/backend/src/module/auth/password-recovery/input/new-password.input.ts @@ -0,0 +1,26 @@ +import { + IsPasswordMatchingConstraintDecorator, +} from '@/src/shared/decorators/is-password-matching-constraint.decorator' +import { Field, InputType } from '@nestjs/graphql' +import { IsNotEmpty, IsString, IsUUID, MinLength, Validate } from 'class-validator' + +@InputType() +export class NewPasswordInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + @MinLength(8) + password: string + + @Field(() => String) + @IsString() + @IsNotEmpty() + @MinLength(8) + @Validate(IsPasswordMatchingConstraintDecorator) + passwordRepeat: string + + @Field(() => String) + @IsUUID('4') + @IsNotEmpty() + token: string +} diff --git a/backend/src/module/auth/password-recovery/input/reset-password.input.ts b/backend/src/module/auth/password-recovery/input/reset-password.input.ts new file mode 100644 index 0000000..4fd9f22 --- /dev/null +++ b/backend/src/module/auth/password-recovery/input/reset-password.input.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { IsEmail, IsNotEmpty } from 'class-validator'; + +@InputType() +export class ResetPasswordInput { + + @Field(() => String) + @IsNotEmpty() + @IsEmail() + email: string +} diff --git a/backend/src/module/auth/password-recovery/password-recovery.module.ts b/backend/src/module/auth/password-recovery/password-recovery.module.ts new file mode 100644 index 0000000..7fb51e5 --- /dev/null +++ b/backend/src/module/auth/password-recovery/password-recovery.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PasswordRecoveryService } from './password-recovery.service'; +import { PasswordRecoveryResolver } from './password-recovery.resolver'; + +@Module({ + providers: [PasswordRecoveryResolver, PasswordRecoveryService], +}) +export class PasswordRecoveryModule {} diff --git a/backend/src/module/auth/password-recovery/password-recovery.resolver.ts b/backend/src/module/auth/password-recovery/password-recovery.resolver.ts new file mode 100644 index 0000000..ac0de9c --- /dev/null +++ b/backend/src/module/auth/password-recovery/password-recovery.resolver.ts @@ -0,0 +1,25 @@ +import { NewPasswordInput } from '@/src/module/auth/password-recovery/input/new-password.input' +import { ResetPasswordInput } from '@/src/module/auth/password-recovery/input/reset-password.input' +import { UserAgent } from '@/src/shared/decorators/user-agent.decorator' +import { GqlContext } from '@/src/shared/types/gql-context.types' +import { Args, Context, Mutation, Resolver } from '@nestjs/graphql' +import { PasswordRecoveryService } from './password-recovery.service' + +@Resolver('PasswordRecovery') +export class PasswordRecoveryResolver { + constructor(private readonly passwordRecoveryService: PasswordRecoveryService) {} + + @Mutation(() => Boolean, { name: 'resetPassword' }) + public async resetPassword( + @Context() { req }: GqlContext, + @Args('data') input: ResetPasswordInput, + @UserAgent() userAgent: string, + ) { + return this.passwordRecoveryService.resetPassword(req, input, userAgent) + } + + @Mutation(() => Boolean, { name: 'setNewPassword' }) + public async setNewPassword(@Args('data') input: NewPasswordInput) { + return this.passwordRecoveryService.setNewPassword(input) + } +} diff --git a/backend/src/module/auth/password-recovery/password-recovery.service.ts b/backend/src/module/auth/password-recovery/password-recovery.service.ts new file mode 100644 index 0000000..0670224 --- /dev/null +++ b/backend/src/module/auth/password-recovery/password-recovery.service.ts @@ -0,0 +1,70 @@ +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { NewPasswordInput } from '@/src/module/auth/password-recovery/input/new-password.input' +import { ResetPasswordInput } from '@/src/module/auth/password-recovery/input/reset-password.input' +import { MailService } from '@/src/module/libs/mail/mail.service' +import { generateToken } from '@/src/shared/util/generate-token.util' +import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' +import { TokenType } from '@prisma/generated' +import { hash } from 'argon2' +import { Request } from 'express' + +@Injectable() +export class PasswordRecoveryService { + constructor( + private readonly prismaService: PrismaService, + private readonly mailService: MailService, + ) { + } + + public async resetPassword(req: Request, input: ResetPasswordInput, userAgent: string) { + const { email } = input + const user = await this.prismaService.user.findFirst({ + where: { email }, + }) + if (!user) { + throw new NotFoundException('Пользователь с такой почтой не найден') + } + + const resetToken = await generateToken(this.prismaService, user, TokenType.PASSWORD_RESET) + const metadata = getSessionMetadata(req, userAgent) + await this.mailService.sendPasswordResetToken(user.email, resetToken.token, metadata) + + return true + } + + public async setNewPassword(input: NewPasswordInput) { + const { password, token } = input + + const existingToken = await this.prismaService.token.findUnique({ + where: { token, type: TokenType.PASSWORD_RESET }, + }) + + if (!existingToken) { + throw new NotFoundException('Токен не найден') + } + + const hasExpired = new Date(existingToken.expiresIn) < new Date() + if (hasExpired) { + throw new BadRequestException('Токен истек') + } + + await this.prismaService.user.update({ + where: { + id: existingToken.userId!, // todo fixme + }, + data: { + password: await hash(password), + }, + }) + + await this.prismaService.token.delete({ + where: { + id: existingToken.id, + type: TokenType.PASSWORD_RESET, + }, + }) + + return true + } +} diff --git a/backend/src/module/auth/verification/verification.service.ts b/backend/src/module/auth/verification/verification.service.ts index b49c787..8fc5e00 100644 --- a/backend/src/module/auth/verification/verification.service.ts +++ b/backend/src/module/auth/verification/verification.service.ts @@ -56,7 +56,6 @@ export class VerificationService { this.prismaService, user, TokenType.EMAIL_VERIFY, - true, ) await this.mailService.sendVerificationToken(user.email, verificationToken.token); diff --git a/backend/src/module/libs/mail/mail.service.ts b/backend/src/module/libs/mail/mail.service.ts index 1ca14df..0d2fdf6 100644 --- a/backend/src/module/libs/mail/mail.service.ts +++ b/backend/src/module/libs/mail/mail.service.ts @@ -1,3 +1,5 @@ +import PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template' +import { SessionInfo } from '@/src/shared/types/session-metadata.types' import { Token } from '@prisma/generated' import VerificationTemplate from './templates/verification.template' import { MailerService } from '@nestjs-modules/mailer' @@ -20,7 +22,15 @@ export class MailService { return this.sendMail(email, 'Верификация аккаунта', html) } - public sendMail(email: string, subject: string, html: string) { + public async sendPasswordResetToken(email: string, token: Token['token'], metadata: SessionInfo) { + const domain = this.configService.getOrThrow('ALLOWED_ORIGIN') + const html = await render(PasswordRecoveryTemplate({ domain, token, metadata })) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- todo fixme + return this.sendMail(email, 'Сброс пароля', html) + } + + private sendMail(email: string, subject: string, html: string) { return this.mailerService.sendMail({ to: email, subject, diff --git a/backend/src/module/libs/mail/templates/password-recovery.template.tsx b/backend/src/module/libs/mail/templates/password-recovery.template.tsx new file mode 100644 index 0000000..3a6500f --- /dev/null +++ b/backend/src/module/libs/mail/templates/password-recovery.template.tsx @@ -0,0 +1,87 @@ +import * as React from 'react' +import { Body, Head, Heading, Preview, Section, Tailwind, Text, Link } from '@react-email/components' +import { SessionInfo } from '@/src/shared/types/session-metadata.types' +import { Html } from '@react-email/html' + +type PasswordRecoveryTemplateProps = { + domain: string + token: string + metadata: SessionInfo +} + +const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => { + const { domain, metadata, token } = props + const resetLink = `${domain}/account/recovery/${token}` + + return ( + + + Сброс пароля + + +
+ + Сброс пароля + + + Вы запросили сброс пароля для вашей учетной записи. + + + Чтобы создать новый пароль, нажмите на ссылку ниже: + + + Сбросить пароль + +
+ +
+ + Информация о запросе: + +
    +
  • + 🌍 Расположение: + {metadata.location.country} + , + {metadata.location.city} +
  • +
  • + 📱 Операционная система: + {metadata.device.os} +
  • +
  • + 🌐 Браузер: + {metadata.device.browser} +
  • +
  • + 💻 IP-адрес: + {metadata.ip} +
  • +
+ + Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение. + +
+ +
+ + Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу + {' '} + + help@teastream.ru + + . + +
+ +
+ + ) +} + +export default PasswordRecoveryTemplate diff --git a/backend/src/shared/decorators/is-password-matching-constraint.decorator.ts b/backend/src/shared/decorators/is-password-matching-constraint.decorator.ts new file mode 100644 index 0000000..4aa320e --- /dev/null +++ b/backend/src/shared/decorators/is-password-matching-constraint.decorator.ts @@ -0,0 +1,14 @@ +import { NewPasswordInput } from '@/src/module/auth/password-recovery/input/new-password.input' +import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' + +@ValidatorConstraint({ name: 'IsPasswordMatching', async: false }) +export class IsPasswordMatchingConstraintDecorator implements ValidatorConstraintInterface { + public validate(passwordRepeat: string, args: ValidationArguments): boolean { + const values = args.object as NewPasswordInput + return values.password === passwordRepeat + } + + public defaultMessage(): string { + return 'Пароли не совпадают' + } +} diff --git a/backend/src/shared/util/generate-token.util.ts b/backend/src/shared/util/generate-token.util.ts index 9e4eeaf..c3c3bcf 100644 --- a/backend/src/shared/util/generate-token.util.ts +++ b/backend/src/shared/util/generate-token.util.ts @@ -3,7 +3,7 @@ import { $Enums, User } from '@prisma/generated' import TokenType = $Enums.TokenType import { v4 as uuidv4 } from 'uuid' -export async function generateToken(prismaService: PrismaService, user: User, type: TokenType, isUUID: boolean = false) { +export async function generateToken(prismaService: PrismaService, user: User, type: TokenType, isUUID: boolean = true) { const token = isUUID ? uuidv4() : Math.floor(Math.random() * (1_000_000 - 100_000) + 100_000).toString()