add password recovery
This commit is contained in:
parent
7c297879f9
commit
7de0962ed6
@ -40,6 +40,7 @@ model Token {
|
|||||||
|
|
||||||
enum TokenType {
|
enum TokenType {
|
||||||
EMAIL_VERIFY
|
EMAIL_VERIFY
|
||||||
|
PASSWORD_RESET
|
||||||
|
|
||||||
@@map("token_types")
|
@@map("token_types")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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!
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user