add password recovery

This commit is contained in:
Sergey Krylov 2025-07-04 06:06:46 +03:00
parent 7c297879f9
commit 7de0962ed6
14 changed files with 271 additions and 6 deletions

View File

@ -40,6 +40,7 @@ model Token {
enum TokenType { enum TokenType {
EMAIL_VERIFY EMAIL_VERIFY
PASSWORD_RESET
@@map("token_types") @@map("token_types")
} }

View File

@ -1,5 +1,6 @@
import { getGraphQLConfig } from '@/src/core/config/graphql.config' import { getGraphQLConfig } from '@/src/core/config/graphql.config'
import { AccountModule } from '@/src/module/auth/account/account.module' 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 { SessionModule } from '@/src/module/auth/session/session.module'
import { VerificationModule } from '@/src/module/auth/verification/verification.module' import { VerificationModule } from '@/src/module/auth/verification/verification.module'
import { MailModule } from '@/src/module/libs/mail/mail.module' import { MailModule } from '@/src/module/libs/mail/mail.module'
@ -29,6 +30,7 @@ import { RedisModule } from './redis/redis.module'
AccountModule, AccountModule,
SessionModule, SessionModule,
VerificationModule, VerificationModule,
PasswordRecoveryModule,
], ],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -37,15 +37,27 @@ type Mutation {
loginUser(data: LoginInput!): UserModel! loginUser(data: LoginInput!): UserModel!
logoutUser: Boolean! logoutUser: Boolean!
removeSession(id: String!): Boolean! removeSession(id: String!): Boolean!
resetPassword(data: ResetPasswordInput!): Boolean!
setNewPassword(data: NewPasswordInput!): Boolean!
verifyAccount(data: VerificationInput!): UserModel! verifyAccount(data: VerificationInput!): UserModel!
} }
input NewPasswordInput {
password: String!
passwordRepeat: String!
token: String!
}
type Query { type Query {
findCurrentSession: SessionModel! findCurrentSession: SessionModel!
findProfile: UserModel! findProfile: UserModel!
findSessionsByUser: [SessionModel!]! findSessionsByUser: [SessionModel!]!
} }
input ResetPasswordInput {
email: String!
}
type SessionMetadataModel { type SessionMetadataModel {
device: DeviceModel! device: DeviceModel!
ip: String! ip: String!

View File

@ -3,19 +3,19 @@ import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validat
@InputType() @InputType()
export class CreateUserInput { export class CreateUserInput {
@Field() @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/) @Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/)
name: string name: string
@Field() @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsEmail() @IsEmail()
email: string email: string
@Field() @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -56,7 +56,6 @@ export class VerificationService {
this.prismaService, this.prismaService,
user, user,
TokenType.EMAIL_VERIFY, TokenType.EMAIL_VERIFY,
true,
) )
await this.mailService.sendVerificationToken(user.email, verificationToken.token); await this.mailService.sendVerificationToken(user.email, verificationToken.token);

View File

@ -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 { Token } from '@prisma/generated'
import VerificationTemplate from './templates/verification.template' import VerificationTemplate from './templates/verification.template'
import { MailerService } from '@nestjs-modules/mailer' import { MailerService } from '@nestjs-modules/mailer'
@ -20,7 +22,15 @@ export class MailService {
return this.sendMail(email, 'Верификация аккаунта', html) 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<string>('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({ return this.mailerService.sendMail({
to: email, to: email,
subject, subject,

View File

@ -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 (
<Html>
<Head />
<Preview>Сброс пароля</Preview>
<Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center mb-8">
<Heading className="text-3xl text-black font-bold">
Сброс пароля
</Heading>
<Text className="text-black text-base mt-2">
Вы запросили сброс пароля для вашей учетной записи.
</Text>
<Text className="text-black text-base mt-2">
Чтобы создать новый пароль, нажмите на ссылку ниже:
</Text>
<Link href={resetLink} className="inline-flex justify-center items-center rounded-full text-sm font-medium text-white bg-[#18B9AE] px-5 py-2">
Сбросить пароль
</Link>
</Section>
<Section className="bg-gray-100 rounded-lg p-6 mb-6">
<Heading
className="text-xl font-semibold text-[#18B9AE]"
>
Информация о запросе:
</Heading>
<ul className="list-disc list-inside text-black mt-2">
<li>
🌍 Расположение:
{metadata.location.country}
,
{metadata.location.city}
</li>
<li>
📱 Операционная система:
{metadata.device.os}
</li>
<li>
🌐 Браузер:
{metadata.device.browser}
</li>
<li>
💻 IP-адрес:
{metadata.ip}
</li>
</ul>
<Text className="text-gray-600 mt-2">
Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение.
</Text>
</Section>
<Section className="text-center mt-8">
<Text className="text-gray-600">
Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу
{' '}
<Link
href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline"
>
help@teastream.ru
</Link>
.
</Text>
</Section>
</Body>
</Tailwind>
</Html>
)
}
export default PasswordRecoveryTemplate

View File

@ -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 'Пароли не совпадают'
}
}

View File

@ -3,7 +3,7 @@ import { $Enums, User } from '@prisma/generated'
import TokenType = $Enums.TokenType import TokenType = $Enums.TokenType
import { v4 as uuidv4 } from 'uuid' 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 const token = isUUID
? uuidv4() ? uuidv4()
: Math.floor(Math.random() * (1_000_000 - 100_000) + 100_000).toString() : Math.floor(Math.random() * (1_000_000 - 100_000) + 100_000).toString()