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()