add email verification
This commit is contained in:
parent
f6ea096842
commit
7c297879f9
@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.12.2",
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/apollo": "^13.1.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
@ -31,6 +32,9 @@
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@react-email/components": "^0.1.1",
|
||||
"@react-email/html": "^0.0.11",
|
||||
"@react-email/tailwind": "^1.0.5",
|
||||
"argon2": "^0.43.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
@ -43,6 +47,8 @@
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"prisma": "^6.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@ -61,7 +67,9 @@
|
||||
"@types/geoip-lite": "^1.4.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@ -9,15 +9,37 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String
|
||||
name String @unique
|
||||
displayName String @map("display_name")
|
||||
avatar String?
|
||||
bio String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String
|
||||
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")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Token {
|
||||
id String @id @default(uuid())
|
||||
token String @unique
|
||||
type TokenType
|
||||
expiresIn DateTime @map("expires_in")
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String? @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("tokens")
|
||||
}
|
||||
|
||||
enum TokenType {
|
||||
EMAIL_VERIFY
|
||||
|
||||
@@map("token_types")
|
||||
}
|
||||
|
||||
19
backend/src/core/config/mailer.config.ts
Normal file
19
backend/src/core/config/mailer.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { MailerOptions } from '@nestjs-modules/mailer'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
export function getMailConfig(configService: ConfigService): MailerOptions {
|
||||
return {
|
||||
transport: {
|
||||
host: configService.getOrThrow<string>('MAIL_HOST'),
|
||||
port: configService.getOrThrow<number>('MAIL_PORT'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: configService.getOrThrow<string>('MAIL_LOGIN'),
|
||||
pass: configService.getOrThrow<string>('MAIL_PASSWORD'),
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
from: `"TeaStream" ${configService.getOrThrow<string>('MAIL_LOGIN')}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { getGraphQLConfig } from '@/src/core/config/graphql.config'
|
||||
import { AccountModule } from '@/src/module/auth/account/account.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'
|
||||
import { IS_DEV } from '@/src/shared/util/is-dev.util'
|
||||
import { ApolloDriver } from '@nestjs/apollo'
|
||||
import { Module } from '@nestjs/common'
|
||||
@ -23,8 +25,10 @@ import { RedisModule } from './redis/redis.module'
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
MailModule,
|
||||
AccountModule,
|
||||
SessionModule,
|
||||
VerificationModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
@ -37,6 +37,7 @@ type Mutation {
|
||||
loginUser(data: LoginInput!): UserModel!
|
||||
logoutUser: Boolean!
|
||||
removeSession(id: String!): Boolean!
|
||||
verifyAccount(data: VerificationInput!): UserModel!
|
||||
}
|
||||
|
||||
type Query {
|
||||
@ -65,7 +66,13 @@ type UserModel {
|
||||
displayName: String!
|
||||
email: String!
|
||||
id: ID!
|
||||
isEmailVerified: Boolean!
|
||||
isVerified: Boolean!
|
||||
name: String!
|
||||
password: String!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
input VerificationInput {
|
||||
token: String!
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { VerificationService } from '@/src/module/auth/verification/verification.service';
|
||||
import { Module } from '@nestjs/common'
|
||||
import { AccountService } from './account.service'
|
||||
import { AccountResolver } from './account.resolver'
|
||||
|
||||
@Module({
|
||||
providers: [AccountResolver, AccountService],
|
||||
providers: [AccountResolver, AccountService, VerificationService],
|
||||
})
|
||||
export class AccountModule {}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { User } from '@/prisma/generated'
|
||||
import { PrismaService } from '@/src/core/prisma/prisma.service'
|
||||
import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input'
|
||||
import { VerificationService } from '@/src/module/auth/verification/verification.service'
|
||||
import { ConflictException, Injectable } from '@nestjs/common'
|
||||
import { hash } from 'argon2'
|
||||
|
||||
@ -8,6 +9,7 @@ import { hash } from 'argon2'
|
||||
export class AccountService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly verificationService: VerificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -41,7 +43,7 @@ export class AccountService {
|
||||
throw new ConflictException('Пользователь с такоей почтой уже существует')
|
||||
}
|
||||
|
||||
return this.prismaService.user.create({
|
||||
const user = await this.prismaService.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
@ -49,5 +51,9 @@ export class AccountService {
|
||||
displayName: name,
|
||||
},
|
||||
})
|
||||
|
||||
await this.verificationService.sendVerificationToken(user)
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,12 @@ export class UserModel implements User {
|
||||
@Field(() => String, { nullable: true })
|
||||
bio: string
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailVerified: boolean
|
||||
|
||||
@Field(() => Boolean)
|
||||
isVerified: boolean
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { VerificationService } from '@/src/module/auth/verification/verification.service';
|
||||
import { Module } from '@nestjs/common'
|
||||
import { SessionService } from './session.service'
|
||||
import { SessionResolver } from './session.resolver'
|
||||
|
||||
@Module({
|
||||
providers: [SessionResolver, SessionService],
|
||||
providers: [SessionResolver, SessionService, VerificationService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { PrismaService } from '@/src/core/prisma/prisma.service'
|
||||
import { RedisService } from '@/src/core/redis/redis.service'
|
||||
import { LoginInput } from '@/src/module/auth/session/inputs/login.input'
|
||||
import { VerificationService } from '@/src/module/auth/verification/verification.service'
|
||||
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
|
||||
import { destroySession, saveSession } from '@/src/shared/util/session.util'
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common'
|
||||
@ -20,6 +22,7 @@ export class SessionService {
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly verificationService: VerificationService,
|
||||
) {}
|
||||
|
||||
public async findByUser(req: Request) {
|
||||
@ -89,32 +92,16 @@ export class SessionService {
|
||||
throw new UnauthorizedException('Логин или пароль неверный')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
req.session.userId = user.id
|
||||
req.session.createdAt = new Date()
|
||||
req.session.metadata = getSessionMetadata(req, userAgent)
|
||||
if (!user.isEmailVerified) {
|
||||
await this.verificationService.sendVerificationToken(user)
|
||||
throw new BadRequestException('Аккаунт не верифицирован. Проверьте свою почту для подтверждения')
|
||||
}
|
||||
|
||||
req.session.save((error) => {
|
||||
if (error) {
|
||||
reject(new InternalServerErrorException('Не удалось сохранить сессию'))
|
||||
}
|
||||
|
||||
resolve(user)
|
||||
})
|
||||
})
|
||||
return saveSession(req, user, getSessionMetadata(req, userAgent))
|
||||
}
|
||||
|
||||
public async logout(req: Request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.session.destroy((error) => {
|
||||
if (error) {
|
||||
reject(new InternalServerErrorException('Не удалось завершить сессию'))
|
||||
}
|
||||
|
||||
req.res?.clearCookie(this.configService.getOrThrow('SESSION_NAME'))
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
return destroySession(req, this.configService)
|
||||
}
|
||||
|
||||
public clearSession(req: Request) {
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, InputType } from '@nestjs/graphql'
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator'
|
||||
|
||||
@InputType()
|
||||
export class VerificationInput {
|
||||
@Field(() => String)
|
||||
@IsUUID('4')
|
||||
@IsNotEmpty()
|
||||
public token: string
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { VerificationService } from './verification.service'
|
||||
import { VerificationResolver } from './verification.resolver'
|
||||
|
||||
@Module({
|
||||
providers: [VerificationResolver, VerificationService],
|
||||
})
|
||||
export class VerificationModule {}
|
||||
@ -0,0 +1,20 @@
|
||||
import { UserModel } from '@/src/module/auth/account/models/user.model'
|
||||
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.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 { VerificationService } from './verification.service'
|
||||
|
||||
@Resolver('Verification')
|
||||
export class VerificationResolver {
|
||||
constructor(private readonly verificationService: VerificationService) {}
|
||||
|
||||
@Mutation(() => UserModel, { name: 'verifyAccount' })
|
||||
public async verify(
|
||||
@Context() { req }: GqlContext,
|
||||
@Args('data') input: VerificationInput,
|
||||
@UserAgent() userAgent: string,
|
||||
) {
|
||||
return this.verificationService.verify(req, input, userAgent)
|
||||
}
|
||||
}
|
||||
66
backend/src/module/auth/verification/verification.service.ts
Normal file
66
backend/src/module/auth/verification/verification.service.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { PrismaService } from '@/src/core/prisma/prisma.service'
|
||||
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.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 { saveSession } from '@/src/shared/util/session.util'
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { TokenType, User } from '@prisma/generated'
|
||||
import { Request } from 'express'
|
||||
|
||||
@Injectable()
|
||||
export class VerificationService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async verify(req: Request, input: VerificationInput, userAgent: string) {
|
||||
const { token } = input
|
||||
const existingToken = await this.prismaService.token.findUnique({
|
||||
where: { token, type: TokenType.EMAIL_VERIFY },
|
||||
})
|
||||
|
||||
if (!existingToken) {
|
||||
throw new NotFoundException('Токен не найден')
|
||||
}
|
||||
|
||||
const hasExpired = new Date(existingToken.expiresIn) < new Date()
|
||||
|
||||
if (hasExpired) {
|
||||
throw new BadRequestException('Токен истек')
|
||||
}
|
||||
|
||||
const user = await this.prismaService.user.update({
|
||||
where: {
|
||||
id: existingToken.userId!, // todo fixme
|
||||
},
|
||||
data: {
|
||||
isEmailVerified: true,
|
||||
},
|
||||
})
|
||||
|
||||
await this.prismaService.token.delete({
|
||||
where: {
|
||||
id: existingToken.id,
|
||||
type: TokenType.EMAIL_VERIFY,
|
||||
},
|
||||
})
|
||||
|
||||
return saveSession(req, user, getSessionMetadata(req, userAgent))
|
||||
}
|
||||
|
||||
public async sendVerificationToken(user: User) {
|
||||
const verificationToken = await generateToken(
|
||||
this.prismaService,
|
||||
user,
|
||||
TokenType.EMAIL_VERIFY,
|
||||
true,
|
||||
)
|
||||
|
||||
await this.mailService.sendVerificationToken(user.email, verificationToken.token);
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
17
backend/src/module/libs/mail/mail.module.ts
Normal file
17
backend/src/module/libs/mail/mail.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { getMailConfig } from '@/src/core/config/mailer.config'
|
||||
import { MailerModule } from '@nestjs-modules/mailer'
|
||||
import { Global, Module } from '@nestjs/common'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { MailService } from './mail.service'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [MailerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: getMailConfig,
|
||||
inject: [ConfigService],
|
||||
})],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
30
backend/src/module/libs/mail/mail.service.ts
Normal file
30
backend/src/module/libs/mail/mail.service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Token } from '@prisma/generated'
|
||||
import VerificationTemplate from './templates/verification.template'
|
||||
import { MailerService } from '@nestjs-modules/mailer'
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { render } from '@react-email/components'
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
public async sendVerificationToken(email: string, token: Token['token']) {
|
||||
const domain = this.configService.getOrThrow<string>('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)
|
||||
}
|
||||
|
||||
public sendMail(email: string, subject: string, html: string) {
|
||||
return this.mailerService.sendMail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import * as React from 'react'
|
||||
import { Body, Head, Heading, Link, Preview, Section, Tailwind, Text } from '@react-email/components'
|
||||
import { Html } from '@react-email/html'
|
||||
|
||||
type VerificationTemplateProps = {
|
||||
domain: string
|
||||
token: string
|
||||
}
|
||||
const VerificationTemplate = (props: VerificationTemplateProps) => {
|
||||
const { domain, token } = props
|
||||
|
||||
const verificationLink = `${domain}/account/verify?token=${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-3-xl text-black font-bold">
|
||||
Подтверждение вашей почты
|
||||
</Heading>
|
||||
|
||||
<Text className="text-base text-black">
|
||||
Спасибо за регистрацию.
|
||||
Чтобы подтвердить свой адрес электронной почты, перейдите по следующей ссылке
|
||||
</Text>
|
||||
|
||||
<Link href={verificationLink} 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="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 VerificationTemplate
|
||||
38
backend/src/shared/util/generate-token.util.ts
Normal file
38
backend/src/shared/util/generate-token.util.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { PrismaService } from '@/src/core/prisma/prisma.service'
|
||||
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) {
|
||||
const token = isUUID
|
||||
? uuidv4()
|
||||
: Math.floor(Math.random() * (1_000_000 - 100_000) + 100_000).toString()
|
||||
|
||||
const expiresIn = new Date(new Date().getTime() + 15 * 60 * 1000)
|
||||
const existingToken = await prismaService.token.findFirst({
|
||||
where: {
|
||||
type,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingToken) {
|
||||
await prismaService.token.delete({ where: { id: existingToken.id } })
|
||||
}
|
||||
|
||||
return prismaService.token.create({
|
||||
data: {
|
||||
token,
|
||||
expiresIn,
|
||||
type,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
34
backend/src/shared/util/session.util.ts
Normal file
34
backend/src/shared/util/session.util.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { SessionInfo } from '@/src/shared/types/session-metadata.types'
|
||||
import { InternalServerErrorException } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { User } from '@prisma/generated'
|
||||
import { Request } from 'express'
|
||||
|
||||
export function saveSession(req: Request, user: User, metadata: SessionInfo) {
|
||||
return new Promise<User>((resolve, reject) => {
|
||||
req.session.createdAt = new Date()
|
||||
req.session.userId = user.id
|
||||
req.session.metadata = metadata
|
||||
|
||||
req.session.save((error) => {
|
||||
if (error) {
|
||||
reject(new InternalServerErrorException('Не удалось сохранить сессию'))
|
||||
}
|
||||
|
||||
resolve(user)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function destroySession(req: Request, configService: ConfigService) {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.session.destroy((error) => {
|
||||
if (error) {
|
||||
reject(new InternalServerErrorException('Не удалось завершить сессию'))
|
||||
}
|
||||
|
||||
req.res?.clearCookie(configService.getOrThrow('SESSION_NAME'))
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -21,6 +21,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
||||
|
||||
1754
backend/yarn.lock
1754
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user