add password recovery
This commit is contained in:
parent
7c297879f9
commit
7de0962ed6
@ -40,6 +40,7 @@ model Token {
|
||||
|
||||
enum TokenType {
|
||||
EMAIL_VERIFY
|
||||
PASSWORD_RESET
|
||||
|
||||
@@map("token_types")
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -56,7 +56,6 @@ export class VerificationService {
|
||||
this.prismaService,
|
||||
user,
|
||||
TokenType.EMAIL_VERIFY,
|
||||
true,
|
||||
)
|
||||
|
||||
await this.mailService.sendVerificationToken(user.email, verificationToken.token);
|
||||
|
||||
@ -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<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({
|
||||
to: email,
|
||||
subject,
|
||||
|
||||
@ -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
|
||||
@ -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 'Пароли не совпадают'
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user