diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7ee84f9..ca996d4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,20 +9,22 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique + id String @id @default(uuid()) + email String @unique password String - name String @unique - displayName String @map("display_name") + name String @unique + displayName String @map("display_name") avatar String? bio String? token Token[] - isVerified Boolean @default(false) @map("is_verified") - isEmailVerified Boolean @default(false) @map("is_email_verified") - isTotpEnabled Boolean @default(false) @map("is_totp_enabled") - totpSecret String? @map("totp_secret") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + isVerified Boolean @default(false) @map("is_verified") + isEmailVerified Boolean @default(false) @map("is_email_verified") + isTotpEnabled Boolean @default(false) @map("is_totp_enabled") + isDeactivated Boolean @default(false) @map("is_deactivated") + deactivatedAt DateTime? @map("deactivated_at") + totpSecret String? @map("totp_secret") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("users") } @@ -43,6 +45,7 @@ model Token { enum TokenType { EMAIL_VERIFY PASSWORD_RESET + DEACTIVATE_ACCOUNT @@map("token_types") } diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 500e7e1..c7a7996 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 { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module'; import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module' import { SessionModule } from '@/src/module/auth/session/session.module' import { TotpModule } from '@/src/module/auth/totp/totp.module' @@ -33,6 +34,7 @@ import { RedisModule } from './redis/redis.module' VerificationModule, PasswordRecoveryModule, TotpModule, + DeactivateModule, ], }) export class CoreModule {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index f2dc7d1..3be3ea8 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -18,6 +18,12 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date """ scalar DateTime +input DeactivateAccountInput { + email: String! + password: String! + pin: String +} + type DeviceModel { browser: String! os: String! @@ -45,6 +51,7 @@ input LoginInput { type Mutation { clearSessionCookie: Boolean! createUser(data: CreateUserInput!): UserModel! + deactivateAccount(data: DeactivateAccountInput!): AuthModel! disableTotp: Boolean! enableTotp(data: EnableTotpInput!): Boolean! loginUser(data: LoginInput!): AuthModel! @@ -94,9 +101,11 @@ type UserModel { avatar: String bio: String createdAt: DateTime! + deactivatedAt: DateTime displayName: String! email: String! id: ID! + isDeactivated: Boolean! isEmailVerified: Boolean! isTotpEnabled: Boolean! isVerified: Boolean! diff --git a/backend/src/module/auth/account/models/user.model.ts b/backend/src/module/auth/account/models/user.model.ts index c0c00ee..9294f71 100644 --- a/backend/src/module/auth/account/models/user.model.ts +++ b/backend/src/module/auth/account/models/user.model.ts @@ -33,6 +33,12 @@ export class UserModel implements User { @Field(() => Boolean) isTotpEnabled: boolean + @Field(() => Boolean) + isDeactivated: boolean + + @Field(() => Date, { nullable: true }) + deactivatedAt: Date + @Field(() => String, { nullable: true }) totpSecret: string diff --git a/backend/src/module/auth/deactivate/deactivate.module.ts b/backend/src/module/auth/deactivate/deactivate.module.ts new file mode 100644 index 0000000..f644ce2 --- /dev/null +++ b/backend/src/module/auth/deactivate/deactivate.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { DeactivateService } from './deactivate.service'; +import { DeactivateResolver } from './deactivate.resolver'; + +@Module({ + providers: [DeactivateResolver, DeactivateService], +}) +export class DeactivateModule {} diff --git a/backend/src/module/auth/deactivate/deactivate.resolver.ts b/backend/src/module/auth/deactivate/deactivate.resolver.ts new file mode 100644 index 0000000..abae648 --- /dev/null +++ b/backend/src/module/auth/deactivate/deactivate.resolver.ts @@ -0,0 +1,25 @@ +import { AuthModel } from '@/src/module/auth/account/models/auth.model' +import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input' +import { Authorization } from '@/src/shared/decorators/auth.decorator' +import { Authorized } from '@/src/shared/decorators/authorized.decorator' +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 { User } from '@prisma/generated' +import { DeactivateService } from './deactivate.service' + +@Resolver('Deactivate') +export class DeactivateResolver { + constructor(private readonly deactivateService: DeactivateService) {} + + @Authorization() + @Mutation(() => AuthModel, { name: 'deactivateAccount' }) + public async deactivate( + @Context() { req }: GqlContext, + @Args('data') input: DeactivateAccountInput, + @UserAgent() userAgent: string, + @Authorized() user: User, + ) { + return this.deactivateService.deactivate(req, input, user, userAgent) + } +} diff --git a/backend/src/module/auth/deactivate/deactivate.service.ts b/backend/src/module/auth/deactivate/deactivate.service.ts new file mode 100644 index 0000000..380f748 --- /dev/null +++ b/backend/src/module/auth/deactivate/deactivate.service.ts @@ -0,0 +1,94 @@ +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input' +import { MailService } from '@/src/module/libs/mail/mail.service' +import { SessionInfo } from '@/src/shared/types/session-metadata.types' +import { generateToken } from '@/src/shared/util/generate-token.util' +import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' +import { destroySession } from '@/src/shared/util/session.util' +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { TokenType, User } from '@prisma/generated' +import { verify } from 'argon2' +import { Request } from 'express' + +@Injectable() +export class DeactivateService { + constructor( + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + private readonly mailService: MailService, + ) { + } + + public async deactivate(req: Request, input: DeactivateAccountInput, user: User, userAgent: string) { + const { email, password, pin } = input + + if (email !== user.email) { + throw new BadRequestException('Неверная почта') + } + + const isValidPassword = await verify(user.password, password) + + if (!isValidPassword) { + throw new BadRequestException('Неверный пароль') + } + + if (!pin) { + await this.sendDeactivationToken(req, user, userAgent) + return { message: 'Требуется ввести код подтверждения' } + } + + await this.validateDeactivateToken(req, pin) + + return { user } + } + + private async validateDeactivateToken(req: Request, token: string) { + const existingToken = await this.prismaService.token.findUnique({ + where: { token, type: TokenType.DEACTIVATE_ACCOUNT }, + }) + + 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: { + isDeactivated: true, + deactivatedAt: new Date(), + }, + }) + + await this.prismaService.token.delete({ + where: { + id: existingToken.id, + type: TokenType.DEACTIVATE_ACCOUNT, + }, + }) + + return destroySession(req, this.configService) + } + + public async sendDeactivationToken(req: Request, user: User, userAgent: string) { + const deactivateToken = await generateToken( + this.prismaService, + user, + TokenType.DEACTIVATE_ACCOUNT, + false, + ) + + const metadata = getSessionMetadata(req, userAgent) + await this.mailService.sendDeactivateToken(user.email, deactivateToken.token, metadata) + + return true + } +} diff --git a/backend/src/module/auth/deactivate/inputs/deactivate-account.input.ts b/backend/src/module/auth/deactivate/inputs/deactivate-account.input.ts new file mode 100644 index 0000000..0cc32b4 --- /dev/null +++ b/backend/src/module/auth/deactivate/inputs/deactivate-account.input.ts @@ -0,0 +1,24 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, MinLength } from 'class-validator' + +@InputType() +export class DeactivateAccountInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + @IsEmail() + email: string + + @Field(() => String) + @IsString() + @IsNotEmpty() + @MinLength(8) + password: string + + @Field(() => String, { nullable: true }) + @IsString() + @IsNotEmpty() + @IsOptional() + @Length(6, 6) + pin: string +} diff --git a/backend/src/module/libs/mail/mail.service.ts b/backend/src/module/libs/mail/mail.service.ts index 0d2fdf6..b0744e4 100644 --- a/backend/src/module/libs/mail/mail.service.ts +++ b/backend/src/module/libs/mail/mail.service.ts @@ -1,3 +1,4 @@ +import { DeactivateTemplate } from '@/src/module/libs/mail/templates/deactivate.template' 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' @@ -18,16 +19,20 @@ export class MailService { const domain = this.configService.getOrThrow('ALLOWED_ORIGIN') const html = await render(VerificationTemplate({ domain, token })) - // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- todo fixme - return this.sendMail(email, 'Верификация аккаунта', html) + void this.sendMail(email, 'Верификация аккаунта', html) } 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) + void this.sendMail(email, 'Сброс пароля', html) + } + + public async sendDeactivateToken(email: string, token: Token['token'], metadata: SessionInfo) { + const html = await render(DeactivateTemplate({ token, metadata })) + + void this.sendMail(email, 'Деактивация аккаунта', html) } private sendMail(email: string, subject: string, html: string) { diff --git a/backend/src/module/libs/mail/templates/deactivate.template.tsx b/backend/src/module/libs/mail/templates/deactivate.template.tsx new file mode 100644 index 0000000..45b0ef6 --- /dev/null +++ b/backend/src/module/libs/mail/templates/deactivate.template.tsx @@ -0,0 +1,90 @@ +import type { SessionInfo } from '@/src/shared/types/session-metadata.types' +import { Body, Head, Heading, Link, Preview, Section, Tailwind, Text } from '@react-email/components' +import { Html } from '@react-email/html' +import * as React from 'react' + +interface DeactivateTemplateProps { + token: string + metadata: SessionInfo +} + +export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps) { + return ( + + + Деактивация аккаунта + + +
+ + Запрос на деактивацию аккаунта + + + Вы инициировали процесс деактивации вашего аккаунта на платформе + {' '} + TeaStream + . + +
+ +
+ + Код подтверждения: + + + {token} + + + Этот код действителен в течение 5 минут. + +
+ +
+ + Информация о запросе: + +
    +
  • + 🌍 Расположение: + {metadata.location.country} + , + {metadata.location.city} +
  • +
  • + 📱 Операционная система: + {metadata.device.os} +
  • +
  • + 🌐 Браузер: + {metadata.device.browser} +
  • +
  • + 💻 IP-адрес: + {metadata.ip} +
  • +
+ + Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение. + +
+ +
+ + Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу + {' '} + + help@teastream.ru + + . + +
+ +
+ + ) +}