add email verification

This commit is contained in:
Sergey Krylov 2025-07-04 05:10:08 +03:00
parent f6ea096842
commit 7c297879f9
21 changed files with 2110 additions and 60 deletions

View File

@ -23,6 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.12.2", "@apollo/server": "^4.12.2",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/apollo": "^13.1.0", "@nestjs/apollo": "^13.1.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
@ -31,6 +32,9 @@
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^6.10.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", "argon2": "^0.43.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
@ -43,6 +47,8 @@
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"prisma": "^6.10.1", "prisma": "^6.10.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -61,7 +67,9 @@
"@types/geoip-lite": "^1.4.4", "@types/geoip-lite": "^1.4.4",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/react": "^19.1.8",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

@ -9,15 +9,37 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String password String
name String @unique name String @unique
displayName String @map("display_name") displayName String @map("display_name")
avatar String? avatar String?
bio String? bio String?
createdAt DateTime @default(now()) @map("created_at") token Token[]
updatedAt DateTime @updatedAt @map("updated_at") 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") @@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")
}

View 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')}`,
},
}
}

View File

@ -1,6 +1,8 @@
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 { 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 { MailModule } from '@/src/module/libs/mail/mail.module'
import { IS_DEV } from '@/src/shared/util/is-dev.util' import { IS_DEV } from '@/src/shared/util/is-dev.util'
import { ApolloDriver } from '@nestjs/apollo' import { ApolloDriver } from '@nestjs/apollo'
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
@ -23,8 +25,10 @@ import { RedisModule } from './redis/redis.module'
}), }),
PrismaModule, PrismaModule,
RedisModule, RedisModule,
MailModule,
AccountModule, AccountModule,
SessionModule, SessionModule,
VerificationModule,
], ],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -37,6 +37,7 @@ type Mutation {
loginUser(data: LoginInput!): UserModel! loginUser(data: LoginInput!): UserModel!
logoutUser: Boolean! logoutUser: Boolean!
removeSession(id: String!): Boolean! removeSession(id: String!): Boolean!
verifyAccount(data: VerificationInput!): UserModel!
} }
type Query { type Query {
@ -65,7 +66,13 @@ type UserModel {
displayName: String! displayName: String!
email: String! email: String!
id: ID! id: ID!
isEmailVerified: Boolean!
isVerified: Boolean!
name: String! name: String!
password: String! password: String!
updatedAt: DateTime! updatedAt: DateTime!
} }
input VerificationInput {
token: String!
}

View File

@ -1,8 +1,9 @@
import { VerificationService } from '@/src/module/auth/verification/verification.service';
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { AccountService } from './account.service' import { AccountService } from './account.service'
import { AccountResolver } from './account.resolver' import { AccountResolver } from './account.resolver'
@Module({ @Module({
providers: [AccountResolver, AccountService], providers: [AccountResolver, AccountService, VerificationService],
}) })
export class AccountModule {} export class AccountModule {}

View File

@ -1,6 +1,7 @@
import { User } from '@/prisma/generated' import { User } from '@/prisma/generated'
import { PrismaService } from '@/src/core/prisma/prisma.service' import { PrismaService } from '@/src/core/prisma/prisma.service'
import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input' 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 { ConflictException, Injectable } from '@nestjs/common'
import { hash } from 'argon2' import { hash } from 'argon2'
@ -8,6 +9,7 @@ import { hash } from 'argon2'
export class AccountService { export class AccountService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly verificationService: VerificationService,
) { ) {
} }
@ -41,7 +43,7 @@ export class AccountService {
throw new ConflictException('Пользователь с такоей почтой уже существует') throw new ConflictException('Пользователь с такоей почтой уже существует')
} }
return this.prismaService.user.create({ const user = await this.prismaService.user.create({
data: { data: {
name, name,
email, email,
@ -49,5 +51,9 @@ export class AccountService {
displayName: name, displayName: name,
}, },
}) })
await this.verificationService.sendVerificationToken(user)
return user
} }
} }

View File

@ -24,6 +24,12 @@ export class UserModel implements User {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
bio: string bio: string
@Field(() => Boolean)
isEmailVerified: boolean
@Field(() => Boolean)
isVerified: boolean
@Field(() => Date) @Field(() => Date)
createdAt: Date createdAt: Date

View File

@ -1,8 +1,9 @@
import { VerificationService } from '@/src/module/auth/verification/verification.service';
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { SessionService } from './session.service' import { SessionService } from './session.service'
import { SessionResolver } from './session.resolver' import { SessionResolver } from './session.resolver'
@Module({ @Module({
providers: [SessionResolver, SessionService], providers: [SessionResolver, SessionService, VerificationService],
}) })
export class SessionModule {} export class SessionModule {}

View File

@ -1,11 +1,13 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { PrismaService } from '@/src/core/prisma/prisma.service'
import { RedisService } from '@/src/core/redis/redis.service' import { RedisService } from '@/src/core/redis/redis.service'
import { LoginInput } from '@/src/module/auth/session/inputs/login.input' 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 { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
import { destroySession, saveSession } from '@/src/shared/util/session.util'
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
InternalServerErrorException,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common' } from '@nestjs/common'
@ -20,6 +22,7 @@ export class SessionService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly redisService: RedisService, private readonly redisService: RedisService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly verificationService: VerificationService,
) {} ) {}
public async findByUser(req: Request) { public async findByUser(req: Request) {
@ -89,32 +92,16 @@ export class SessionService {
throw new UnauthorizedException('Логин или пароль неверный') throw new UnauthorizedException('Логин или пароль неверный')
} }
return new Promise((resolve, reject) => { if (!user.isEmailVerified) {
req.session.userId = user.id await this.verificationService.sendVerificationToken(user)
req.session.createdAt = new Date() throw new BadRequestException('Аккаунт не верифицирован. Проверьте свою почту для подтверждения')
req.session.metadata = getSessionMetadata(req, userAgent) }
req.session.save((error) => { return saveSession(req, user, getSessionMetadata(req, userAgent))
if (error) {
reject(new InternalServerErrorException('Не удалось сохранить сессию'))
}
resolve(user)
})
})
} }
public async logout(req: Request) { public async logout(req: Request) {
return new Promise((resolve, reject) => { return destroySession(req, this.configService)
req.session.destroy((error) => {
if (error) {
reject(new InternalServerErrorException('Не удалось завершить сессию'))
}
req.res?.clearCookie(this.configService.getOrThrow('SESSION_NAME'))
resolve(true)
})
})
} }
public clearSession(req: Request) { public clearSession(req: Request) {

View File

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

View File

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

View File

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

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

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

View 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,
})
}
}

View File

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

View 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,
},
})
}

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

View File

@ -21,6 +21,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false,
"jsx": "react"
} }
} }

File diff suppressed because it is too large Load Diff