├── bun.lockb ├── tsconfig.build.json ├── nest-cli.json ├── src ├── prisma │ ├── prisma.module.ts │ └── prisma.service.ts ├── user │ ├── user.module.ts │ ├── dto │ │ └── update-user.dto.ts │ ├── user.controller.ts │ └── user.service.ts ├── auth │ ├── password-recovery │ │ ├── dto │ │ │ ├── reset-password.dto.ts │ │ │ └── new-password.dto.ts │ │ ├── password-recovery.module.ts │ │ ├── password-recovery.controller.ts │ │ └── password-recovery.service.ts │ ├── provider │ │ ├── services │ │ │ ├── types │ │ │ │ ├── provider-options.types.ts │ │ │ │ ├── base-provider-options.types.ts │ │ │ │ └── user-info.types.ts │ │ │ ├── google.provider.ts │ │ │ ├── yandex.provider.ts │ │ │ └── base-oauth.service.ts │ │ ├── provider.constants.ts │ │ ├── provider.service.ts │ │ └── provider.module.ts │ ├── two-factor-auth │ │ ├── two-factor-auth.module.ts │ │ └── two-factor-auth.service.ts │ ├── email-confirmation │ │ ├── dto │ │ │ └── confirmation.dto.ts │ │ ├── email-confirmation.module.ts │ │ ├── email-confirmation.controller.ts │ │ └── email-confirmation.service.ts │ ├── decorators │ │ ├── roles.decorator.ts │ │ ├── auth.decorator.ts │ │ └── authorized.decorator.ts │ ├── dto │ │ ├── login.dto.ts │ │ └── register.dto.ts │ ├── auth.module.ts │ ├── guards │ │ ├── auth.guard.ts │ │ ├── provider.guard.ts │ │ └── roles.guard.ts │ ├── auth.controller.ts │ └── auth.service.ts ├── express-session.d.ts ├── libs │ ├── mail │ │ ├── mail.module.ts │ │ ├── templates │ │ │ ├── two-factor-auth.template.tsx │ │ │ ├── reset-password.template.tsx │ │ │ └── confirmation.template.tsx │ │ └── mail.service.ts │ └── common │ │ ├── utils │ │ ├── is-dev.util.ts │ │ ├── parse-boolean.util.ts │ │ └── ms.util.ts │ │ └── decorators │ │ └── is-passwords-matching-constraint.decorator.ts ├── config │ ├── recaptcha.config.ts │ ├── mailer.config.ts │ └── providers.config.ts ├── app.module.ts └── main.ts ├── .prettierrc ├── .gitignore ├── .eslintrc.js ├── tsconfig.json ├── README.md ├── prisma └── schema.prisma ├── docker-compose.yml ├── .env.example └── package.json /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeaCoder52/nestjs-full-authorization/HEAD/bun.lockb -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { PrismaService } from './prisma.service' 4 | 5 | @Global() 6 | @Module({ 7 | providers: [PrismaService], 8 | exports: [PrismaService] 9 | }) 10 | export class PrismaModule {} 11 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { UserController } from './user.controller' 4 | import { UserService } from './user.service' 5 | 6 | @Module({ 7 | controllers: [UserController], 8 | providers: [UserService] 9 | }) 10 | export class UserModule {} 11 | -------------------------------------------------------------------------------- /src/auth/password-recovery/dto/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator' 2 | 3 | export class ResetPasswordDto { 4 | @IsEmail({}, { message: 'Введите корректный адрес электронной почты.' }) 5 | @IsNotEmpty({ message: 'Поле email не может быть пустым.' }) 6 | email: string 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/provider/services/types/provider-options.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Опции провайдера OAuth. 3 | * 4 | * Этот тип описывает параметры, необходимые для настройки провайдера OAuth. 5 | */ 6 | export type TypeProviderOptions = { 7 | scopes: string[] 8 | client_id: string 9 | client_secret: string 10 | } 11 | -------------------------------------------------------------------------------- /src/express-session.d.ts: -------------------------------------------------------------------------------- 1 | import 'express-session' 2 | 3 | declare module 'express-session' { 4 | /** 5 | * Расширяет стандартный интерфейс SessionData, добавляя свойство userId. 6 | * Свойство userId будет доступно в объекте сессии. 7 | */ 8 | interface SessionData { 9 | userId?: string 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/two-factor-auth/two-factor-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { MailService } from '@/libs/mail/mail.service' 4 | 5 | import { TwoFactorAuthService } from './two-factor-auth.service' 6 | 7 | @Module({ 8 | providers: [TwoFactorAuthService, MailService] 9 | }) 10 | export class TwoFactorAuthModule {} 11 | -------------------------------------------------------------------------------- /src/auth/password-recovery/dto/new-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, MinLength } from 'class-validator' 2 | 3 | export class NewPasswordDto { 4 | @IsString({ message: 'Пароль должен быть строкой.' }) 5 | @MinLength(6, { message: 'Пароль должен содержать не менее 6 символов.' }) 6 | @IsNotEmpty({ message: 'Поле новый пароль не может быть пустым.' }) 7 | password: string 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/provider/services/types/base-provider-options.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Опции базового провайдера OAuth. 3 | * 4 | * Этот тип описывает необходимые параметры для аутентификации через OAuth. 5 | */ 6 | export type TypeBaseProviderOptions = { 7 | name: string 8 | authorize_url: string 9 | access_url: string 10 | profile_url: string 11 | scopes: string[] 12 | client_id: string 13 | client_secret: string 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/email-confirmation/dto/confirmation.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator' 2 | 3 | /** 4 | * DTO для подтверждения электронной почты. 5 | */ 6 | export class ConfirmationDto { 7 | /** 8 | * Токен подтверждения. 9 | * @example "123e4567-e89b-12d3-a456-426614174000" 10 | */ 11 | @IsString({ message: 'Токен должен быть строкой.' }) 12 | @IsNotEmpty({ message: 'Поле токен не может быть пустым.' }) 13 | token: string 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/provider/services/types/user-info.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Информация о пользователе, полученная от OAuth-провайдера. 3 | * 4 | * Этот тип описывает структуру данных, содержащую информацию о пользователе, 5 | * включая токены доступа и информацию о провайдере. 6 | */ 7 | export type TypeUserInfo = { 8 | id: string 9 | picture: string 10 | name: string 11 | email: string 12 | access_token?: string | null 13 | refresh_token?: string 14 | expires_at?: number 15 | provider: string 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/password-recovery/password-recovery.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { MailService } from '@/libs/mail/mail.service' 4 | import { UserService } from '@/user/user.service' 5 | 6 | import { PasswordRecoveryController } from './password-recovery.controller' 7 | import { PasswordRecoveryService } from './password-recovery.service' 8 | 9 | @Module({ 10 | controllers: [PasswordRecoveryController], 11 | providers: [PasswordRecoveryService, UserService, MailService] 12 | }) 13 | export class PasswordRecoveryModule {} 14 | -------------------------------------------------------------------------------- /src/libs/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { MailerModule } from '@nestjs-modules/mailer' 2 | import { Module } from '@nestjs/common' 3 | import { ConfigModule, ConfigService } from '@nestjs/config' 4 | 5 | import { getMailerConfig } from '@/config/mailer.config' 6 | 7 | import { MailService } from './mail.service' 8 | 9 | @Module({ 10 | imports: [ 11 | MailerModule.forRootAsync({ 12 | imports: [ConfigModule], 13 | useFactory: getMailerConfig, 14 | inject: [ConfigService] 15 | }) 16 | ], 17 | providers: [MailService] 18 | }) 19 | export class MailModule {} 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "useTabs": true, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "arrowParens": "avoid", 9 | "importOrderSeparation": true, 10 | "importOrderSortSpecifiers": true, 11 | "importOrderCaseInsensitive": true, 12 | "importOrderParserPlugins": [ 13 | "classProperties", 14 | "decorators-legacy", 15 | "typescript" 16 | ], 17 | "importOrder": ["", "^@/(.*)$", "^../(.*)", "^./(.*)"], 18 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | import { UserRole } from '@prisma/__generated__' 3 | 4 | export const ROLES_KEY = 'roles' 5 | 6 | /** 7 | * Декоратор для установки метаданных ролей. 8 | * 9 | * Этот декоратор позволяет указать роли, необходимые для доступа к методу или классу. 10 | * 11 | * @param roles - Массив ролей, которые должны быть установлены в метаданных. 12 | * @returns Функция SetMetadata, устанавливающая роли в метаданных. 13 | */ 14 | export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles) 15 | -------------------------------------------------------------------------------- /src/libs/common/utils/is-dev.util.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config' 2 | import * as dotenv from 'dotenv' 3 | 4 | // Загружает переменные окружения из файла .env 5 | dotenv.config() 6 | 7 | /** 8 | * Проверяет, находится ли приложение в режиме разработки. 9 | * @param configService - Сервис конфигурации. 10 | * @returns true, если режим разработки; иначе false. 11 | */ 12 | export const isDev = (configService: ConfigService) => 13 | configService.getOrThrow('NODE_ENV') === 'development' 14 | 15 | /** 16 | * Определяет, работает ли приложение в режиме разработки. 17 | */ 18 | export const IS_DEV_ENV = process.env.NODE_ENV === 'development' 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # dotenv environment variable files 38 | .env 39 | .env.development.local 40 | .env.test.local 41 | .env.production.local 42 | .env.local -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/auth/email-confirmation/email-confirmation.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common' 2 | 3 | import { MailModule } from '@/libs/mail/mail.module' 4 | import { MailService } from '@/libs/mail/mail.service' 5 | import { UserService } from '@/user/user.service' 6 | 7 | import { AuthModule } from '../auth.module' 8 | 9 | import { EmailConfirmationController } from './email-confirmation.controller' 10 | import { EmailConfirmationService } from './email-confirmation.service' 11 | 12 | @Module({ 13 | imports: [MailModule, forwardRef(() => AuthModule)], 14 | controllers: [EmailConfirmationController], 15 | providers: [EmailConfirmationService, UserService, MailService], 16 | exports: [EmailConfirmationService] 17 | }) 18 | export class EmailConfirmationModule {} 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["src/*"], 15 | "@prisma/__generated__": ["prisma/__generated__"], 16 | "@prisma/__generated__/*": ["prisma/__generated__/*"] 17 | }, 18 | "incremental": true, 19 | "skipLibCheck": true, 20 | "strictNullChecks": false, 21 | "noImplicitAny": false, 22 | "strictBindCallApply": false, 23 | "forceConsistentCasingInFileNames": false, 24 | "noFallthroughCasesInSwitch": false, 25 | "jsx": "react" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/provider/provider.constants.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider, ModuleMetadata } from '@nestjs/common' 2 | 3 | import { BaseOAuthService } from './services/base-oauth.service' 4 | 5 | /** 6 | * Символ для идентификации опций провайдера. 7 | */ 8 | export const ProviderOptionsSymbol = Symbol() 9 | 10 | /** 11 | * Тип для опций провайдера. 12 | * 13 | * Этот тип описывает базовый URL и массив сервисов OAuth. 14 | */ 15 | export type TypeOptions = { 16 | baseUrl: string 17 | services: BaseOAuthService[] 18 | } 19 | 20 | /** 21 | * Тип для асинхронных опций провайдера. 22 | * 23 | * Этот тип описывает асинхронные опции, включая импорты и фабричные функции. 24 | */ 25 | export type TypeAsyncOptions = Pick & 26 | Pick, 'useFactory' | 'inject'> 27 | -------------------------------------------------------------------------------- /src/config/recaptcha.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config' 2 | import { GoogleRecaptchaModuleOptions } from '@nestlab/google-recaptcha' 3 | 4 | import { isDev } from '@/libs/common/utils/is-dev.util' 5 | 6 | /** 7 | * Конфигурация для Google reCAPTCHA. 8 | * 9 | * Эта функция асинхронно извлекает параметры конфигурации из ConfigService 10 | * и формирует объект конфигурации для модуля Google reCAPTCHA. 11 | * 12 | * @param configService - Сервис для работы с конфигурацией приложения. 13 | * @returns Объект конфигурации для Google reCAPTCHA. 14 | */ 15 | export const getRecaptchaConfig = async ( 16 | configService: ConfigService 17 | ): Promise => ({ 18 | secretKey: configService.getOrThrow('GOOGLE_RECAPTCHA_SECRET_KEY'), 19 | response: req => req.headers.recaptcha, 20 | skipIf: isDev(configService) 21 | }) 22 | -------------------------------------------------------------------------------- /src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator' 2 | 3 | /** 4 | * DTO для обновления данных пользователя. 5 | */ 6 | export class UpdateUserDto { 7 | /** 8 | * Имя пользователя. 9 | * @example Иван Иванов 10 | */ 11 | @IsString({ message: 'Имя должно быть строкой.' }) 12 | @IsNotEmpty({ message: 'Имя обязательно для заполнения.' }) 13 | name: string 14 | 15 | /** 16 | * Email пользователя. 17 | * @example example@example.com 18 | */ 19 | @IsString({ message: 'Email должен быть строкой.' }) 20 | @IsEmail({}, { message: 'Некорректный формат email.' }) 21 | @IsNotEmpty({ message: 'Email обязателен для заполнения.' }) 22 | email: string 23 | 24 | /** 25 | * Флаг, указывающий, включена ли двухфакторная аутентификация. 26 | */ 27 | @IsBoolean({ message: 'isTwoFactorEnabled должно быть булевым значением.' }) 28 | isTwoFactorEnabled: boolean 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/decorators/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common' 2 | import { UserRole } from '@prisma/__generated__' 3 | 4 | import { AuthGuard } from '../guards/auth.guard' 5 | import { RolesGuard } from '../guards/roles.guard' 6 | 7 | import { Roles } from './roles.decorator' 8 | 9 | /** 10 | * Декоратор для авторизации пользователей с определенными ролями. 11 | * 12 | * Этот декоратор применяет защиту на основе ролей и аутентификации. 13 | * Если указаны роли, применяется также декоратор Roles. 14 | * 15 | * @param roles - Массив ролей, для которых требуется доступ. 16 | * @returns Декораторы, применяемые к методу или классу. 17 | */ 18 | export function Authorization(...roles: UserRole[]) { 19 | if (roles.length > 0) { 20 | return applyDecorators( 21 | Roles(...roles), 22 | UseGuards(AuthGuard, RolesGuard) 23 | ) 24 | } 25 | 26 | return applyDecorators(UseGuards(AuthGuard)) 27 | } 28 | -------------------------------------------------------------------------------- /src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common' 2 | import { PrismaClient } from '@prisma/__generated__' 3 | 4 | /** 5 | * Сервис для работы с Prisma. 6 | * 7 | * Управляет соединением с базой данных в рамках жизненного цикла модуля. 8 | */ 9 | @Injectable() 10 | export class PrismaService 11 | extends PrismaClient 12 | implements OnModuleInit, OnModuleDestroy 13 | { 14 | /** 15 | * Устанавливает соединение с базой данных при инициализации модуля. 16 | * 17 | * @returns Промис, который разрешается после подключения. 18 | */ 19 | public async onModuleInit(): Promise { 20 | await this.$connect() 21 | } 22 | 23 | /** 24 | * Закрывает соединение с базой данных при уничтожении модуля. 25 | * 26 | * @returns Промис, который разрешается после отключения. 27 | */ 28 | public async onModuleDestroy(): Promise { 29 | await this.$disconnect() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/auth/decorators/authorized.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import { User } from '@prisma/__generated__' 3 | 4 | /** 5 | * Декоратор для получения авторизованного пользователя из контекста запроса. 6 | * 7 | * Этот декоратор позволяет извлекать данные пользователя из объекта запроса. 8 | * Если указан параметр, возвращает конкретное свойство пользователя, 9 | * иначе возвращает весь объект пользователя. 10 | * 11 | * @param data - Имя свойства пользователя, которое нужно извлечь. 12 | * @param ctx - Контекст выполнения, содержащий информацию о текущем запросе. 13 | * @returns Значение свойства пользователя или весь объект пользователя. 14 | */ 15 | export const Authorized = createParamDecorator( 16 | (data: keyof User, ctx: ExecutionContext) => { 17 | const request = ctx.switchToHttp().getRequest() 18 | const user = request.user 19 | 20 | return data ? user[data] : user 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsOptional, 5 | IsString, 6 | MinLength 7 | } from 'class-validator' 8 | 9 | /** 10 | * DTO для входа пользователя в систему. 11 | */ 12 | export class LoginDto { 13 | /** 14 | * Email пользователя. 15 | * @example example@example.com 16 | */ 17 | @IsString({ message: 'Email должен быть строкой.' }) 18 | @IsEmail({}, { message: 'Некорректный формат email.' }) 19 | @IsNotEmpty({ message: 'Email обязателен для заполнения.' }) 20 | email: string 21 | 22 | /** 23 | * Пароль пользователя. 24 | * @example password123 25 | */ 26 | @IsString({ message: 'Пароль должен быть строкой.' }) 27 | @IsNotEmpty({ message: 'Поле пароль не может быть пустым.' }) 28 | @MinLength(6, { message: 'Пароль должен содержать не менее 6 символов.' }) 29 | password: string 30 | 31 | /** 32 | * Код двухфакторной аутентификации (необязательно). 33 | * @example 123456 34 | */ 35 | @IsOptional() 36 | @IsString() 37 | code: string 38 | } 39 | -------------------------------------------------------------------------------- /src/config/mailer.config.ts: -------------------------------------------------------------------------------- 1 | import { MailerOptions } from '@nestjs-modules/mailer' 2 | import { ConfigService } from '@nestjs/config' 3 | 4 | import { isDev } from '@/libs/common/utils/is-dev.util' 5 | 6 | /** 7 | * Конфигурация для почтового сервера. 8 | * 9 | * Эта функция асинхронно извлекает параметры конфигурации из ConfigService 10 | * и формирует объект конфигурации для Mailer. 11 | * 12 | * @param configService - Сервис для работы с конфигурацией приложения. 13 | * @returns Объект конфигурации для Mailer. 14 | */ 15 | export const getMailerConfig = async ( 16 | configService: ConfigService 17 | ): Promise => ({ 18 | transport: { 19 | host: configService.getOrThrow('MAIL_HOST'), 20 | port: configService.getOrThrow('MAIL_PORT'), 21 | secure: !isDev(configService), 22 | auth: { 23 | user: configService.getOrThrow('MAIL_LOGIN'), 24 | pass: configService.getOrThrow('MAIL_PASSWORD') 25 | } 26 | }, 27 | defaults: { 28 | from: `"TeaCoder Team" ${configService.getOrThrow('MAIL_LOGIN')}` 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { ConfigModule } from '@nestjs/config' 3 | 4 | import { AuthModule } from './auth/auth.module' 5 | import { EmailConfirmationModule } from './auth/email-confirmation/email-confirmation.module' 6 | import { PasswordRecoveryModule } from './auth/password-recovery/password-recovery.module' 7 | import { ProviderModule } from './auth/provider/provider.module' 8 | import { TwoFactorAuthModule } from './auth/two-factor-auth/two-factor-auth.module' 9 | import { IS_DEV_ENV } from './libs/common/utils/is-dev.util' 10 | import { MailModule } from './libs/mail/mail.module' 11 | import { PrismaModule } from './prisma/prisma.module' 12 | import { UserModule } from './user/user.module' 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ 17 | ignoreEnvFile: !IS_DEV_ENV, 18 | isGlobal: true 19 | }), 20 | PrismaModule, 21 | AuthModule, 22 | UserModule, 23 | ProviderModule, 24 | MailModule, 25 | EmailConfirmationModule, 26 | PasswordRecoveryModule, 27 | TwoFactorAuthModule 28 | ] 29 | }) 30 | export class AppModule {} 31 | -------------------------------------------------------------------------------- /src/libs/mail/templates/two-factor-auth.template.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Heading, 4 | Tailwind, 5 | Text 6 | } from '@react-email/components'; 7 | import { Html } from '@react-email/html'; 8 | import * as React from 'react'; 9 | 10 | interface TwoFactorAuthTemplateProps { 11 | token: string; 12 | } 13 | 14 | /** 15 | * Генерирует шаблон письма для двухфакторной аутентификации. 16 | * Письмо содержит код, который необходимо ввести для завершения аутентификации. 17 | * 18 | * @param {TwoFactorAuthTemplateProps} props - Токен для двухфакторной аутентификации. 19 | * @returns {JSX.Element} Сгенерированный шаблон письма. 20 | */ 21 | export function TwoFactorAuthTemplate({ token }: TwoFactorAuthTemplateProps) { 22 | return ( 23 | 24 | 25 | 26 | Двухфакторная аутентификация 27 | Ваш код двухфакторной аутентификации: {token} 28 | 29 | Пожалуйста, введите этот код в приложении для завершения процесса аутентификации. 30 | 31 | 32 | Если вы не запрашивали этот код, просто проигнорируйте это сообщение. 33 | 34 | 35 | 36 | 37 | ); 38 | } -------------------------------------------------------------------------------- /src/auth/provider/provider.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, OnModuleInit } from '@nestjs/common' 2 | 3 | import { ProviderOptionsSymbol, TypeOptions } from './provider.constants' 4 | import { BaseOAuthService } from './services/base-oauth.service' 5 | 6 | /** 7 | * Сервис для управления провайдерами OAuth. 8 | */ 9 | @Injectable() 10 | export class ProviderService implements OnModuleInit { 11 | /** 12 | * Конструктор сервиса провайдеров. 13 | * 14 | * @param options - Опции провайдера, содержащие базовый URL и сервисы. 15 | */ 16 | public constructor( 17 | @Inject(ProviderOptionsSymbol) private readonly options: TypeOptions 18 | ) {} 19 | 20 | /** 21 | * Инициализация модуля. 22 | * 23 | * Устанавливает базовый URL для всех сервисов провайдеров. 24 | */ 25 | public onModuleInit() { 26 | for (const provider of this.options.services) { 27 | provider.baseUrl = this.options.baseUrl 28 | } 29 | } 30 | 31 | /** 32 | * Находит сервис провайдера по имени. 33 | * 34 | * @param service - Имя сервиса провайдера. 35 | * @returns Сервис провайдера или null, если не найден. 36 | */ 37 | public findByService(service: string): BaseOAuthService | null { 38 | return this.options.services.find(s => s.name === service) ?? null 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/auth/email-confirmation/email-confirmation.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Post, 7 | Req 8 | } from '@nestjs/common' 9 | import { Request } from 'express' 10 | 11 | import { ConfirmationDto } from './dto/confirmation.dto' 12 | import { EmailConfirmationService } from './email-confirmation.service' 13 | 14 | /** 15 | * Контроллер для управления подтверждением электронной почты. 16 | */ 17 | @Controller('auth/email-confirmation') 18 | export class EmailConfirmationController { 19 | /** 20 | * Конструктор контроллера подтверждения электронной почты. 21 | * @param emailConfirmationService - Сервис для управления подтверждением электронной почты. 22 | */ 23 | public constructor( 24 | private readonly emailConfirmationService: EmailConfirmationService 25 | ) {} 26 | 27 | /** 28 | * Обрабатывает запрос на подтверждение электронной почты. 29 | * @param req - Объект запроса Express. 30 | * @param dto - DTO с токеном подтверждения. 31 | * @returns Сессия пользователя после успешного подтверждения. 32 | */ 33 | @Post() 34 | @HttpCode(HttpStatus.OK) 35 | public async newVerification( 36 | @Req() req: Request, 37 | @Body() dto: ConfirmationDto 38 | ) { 39 | return this.emailConfirmationService.newVerification(req, dto) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/config/providers.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config' 2 | 3 | import { TypeOptions } from '@/auth/provider/provider.constants' 4 | import { GoogleProvider } from '@/auth/provider/services/google.provider' 5 | import { YandexProvider } from '@/auth/provider/services/yandex.provider' 6 | 7 | /** 8 | * Конфигурация для провайдеров OAuth. 9 | * 10 | * Эта функция асинхронно извлекает параметры конфигурации из ConfigService 11 | * и формирует объект конфигурации для OAuth провайдеров. 12 | * 13 | * @param configService - Сервис для работы с конфигурацией приложения. 14 | * @returns Объект конфигурации для провайдеров OAuth. 15 | */ 16 | export const getProvidersConfig = async ( 17 | configService: ConfigService 18 | ): Promise => ({ 19 | baseUrl: configService.getOrThrow('APPLICATION_URL'), 20 | services: [ 21 | new GoogleProvider({ 22 | client_id: configService.getOrThrow('GOOGLE_CLIENT_ID'), 23 | client_secret: configService.getOrThrow( 24 | 'GOOGLE_CLIENT_SECRET' 25 | ), 26 | scopes: ['email', 'profile'] 27 | }), 28 | new YandexProvider({ 29 | client_id: configService.getOrThrow('YANDEX_CLIENT_ID'), 30 | client_secret: configService.getOrThrow( 31 | 'YANDEX_CLIENT_SECRET' 32 | ), 33 | scopes: ['login:email', 'login:avatar', 'login:info'] 34 | }) 35 | ] 36 | }) 37 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common' 2 | import { ConfigModule, ConfigService } from '@nestjs/config' 3 | import { GoogleRecaptchaModule } from '@nestlab/google-recaptcha' 4 | 5 | import { getProvidersConfig } from '@/config/providers.config' 6 | import { getRecaptchaConfig } from '@/config/recaptcha.config' 7 | import { MailService } from '@/libs/mail/mail.service' 8 | import { UserService } from '@/user/user.service' 9 | 10 | import { AuthController } from './auth.controller' 11 | import { AuthService } from './auth.service' 12 | import { EmailConfirmationModule } from './email-confirmation/email-confirmation.module' 13 | import { ProviderModule } from './provider/provider.module' 14 | import { TwoFactorAuthService } from './two-factor-auth/two-factor-auth.service' 15 | 16 | @Module({ 17 | imports: [ 18 | ProviderModule.registerAsync({ 19 | imports: [ConfigModule], 20 | useFactory: getProvidersConfig, 21 | inject: [ConfigService] 22 | }), 23 | GoogleRecaptchaModule.forRootAsync({ 24 | imports: [ConfigModule], 25 | useFactory: getRecaptchaConfig, 26 | inject: [ConfigService] 27 | }), 28 | forwardRef(() => EmailConfirmationModule) 29 | ], 30 | controllers: [AuthController], 31 | providers: [AuthService, UserService, MailService, TwoFactorAuthService], 32 | exports: [AuthService] 33 | }) 34 | export class AuthModule {} 35 | -------------------------------------------------------------------------------- /src/libs/mail/templates/reset-password.template.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Heading, 4 | Link, 5 | Tailwind, 6 | Text 7 | } from '@react-email/components'; 8 | import { Html } from '@react-email/html'; 9 | import * as React from 'react'; 10 | 11 | interface ResetPasswordTemplateProps { 12 | domain: string; 13 | token: string; 14 | } 15 | 16 | /** 17 | * Генерирует шаблон письма для сброса пароля. 18 | * Ссылка для сброса формируется из домена и токена. Письмо информирует, 19 | * что ссылка действительна 1 час. 20 | * 21 | * @param {ResetPasswordTemplateProps} props - Домен и токен для генерации ссылки. 22 | * @returns {JSX.Element} Сгенерированный шаблон письма. 23 | */ 24 | export function ResetPasswordTemplate({ domain, token }: ResetPasswordTemplateProps) { 25 | const resetLink = `${domain}/auth/new-password?token=${token}`; 26 | 27 | return ( 28 | 29 | 30 | 31 | Сброс пароля 32 | 33 | Привет! Вы запросили сброс пароля. Пожалуйста, перейдите по следующей ссылке, чтобы создать новый пароль: 34 | 35 | Подтвердить сброс пароля 36 | 37 | Эта ссылка действительна в течение 1 часа. Если вы не запрашивали сброс пароля, просто проигнорируйте это сообщение. 38 | 39 | 40 | 41 | 42 | ); 43 | } -------------------------------------------------------------------------------- /src/auth/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException 6 | } from '@nestjs/common' 7 | 8 | import { UserService } from '@/user/user.service' 9 | 10 | /** 11 | * Guard для проверки аутентификации пользователя. 12 | */ 13 | @Injectable() 14 | export class AuthGuard implements CanActivate { 15 | /** 16 | * Конструктор охранителя аутентификации. 17 | * @param userService - Сервис для работы с пользователями. 18 | */ 19 | public constructor(private readonly userService: UserService) {} 20 | 21 | /** 22 | * Проверяет, имеет ли пользователь доступ к ресурсу. 23 | * @param context - Контекст выполнения, содержащий информацию о текущем запросе. 24 | * @returns true, если пользователь аутентифицирован; в противном случае выбрасывает UnauthorizedException. 25 | * @throws UnauthorizedException - Если пользователь не авторизован. 26 | */ 27 | public async canActivate(context: ExecutionContext): Promise { 28 | const request = context.switchToHttp().getRequest() 29 | 30 | if (typeof request.session.userId === 'undefined') { 31 | throw new UnauthorizedException( 32 | 'Пользователь не авторизован. Пожалуйста, войдите в систему, чтобы получить доступ.' 33 | ) 34 | } 35 | 36 | const user = await this.userService.findById(request.session.userId) 37 | 38 | request.user = user 39 | 40 | return true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/libs/mail/templates/confirmation.template.tsx: -------------------------------------------------------------------------------- 1 | import { Body, Heading, Link, Tailwind, Text } from "@react-email/components" 2 | import { Html } from "@react-email/html" 3 | import * as React from 'react' 4 | 5 | interface ConfirmationTemplateProps { 6 | domain: string 7 | token: string 8 | } 9 | 10 | /** 11 | * Генерирует шаблон письма для подтверждения электронной почты пользователя. 12 | * Ссылка для подтверждения формируется из домена и токена. Письмо информирует, 13 | * что ссылка действительна 1 час. 14 | * 15 | * @param {ConfirmationTemplateProps} props - Домен и токен для генерации ссылки. 16 | * @returns {JSX.Element} Сгенерированный шаблон письма. 17 | */ 18 | export function ConfirmationTemplate({ 19 | domain, 20 | token 21 | }: ConfirmationTemplateProps) { 22 | const confirmLink = `${domain}/auth/new-verification?token=${token}` 23 | 24 | return ( 25 | 26 | 27 | 28 | Подтверждение почты 29 | 30 | Привет! Чтобы подтвердить свой адрес электронной почты, пожалуйста, перейдите по следующей ссылке: 31 | 32 | Подтвердить почту 33 | 34 | Эта ссылка действительна в течение 1 часа. Если вы не запрашивали подтверждение, просто проигнорируйте это сообщение. 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/libs/common/decorators/is-passwords-matching-constraint.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationArguments, 3 | ValidatorConstraint, 4 | ValidatorConstraintInterface 5 | } from 'class-validator' 6 | 7 | import { RegisterDto } from '@/auth/dto/register.dto' 8 | 9 | /** 10 | * Ограничение для проверки совпадения паролей. 11 | * 12 | * Этот класс реализует интерфейс ValidatorConstraintInterface и используется 13 | * для проверки, совпадают ли два пароля в процессе валидации. 14 | */ 15 | @ValidatorConstraint({ name: 'IsPasswordsMatching', async: false }) 16 | export class IsPasswordsMatchingConstraint 17 | implements ValidatorConstraintInterface 18 | { 19 | /** 20 | * Проверяет, совпадает ли подтверждение пароля с основным паролем. 21 | * 22 | * @param passwordRepeat - Подтверждение пароля, введенное пользователем. 23 | * @param args - Аргументы валидации, содержащие объект, который проверяется. 24 | * @returns true, если пароли совпадают; иначе false. 25 | */ 26 | public validate(passwordRepeat: string, args: ValidationArguments) { 27 | const obj = args.object as RegisterDto 28 | return obj.password === passwordRepeat 29 | } 30 | 31 | /** 32 | * Возвращает сообщение по умолчанию, если валидация не прошла. 33 | * 34 | * @param validationArguments - Аргументы валидации. 35 | * @returns Сообщение об ошибке. 36 | */ 37 | public defaultMessage(validationArguments?: ValidationArguments) { 38 | return 'Пароли не совпадают' 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/libs/common/utils/parse-boolean.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Преобразует строковое значение в логическое значение (boolean). 3 | * 4 | * Эта функция принимает строку, представляющую логическое значение, 5 | * и возвращает соответствующее логическое значение. Если строка равна 6 | * "true" (игнорируя регистр), функция вернет `true`. Если строка равна 7 | * "false", функция вернет `false`. Если передано значение другого типа 8 | * или строка не соответствует ожидаемым значениям, будет выброшено 9 | * исключение. 10 | * 11 | * @param value - Строка, представляющая логическое значение ("true" или "false"). 12 | * @returns {boolean} Логическое значение, соответствующее переданной строке. 13 | * @throws {Error} Если переданное значение не может быть преобразовано в логическое значение. 14 | * 15 | * @example 16 | * parseBoolean('true'); // вернет true 17 | * parseBoolean('false'); // вернет false 18 | * parseBoolean('TRUE'); // вернет true 19 | * parseBoolean('False'); // вернет false 20 | */ 21 | export function parseBoolean(value: string): boolean { 22 | if (typeof value === 'boolean') { 23 | return value 24 | } 25 | 26 | if (typeof value === 'string') { 27 | const lowerValue = value.trim().toLowerCase() 28 | if (lowerValue === 'true') { 29 | return true 30 | } 31 | if (lowerValue === 'false') { 32 | return false 33 | } 34 | } 35 | 36 | throw new Error( 37 | `Не удалось преобразовать значение "${value}" в логическое значение.` 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/auth/guards/provider.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | NotFoundException 6 | } from '@nestjs/common' 7 | import { Request } from 'express' 8 | 9 | import { ProviderService } from '../provider/provider.service' 10 | 11 | /** 12 | * Guard для проверки наличия провайдера аутентификации. 13 | */ 14 | @Injectable() 15 | export class AuthProviderGuard implements CanActivate { 16 | /** 17 | * Конструктор охранителя провайдера аутентификации. 18 | * @param providerService - Сервис для работы с провайдерами аутентификации. 19 | */ 20 | public constructor(private readonly providerService: ProviderService) {} 21 | 22 | /** 23 | * Проверяет, существует ли указанный провайдер аутентификации. 24 | * @param context - Контекст выполнения, содержащий информацию о текущем запросе. 25 | * @returns true, если провайдер найден; в противном случае выбрасывает NotFoundException. 26 | * @throws NotFoundException - Если провайдер не найден. 27 | */ 28 | public canActivate(context: ExecutionContext) { 29 | const request = context.switchToHttp().getRequest() as Request 30 | 31 | const provider = request.params.provider 32 | 33 | const providerInstance = this.providerService.findByService(provider) 34 | 35 | if (!providerInstance) { 36 | throw new NotFoundException( 37 | `Провайдер "${provider}" не найден. Пожалуйста, проверьте правильность введенных данных.` 38 | ) 39 | } 40 | 41 | return true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | Injectable 6 | } from '@nestjs/common' 7 | import { Reflector } from '@nestjs/core' 8 | import { UserRole } from '@prisma/__generated__' 9 | 10 | import { ROLES_KEY } from '../decorators/roles.decorator' 11 | 12 | /** 13 | * Guard для проверки ролей пользователя. 14 | */ 15 | @Injectable() 16 | export class RolesGuard implements CanActivate { 17 | /** 18 | * Конструктор охранителя ролей. 19 | * @param reflector - Рефлектор для получения метаданных. 20 | */ 21 | public constructor(private readonly reflector: Reflector) {} 22 | 23 | /** 24 | * Проверяет, имеет ли пользователь необходимые роли для доступа к ресурсу. 25 | * @param context - Контекст выполнения, содержащий информацию о текущем запросе. 26 | * @returns true, если у пользователя достаточно прав; в противном случае выбрасывает ForbiddenException. 27 | * @throws ForbiddenException - Если у пользователя недостаточно прав. 28 | */ 29 | public async canActivate(context: ExecutionContext): Promise { 30 | const roles = this.reflector.getAllAndOverride(ROLES_KEY, [ 31 | context.getHandler(), 32 | context.getClass() 33 | ]) 34 | const request = context.switchToHttp().getRequest() 35 | 36 | if (!roles) return true 37 | 38 | if (!roles.includes(request.user.role)) { 39 | throw new ForbiddenException( 40 | 'Недостаточно прав. У вас нет прав доступа к этому ресурсу.' 41 | ) 42 | } 43 | 44 | return true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/auth/provider/provider.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common' 2 | 3 | import { 4 | ProviderOptionsSymbol, 5 | TypeAsyncOptions, 6 | TypeOptions 7 | } from './provider.constants' 8 | import { ProviderService } from './provider.service' 9 | 10 | /** 11 | * Модуль для управления провайдерами OAuth. 12 | */ 13 | @Module({}) 14 | export class ProviderModule { 15 | /** 16 | * Регистрация модуля провайдеров с синхронными опциями. 17 | * 18 | * @param options - Опции провайдера, содержащие базовый URL и сервисы. 19 | * @returns Динамический модуль провайдеров. 20 | */ 21 | public static register(options: TypeOptions): DynamicModule { 22 | return { 23 | module: ProviderModule, 24 | providers: [ 25 | { 26 | useValue: options.services, 27 | provide: ProviderOptionsSymbol 28 | }, 29 | ProviderService 30 | ], 31 | exports: [ProviderService] 32 | } 33 | } 34 | 35 | /** 36 | * Регистрация модуля провайдеров с асинхронными опциями. 37 | * 38 | * @param options - Асинхронные опции провайдера, включая импорты и фабричные функции. 39 | * @returns Динамический модуль провайдеров. 40 | */ 41 | public static registerAsync(options: TypeAsyncOptions): DynamicModule { 42 | return { 43 | module: ProviderModule, 44 | imports: options.imports, 45 | providers: [ 46 | { 47 | useFactory: options.useFactory, 48 | provide: ProviderOptionsSymbol, 49 | inject: options.inject 50 | }, 51 | ProviderService 52 | ], 53 | exports: [ProviderService] 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsString, 5 | MinLength, 6 | Validate 7 | } from 'class-validator' 8 | 9 | import { IsPasswordsMatchingConstraint } from '@/libs/common/decorators/is-passwords-matching-constraint.decorator' 10 | 11 | /** 12 | * DTO для регистрации пользователя. 13 | */ 14 | export class RegisterDto { 15 | /** 16 | * Имя пользователя. 17 | * @example John Doe 18 | */ 19 | @IsString({ message: 'Имя должно быть строкой.' }) 20 | @IsNotEmpty({ message: 'Имя обязательно для заполнения.' }) 21 | name: string 22 | 23 | /** 24 | * Email пользователя. 25 | * @example example@example.com 26 | */ 27 | @IsString({ message: 'Email должен быть строкой.' }) 28 | @IsEmail({}, { message: 'Некорректный формат email.' }) 29 | @IsNotEmpty({ message: 'Email обязателен для заполнения.' }) 30 | email: string 31 | 32 | /** 33 | * Пароль пользователя. 34 | * @example password123 35 | */ 36 | @IsString({ message: 'Пароль должен быть строкой.' }) 37 | @IsNotEmpty({ message: 'Пароль обязателен для заполнения.' }) 38 | @MinLength(6, { 39 | message: 'Пароль должен содержать минимум 6 символов.' 40 | }) 41 | password: string 42 | 43 | /** 44 | * Подтверждение пароля пользователя. 45 | * @example password123 46 | */ 47 | @IsString({ message: 'Пароль подтверждения должен быть строкой.' }) 48 | @IsNotEmpty({ message: 'Поле подтверждения пароля не может быть пустым.' }) 49 | @MinLength(6, { 50 | message: 'Пароль подтверждения должен содержать не менее 6 символов.' 51 | }) 52 | @Validate(IsPasswordsMatchingConstraint, { 53 | message: 'Пароли не совпадают.' 54 | }) 55 | passwordRepeat: string 56 | } 57 | -------------------------------------------------------------------------------- /src/auth/password-recovery/password-recovery.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Param, 7 | Post 8 | } from '@nestjs/common' 9 | import { Recaptcha } from '@nestlab/google-recaptcha' 10 | 11 | import { NewPasswordDto } from './dto/new-password.dto' 12 | import { ResetPasswordDto } from './dto/reset-password.dto' 13 | import { PasswordRecoveryService } from './password-recovery.service' 14 | 15 | /** 16 | * Контроллер для управления восстановлением пароля. 17 | */ 18 | @Controller('auth/password-recovery') 19 | export class PasswordRecoveryController { 20 | /** 21 | * Конструктор контроллера восстановления пароля. 22 | * @param passwordRecoveryService - Сервис для управления восстановлением пароля. 23 | */ 24 | public constructor( 25 | private readonly passwordRecoveryService: PasswordRecoveryService 26 | ) {} 27 | 28 | /** 29 | * Запрашивает сброс пароля и отправляет токен на указанный email. 30 | * @param dto - DTO с адресом электронной почты пользователя. 31 | * @returns true, если токен успешно отправлен. 32 | */ 33 | @Recaptcha() 34 | @Post('reset') 35 | @HttpCode(HttpStatus.OK) 36 | public async resetPassword(@Body() dto: ResetPasswordDto) { 37 | return this.passwordRecoveryService.reset(dto) 38 | } 39 | 40 | /** 41 | * Устанавливает новый пароль для пользователя. 42 | * @param dto - DTO с новым паролем. 43 | * @param token - Токен для сброса пароля. 44 | * @returns true, если пароль успешно изменен. 45 | */ 46 | @Recaptcha() 47 | @Post('new/:token') 48 | @HttpCode(HttpStatus.OK) 49 | public async newPassword( 50 | @Body() dto: NewPasswordDto, 51 | @Param('token') token: string 52 | ) { 53 | return this.passwordRecoveryService.new(dto, token) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Полноценная авторизация с использованием Nest.js, Docker, Prisma, PostgreSQL и Redis 2 | 3 | Этот проект представляет собой полноценную систему авторизации, построенную с использованием следующих технологий: 4 | 5 | - **[Nest.js](https://nestjs.com/)**: Прогрессивный фреймворк Node.js для создания эффективных, масштабируемых и корпоративных серверных приложений. 6 | 7 | - **[Docker](https://www.docker.com/)**: Открытая платформа для разработки, доставки и запуска приложений в контейнерах. 8 | 9 | - **[Prisma](https://www.prisma.io/)**: ORM нового поколения, предоставляющая типобезопасный уровень доступа к базе данных для Node.js и TypeScript. 10 | 11 | - **[PostgreSQL](https://www.postgresql.org/)**: Мощная, открытая объектно-реляционная система управления базами данных. 12 | 13 | - **[Redis](https://redis.io/)**: Открытое хранилище данных в памяти, которое можно использовать в качестве базы данных, кэша и брокера сообщений. 14 | 15 | Бэкенд этой системы построен с использованием Nest.js и включает в себя следующие функции: 16 | 17 | - Авторизация через социальные сети (Google, Yandex) 18 | - Подтверждение электронной почты 19 | - Двухфакторная аутентификация 20 | - Функциональность восстановления пароля 21 | - Управление ролями 22 | 23 | Фронтенд этого проекта доступен в отдельном репозитории: [Ссылка на репозиторий фронтенда](https://github.com/TeaCoder52/nextjs-full-authorization) 24 | 25 | Полный цикл разработки проекта можно посмотреть на YouTube: [Ссылка на видео на YouTube](https://www.youtube.com/watch?v=O5Qry8cBhG4) 26 | 27 | ## Контакты 28 | 29 | Если у вас есть вопросы или вам нужна помощь с проектом, пожалуйста, свяжитесь со мной по адресу [help@teacoder.ru]. 30 | 31 | Наслаждайтесь использованием этой системы авторизации! 🚀 32 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | output = "./__generated__" 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("POSTGRES_URI") 9 | } 10 | 11 | model User { 12 | id String @id @default(uuid()) 13 | 14 | email String @unique 15 | password String 16 | 17 | displayName String 18 | picture String? 19 | 20 | role UserRole @default(REGULAR) 21 | 22 | isVerified Boolean @default(false) @map("is_verified") 23 | isTwoFactorEnabled Boolean @default(false) @map("is_two_factor_enabled") 24 | 25 | method AuthMethod 26 | 27 | accounts Account[] 28 | 29 | createdAt DateTime @default(now()) @map("created_at") 30 | updatedAt DateTime @updatedAt @map("updated_at") 31 | 32 | @@map("users") 33 | } 34 | 35 | model Account { 36 | id String @id @default(uuid()) 37 | 38 | type String 39 | provider String 40 | 41 | refreshToken String? @map("refresh_token") 42 | accessToken String? @map("access_token") 43 | expiresAt Int @map("expires_at") 44 | 45 | createdAt DateTime @default(now()) @map("created_at") 46 | updatedAt DateTime @updatedAt @map("updated_at") 47 | 48 | user User? @relation(fields: [userId], references: [id]) 49 | userId String? @map("user_id") 50 | 51 | @@map("accounts") 52 | } 53 | 54 | model Token { 55 | id String @id @default(uuid()) 56 | 57 | email String 58 | token String @unique 59 | type TokenType 60 | expiresIn DateTime @map("expires_in") 61 | 62 | createdAt DateTime @default(now()) @map("created_at") 63 | 64 | @@map("tokens") 65 | } 66 | 67 | enum UserRole { 68 | REGULAR 69 | ADMIN 70 | } 71 | 72 | enum AuthMethod { 73 | CREDENTIALS 74 | GOOGLE 75 | YANDEX 76 | } 77 | 78 | enum TokenType { 79 | VERIFICATION 80 | TWO_FACTOR 81 | PASSWORD_RESET 82 | } 83 | -------------------------------------------------------------------------------- /src/auth/provider/services/google.provider.ts: -------------------------------------------------------------------------------- 1 | import { BaseOAuthService } from './base-oauth.service' 2 | import { TypeProviderOptions } from './types/provider-options.types' 3 | import { TypeUserInfo } from './types/user-info.types' 4 | 5 | /** 6 | * Провайдер для работы с OAuth Google. 7 | */ 8 | export class GoogleProvider extends BaseOAuthService { 9 | /** 10 | * Конструктор провайдера Google. 11 | * 12 | * @param options - Опции провайдера, содержащие необходимые параметры для аутентификации. 13 | */ 14 | public constructor(options: TypeProviderOptions) { 15 | super({ 16 | name: 'google', 17 | authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', 18 | access_url: 'https://oauth2.googleapis.com/token', 19 | profile_url: 'https://www.googleapis.com/oauth2/v3/userinfo', 20 | scopes: options.scopes, 21 | client_id: options.client_id, 22 | client_secret: options.client_secret 23 | }) 24 | } 25 | 26 | /** 27 | * Извлекает информацию о пользователе из данных, полученных от Google. 28 | * 29 | * @param data - Данные профиля пользователя от Google. 30 | * @returns Объект с информацией о пользователе. 31 | */ 32 | public async extractUserInfo(data: GoogleProfile): Promise { 33 | return super.extractUserInfo({ 34 | email: data.email, 35 | name: data.name, 36 | picture: data.picture 37 | }) 38 | } 39 | } 40 | 41 | /** 42 | * Интерфейс для данных профиля пользователя Google. 43 | */ 44 | interface GoogleProfile extends Record { 45 | aud: string 46 | azp: string 47 | email: string 48 | email_verified: boolean 49 | exp: number 50 | family_name?: string 51 | given_name: string 52 | hd?: string 53 | iat: number 54 | iss: string 55 | jti?: string 56 | locale?: string 57 | name: string 58 | nbf?: number 59 | picture: string 60 | sub: string 61 | access_token: string 62 | refresh_token?: string 63 | } 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' # Указывает версию Docker Compose 2 | 3 | services: # Начало секции с сервисами 4 | db: # Определение сервиса базы данных 5 | container_name: postgres # Имя контейнера для базы данных 6 | image: postgres:15.2 # Используемый образ PostgreSQL версии 15.2 7 | restart: always # Перезапускать контейнер всегда в случае сбоя 8 | environment: # Переменные окружения для настройки базы данных 9 | - POSTGRES_USER=${POSTGRES_USER} # Пользователь базы данных 10 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # Пароль пользователя базы данных 11 | - POSTGRES_DB=${POSTGRES_DB} # Имя базы данных 12 | ports: # Настройка портов 13 | - 5433:5432 # Проброс порта 5432 контейнера на порт 5433 хоста 14 | volumes: # Настройка томов для хранения данных 15 | - postgres_data:/var/lib/postgresql/data # Хранение данных PostgreSQL в томе postgres_data 16 | networks: # Настройка сетей 17 | - backend # Подключение к сети backend 18 | 19 | redis: # Определение сервиса Redis 20 | container_name: redis # Имя контейнера для Redis 21 | image: redis:5.0 # Используемый образ Redis версии 5.0 22 | restart: always # Перезапускать контейнер всегда в случае сбоя 23 | ports: # Настройка портов 24 | - 6379:6379 # Проброс порта 6379 контейнера на порт 6379 хоста 25 | command: redis-server --requirepass ${REDIS_PASSWORD} # Команда для запуска Redis с требованием пароля 26 | volumes: # Настройка томов для хранения данных 27 | - redis_data:/data # Хранение данных Redis в томе redis_data 28 | networks: # Настройка сетей 29 | - backend # Подключение к сети backend 30 | 31 | volumes: # Определение томов 32 | postgres_data: # Том для хранения данных PostgreSQL 33 | redis_data: # Том для хранения данных Redis 34 | 35 | networks: # Определение сетей 36 | backend # Сеть для внутреннего взаимодействия сервисов 37 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | Patch 9 | } from '@nestjs/common' 10 | import { UserRole } from '@prisma/__generated__' 11 | 12 | import { Authorization } from '@/auth/decorators/auth.decorator' 13 | import { Authorized } from '@/auth/decorators/authorized.decorator' 14 | 15 | import { UpdateUserDto } from './dto/update-user.dto' 16 | import { UserService } from './user.service' 17 | 18 | /** 19 | * Контроллер для управления пользователями. 20 | */ 21 | @Controller('users') 22 | export class UserController { 23 | /** 24 | * Конструктор контроллера пользователей. 25 | * @param userService - Сервис для работы с пользователями. 26 | */ 27 | public constructor(private readonly userService: UserService) {} 28 | 29 | /** 30 | * Получает профиль текущего пользователя. 31 | * @param userId - ID авторизованного пользователя. 32 | * @returns Профиль пользователя. 33 | */ 34 | @Authorization() 35 | @HttpCode(HttpStatus.OK) 36 | @Get('profile') 37 | public async findProfile(@Authorized('id') userId: string) { 38 | return this.userService.findById(userId) 39 | } 40 | 41 | /** 42 | * Получает пользователя по ID (доступно только администраторам). 43 | * @param id - ID пользователя. 44 | * @returns Найденный пользователь. 45 | */ 46 | @Authorization(UserRole.ADMIN) 47 | @HttpCode(HttpStatus.OK) 48 | @Get('by-id/:id') 49 | public async findById(@Param('id') id: string) { 50 | return this.userService.findById(id) 51 | } 52 | 53 | /** 54 | * Обновляет профиль текущего пользователя. 55 | * @param userId - ID авторизованного пользователя. 56 | * @param dto - Данные для обновления профиля. 57 | * @returns Обновленный профиль пользователя. 58 | */ 59 | @Authorization() 60 | @HttpCode(HttpStatus.OK) 61 | @Patch('profile') 62 | public async updateProfile( 63 | @Authorized('id') userId: string, 64 | @Body() dto: UpdateUserDto 65 | ) { 66 | return this.userService.update(userId, dto) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/auth/provider/services/yandex.provider.ts: -------------------------------------------------------------------------------- 1 | import { BaseOAuthService } from './base-oauth.service' 2 | import { TypeProviderOptions } from './types/provider-options.types' 3 | import { TypeUserInfo } from './types/user-info.types' 4 | 5 | /** 6 | * Провайдер для работы с OAuth Yandex. 7 | */ 8 | export class YandexProvider extends BaseOAuthService { 9 | /** 10 | * Конструктор провайдера Yandex. 11 | * 12 | * @param options - Опции провайдера, содержащие необходимые параметры для аутентификации. 13 | */ 14 | public constructor(options: TypeProviderOptions) { 15 | super({ 16 | name: 'yandex', 17 | authorize_url: 'https://oauth.yandex.ru/authorize', 18 | access_url: 'https://oauth.yandex.ru/token', 19 | profile_url: 'https://login.yandex.ru/info?format=json', 20 | scopes: options.scopes, 21 | client_id: options.client_id, 22 | client_secret: options.client_secret 23 | }) 24 | } 25 | 26 | /** 27 | * Извлекает информацию о пользователе из данных, полученных от Yandex. 28 | * 29 | * @param data - Данные профиля пользователя от Yandex. 30 | * @returns Объект с информацией о пользователе. 31 | */ 32 | public async extractUserInfo(data: YandexProfile): Promise { 33 | return super.extractUserInfo({ 34 | email: data.emails[0], 35 | name: data.display_name, 36 | picture: data.default_avatar_id 37 | ? `https://avatars.yandex.net/get-yapic/${data.default_avatar_id}/islands-200` 38 | : undefined 39 | }) 40 | } 41 | } 42 | 43 | /** 44 | * Интерфейс для данных профиля пользователя Yandex. 45 | */ 46 | interface YandexProfile { 47 | login: string 48 | id: string 49 | client_id: string 50 | psuid: string 51 | emails?: string[] 52 | default_email?: string 53 | is_avatar_empty?: boolean 54 | default_avatar_id?: string 55 | birthday?: string | null 56 | first_name?: string 57 | last_name?: string 58 | display_name?: string 59 | real_name?: string 60 | sex?: 'male' | 'female' | null 61 | default_phone?: { id: number; number: string } 62 | access_token: string 63 | refresh_token?: string 64 | } 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | import { NestFactory } from '@nestjs/core' 4 | import RedisStore from 'connect-redis' 5 | import * as cookieParser from 'cookie-parser' 6 | import * as session from 'express-session' 7 | import IORedis from 'ioredis' 8 | 9 | import { AppModule } from './app.module' 10 | import { ms, StringValue } from './libs/common/utils/ms.util' 11 | import { parseBoolean } from './libs/common/utils/parse-boolean.util' 12 | 13 | /** 14 | * Запускает приложение NestJS. 15 | * 16 | * Функция инициализирует приложение, настраивает промежуточное ПО, 17 | * конфигурирует управление сессиями и запускает сервер. 18 | * 19 | * @async 20 | * @function bootstrap 21 | * @returns {Promise} Промис, который разрешается, когда приложение запущено. 22 | */ 23 | async function bootstrap() { 24 | const app = await NestFactory.create(AppModule) 25 | 26 | const config = app.get(ConfigService) 27 | const redis = new IORedis(config.getOrThrow('REDIS_URI')) 28 | 29 | app.use(cookieParser(config.getOrThrow('COOKIES_SECRET'))) 30 | 31 | app.useGlobalPipes( 32 | new ValidationPipe({ 33 | transform: true 34 | }) 35 | ) 36 | 37 | app.use( 38 | session({ 39 | // Настройки управления сессиями с использованием Redis 40 | secret: config.getOrThrow('SESSION_SECRET'), 41 | name: config.getOrThrow('SESSION_NAME'), 42 | resave: true, 43 | saveUninitialized: false, 44 | cookie: { 45 | domain: config.getOrThrow('SESSION_DOMAIN'), 46 | maxAge: ms(config.getOrThrow('SESSION_MAX_AGE')), 47 | httpOnly: parseBoolean( 48 | config.getOrThrow('SESSION_HTTP_ONLY') 49 | ), 50 | secure: parseBoolean( 51 | config.getOrThrow('SESSION_SECURE') 52 | ), 53 | sameSite: 'lax' 54 | }, 55 | store: new RedisStore({ 56 | client: redis, 57 | prefix: config.getOrThrow('SESSION_FOLDER') 58 | }) 59 | }) 60 | ) 61 | 62 | app.enableCors({ 63 | // Настройки CORS для приложения 64 | origin: config.getOrThrow('ALLOWED_ORIGIN'), 65 | credentials: true, 66 | exposedHeaders: ['set-cookie'] 67 | }) 68 | 69 | await app.listen(config.getOrThrow('APPLICATION_PORT')) 70 | } 71 | bootstrap() 72 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV='development' # Настройка окружения для приложения (разработка, продакшн и т.д.) 2 | 3 | APPLICATION_PORT=4000 # Порт, на котором будет работать приложение 4 | APPLICATION_URL='http://localhost:${APPLICATION_PORT}' # Базовый URL для приложения с использованием определенного порта 5 | ALLOWED_ORIGIN='http://localhost:3000' # Разрешенный источник для CORS (междоменные запросы) 6 | 7 | COOKIES_SECRET='secret' # Секретный ключ, используемый для подписи куки 8 | SESSION_SECRET='secret' # Секретный ключ для шифрования сессий 9 | SESSION_NAME='session' # Имя куки сессии 10 | SESSION_DOMAIN='localhost' # Домен для куки сессии 11 | SESSION_MAX_AGE='30d' # Максимальный срок действия куки сессии (30 дней) 12 | SESSION_HTTP_ONLY=true # Флаг, предотвращающий доступ клиентского JavaScript к куки сессии 13 | SESSION_SECURE=false # Флаг, указывающий, что куки сессии должны отправляться только по HTTPS 14 | SESSION_FOLDER='sessions:' # Папка или пространство имен для хранения данных сессий 15 | 16 | POSTGRES_USER='your_postgres_user' # Имя пользователя PostgreSQL 17 | POSTGRES_PASSWORD='your_postgres_password' # Пароль для PostgreSQL 18 | POSTGRES_HOST='localhost' # Хост, на котором работает PostgreSQL 19 | POSTGRES_PORT=5433 # Порт, на котором запускается PostgreSQL 20 | POSTGRES_DB='your_postgres_db_name' # Имя базы данных PostgreSQL 21 | POSTGRES_URI='postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}' # URI подключения к PostgreSQL 22 | 23 | REDIS_USER='your_redis_user' # Имя пользователя Redis (если применимо) 24 | REDIS_PASSWORD='your_redis_password' # Пароль для Redis 25 | REDIS_HOST='localhost' # Хост, на котором работает Redis 26 | REDIS_PORT=6379 # Порт, на котором запускается Redis 27 | REDIS_URI='redis://${REDIS_USER}:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}' # URI подключения к Redis 28 | 29 | MAIL_HOST='your_mail_host' # Хост SMTP-сервера для отправки электронных писем 30 | MAIL_PORT=25 # Порт для SMTP-сервера 31 | MAIL_LOGIN='your_mail_login' # Логин для SMTP-сервера 32 | MAIL_PASSWORD='your_mail_password' # Пароль для SMTP-сервера 33 | 34 | GOOGLE_RECAPTCHA_SECRET_KEY='your_recaptcha_secret_key' # Секретный ключ для Google reCAPTCHA 35 | 36 | GOOGLE_CLIENT_ID='your_google_client_id' # Идентификатор клиента для Google OAuth 37 | GOOGLE_CLIENT_SECRET='your_google_client_secret' # Секретный ключ для Google OAuth 38 | 39 | YANDEX_CLIENT_ID='your_yandex_client_id' # Идентификатор клиента для Yandex OAuth 40 | YANDEX_CLIENT_SECRET='your_yandex_client_secret' # Секретный ключ для Yandex OAuth -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-server", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs-modules/mailer": "^2.0.2", 24 | "@nestjs/common": "^10.0.0", 25 | "@nestjs/config": "^3.2.3", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/mapped-types": "*", 28 | "@nestjs/platform-express": "^10.0.0", 29 | "@nestlab/google-recaptcha": "^3.8.0", 30 | "@prisma/client": "^5.19.0", 31 | "@react-email/components": "^0.0.23", 32 | "@react-email/html": "^0.0.10", 33 | "argon2": "^0.41.0", 34 | "class-transformer": "^0.5.1", 35 | "class-validator": "^0.14.1", 36 | "connect-redis": "^7.1.1", 37 | "cookie-parser": "^1.4.6", 38 | "express-session": "^1.18.0", 39 | "ioredis": "^5.4.1", 40 | "reflect-metadata": "^0.1.13", 41 | "rxjs": "^7.8.1" 42 | }, 43 | "devDependencies": { 44 | "@nestjs/cli": "^10.0.0", 45 | "@nestjs/schematics": "^10.0.0", 46 | "@nestjs/testing": "^10.0.0", 47 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 48 | "@types/cookie-parser": "^1.4.7", 49 | "@types/express": "^4.17.17", 50 | "@types/express-session": "^1.18.0", 51 | "@types/jest": "^29.5.2", 52 | "@types/node": "^20.3.1", 53 | "@types/react": "^18.3.4", 54 | "@types/supertest": "^2.0.12", 55 | "@types/uuid": "^10.0.0", 56 | "@typescript-eslint/eslint-plugin": "^6.0.0", 57 | "@typescript-eslint/parser": "^6.0.0", 58 | "eslint": "^8.42.0", 59 | "eslint-config-prettier": "^9.0.0", 60 | "eslint-plugin-prettier": "^5.0.0", 61 | "jest": "^29.5.0", 62 | "prettier": "^3.0.0", 63 | "source-map-support": "^0.5.21", 64 | "supertest": "^6.3.3", 65 | "ts-jest": "^29.1.0", 66 | "ts-loader": "^9.4.3", 67 | "ts-node": "^10.9.1", 68 | "tsconfig-paths": "^4.2.0", 69 | "typescript": "^5.1.3" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": "src", 78 | "testRegex": ".*\\.spec\\.ts$", 79 | "transform": { 80 | "^.+\\.(t|j)s$": "ts-jest" 81 | }, 82 | "collectCoverageFrom": [ 83 | "**/*.(t|j)s" 84 | ], 85 | "coverageDirectory": "../coverage", 86 | "testEnvironment": "node" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/libs/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { MailerService } from '@nestjs-modules/mailer' 2 | import { Injectable } from '@nestjs/common' 3 | import { ConfigService } from '@nestjs/config' 4 | import { render } from '@react-email/components' 5 | 6 | import { ConfirmationTemplate } from './templates/confirmation.template' 7 | import { ResetPasswordTemplate } from './templates/reset-password.template' 8 | import { TwoFactorAuthTemplate } from './templates/two-factor-auth.template' 9 | 10 | /** 11 | * Сервис для отправки email-сообщений. 12 | * 13 | * Этот сервис предоставляет методы для отправки различных типов email-сообщений, 14 | * включая подтверждение почты, сброс пароля и двухфакторную аутентификацию. 15 | */ 16 | @Injectable() 17 | export class MailService { 18 | /** 19 | * Конструктор сервиса почты. 20 | * @param mailerService - Сервис для работы с отправкой email. 21 | * @param configService - Сервис для работы с конфигурацией приложения. 22 | */ 23 | public constructor( 24 | private readonly mailerService: MailerService, 25 | private readonly configService: ConfigService 26 | ) {} 27 | 28 | /** 29 | * Отправляет email для подтверждения почты. 30 | * @param email - Адрес электронной почты получателя. 31 | * @param token - Токен подтверждения. 32 | * @returns Промис, который разрешается при успешной отправке. 33 | */ 34 | public async sendConfirmationEmail(email: string, token: string) { 35 | const domain = this.configService.getOrThrow('ALLOWED_ORIGIN') 36 | const html = await render(ConfirmationTemplate({ domain, token })) 37 | 38 | return this.sendMail(email, 'Подтверждение почты', html) 39 | } 40 | 41 | /** 42 | * Отправляет email для сброса пароля. 43 | * @param email - Адрес электронной почты получателя. 44 | * @param token - Токен для сброса пароля. 45 | * @returns Промис, который разрешается при успешной отправке. 46 | */ 47 | public async sendPasswordResetEmail(email: string, token: string) { 48 | const domain = this.configService.getOrThrow('ALLOWED_ORIGIN') 49 | const html = await render(ResetPasswordTemplate({ domain, token })) 50 | 51 | return this.sendMail(email, 'Сброс пароля', html) 52 | } 53 | 54 | /** 55 | * Отправляет email с токеном двухфакторной аутентификации. 56 | * @param email - Адрес электронной почты получателя. 57 | * @param token - Токен двухфакторной аутентификации. 58 | * @returns Промис, который разрешается при успешной отправке. 59 | */ 60 | public async sendTwoFactorTokenEmail(email: string, token: string) { 61 | const html = await render(TwoFactorAuthTemplate({ token })) 62 | 63 | return this.sendMail(email, 'Подтверждение вашей личности', html) 64 | } 65 | 66 | /** 67 | * Отправляет email-сообщение. 68 | * @param email - Адрес электронной почты получателя. 69 | * @param subject - Тема email-сообщения. 70 | * @param html - HTML-содержимое email-сообщения. 71 | * @returns Промис, который разрешается при успешной отправке. 72 | */ 73 | private sendMail(email: string, subject: string, html: string) { 74 | return this.mailerService.sendMail({ 75 | to: email, 76 | subject, 77 | html 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common' 2 | import { AuthMethod } from '@prisma/__generated__' 3 | import { hash } from 'argon2' 4 | 5 | import { PrismaService } from '@/prisma/prisma.service' 6 | 7 | import { UpdateUserDto } from './dto/update-user.dto' 8 | 9 | /** 10 | * Сервис для работы с пользователями. 11 | */ 12 | @Injectable() 13 | export class UserService { 14 | /** 15 | * Конструктор сервиса пользователей. 16 | * @param prismaService - Сервис для работы с базой данных Prisma. 17 | */ 18 | public constructor(private readonly prismaService: PrismaService) {} 19 | 20 | /** 21 | * Находит пользователя по ID. 22 | * @param {string} id - ID пользователя. 23 | * @returns {Promise} Найденный пользователь. 24 | * @throws {NotFoundException} Если пользователь не найден. 25 | */ 26 | public async findById(id: string) { 27 | const user = await this.prismaService.user.findUnique({ 28 | where: { 29 | id 30 | }, 31 | include: { 32 | accounts: true 33 | } 34 | }) 35 | 36 | if (!user) { 37 | throw new NotFoundException( 38 | 'Пользователь не найден. Пожалуйста, проверьте введенные данные.' 39 | ) 40 | } 41 | 42 | return user 43 | } 44 | 45 | /** 46 | * Находит пользователя по email. 47 | * @param {string} email - Email пользователя. 48 | * @returns {Promise} Найденный пользователь или null, если не найден. 49 | */ 50 | public async findByEmail(email: string) { 51 | const user = await this.prismaService.user.findUnique({ 52 | where: { 53 | email 54 | }, 55 | include: { 56 | accounts: true 57 | } 58 | }) 59 | 60 | return user 61 | } 62 | 63 | /** 64 | * Создает нового пользователя. 65 | * @param email - Email пользователя. 66 | * @param password - Пароль пользователя. 67 | * @param displayName - Отображаемое имя пользователя. 68 | * @param picture - URL аватара пользователя. 69 | * @param method - Метод аутентификации пользователя. 70 | * @param isVerified - Флаг, указывающий, подтвержден ли email пользователя. 71 | * @returns Созданный пользователь. 72 | */ 73 | public async create( 74 | email: string, 75 | password: string, 76 | displayName: string, 77 | picture: string, 78 | method: AuthMethod, 79 | isVerified: boolean 80 | ) { 81 | const user = await this.prismaService.user.create({ 82 | data: { 83 | email, 84 | password: password ? await hash(password) : '', 85 | displayName, 86 | picture, 87 | method, 88 | isVerified 89 | }, 90 | include: { 91 | accounts: true 92 | } 93 | }) 94 | 95 | return user 96 | } 97 | 98 | /** 99 | * Обновляет данные пользователя. 100 | * @param userId - ID пользователя. 101 | * @param dto - Данные для обновления пользователя. 102 | * @returns Обновленный пользователь. 103 | */ 104 | public async update(userId: string, dto: UpdateUserDto) { 105 | const user = await this.findById(userId) 106 | 107 | const updatedUser = await this.prismaService.user.update({ 108 | where: { 109 | id: user.id 110 | }, 111 | data: { 112 | email: dto.email, 113 | displayName: dto.name, 114 | isTwoFactorEnabled: dto.isTwoFactorEnabled 115 | } 116 | }) 117 | 118 | return updatedUser 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/libs/common/utils/ms.util.ts: -------------------------------------------------------------------------------- 1 | // Определение констант для различных единиц времени 2 | const s = 1000 3 | const m = s * 60 4 | const h = m * 60 5 | const d = h * 24 6 | const w = d * 7 7 | const y = d * 365.25 8 | 9 | // Тип для различных единиц времени 10 | type Unit = 11 | | 'Years' 12 | | 'Year' 13 | | 'Yrs' 14 | | 'Yr' 15 | | 'Y' 16 | | 'Weeks' 17 | | 'Week' 18 | | 'W' 19 | | 'Days' 20 | | 'Day' 21 | | 'D' 22 | | 'Hours' 23 | | 'Hour' 24 | | 'Hrs' 25 | | 'Hr' 26 | | 'H' 27 | | 'Minutes' 28 | | 'Minute' 29 | | 'Mins' 30 | | 'Min' 31 | | 'M' 32 | | 'Seconds' 33 | | 'Second' 34 | | 'Secs' 35 | | 'Sec' 36 | | 's' 37 | | 'Milliseconds' 38 | | 'Millisecond' 39 | | 'Msecs' 40 | | 'Msec' 41 | | 'Ms' 42 | 43 | // Тип для единиц времени в любом регистре 44 | type UnitAnyCase = Unit | Uppercase | Lowercase 45 | 46 | // Тип для строкового значения, которое может содержать число и необязательную единицу времени 47 | export type StringValue = 48 | | `${number}` 49 | | `${number}${UnitAnyCase}` 50 | | `${number} ${UnitAnyCase}` 51 | 52 | /** 53 | * Преобразует строковое значение, представляющее время, в миллисекунды. 54 | * 55 | * @param str - Строка, представляющая количество времени, например, "1 hour", "60s", "500 milliseconds". 56 | * @returns Количество миллисекунд, соответствующее указанному времени. 57 | * @throws {Error} Если строка не соответствует ожидаемому формату или если единица времени не распознана. 58 | * 59 | * @example 60 | * ms('1 minute'); // вернет 60000 61 | * ms('2 hours'); // вернет 7200000 62 | * ms('500 ms'); // вернет 500 63 | */ 64 | export function ms(str: StringValue): number { 65 | // Проверка входных данных 66 | if (typeof str !== 'string' || str.length === 0 || str.length > 100) { 67 | throw new Error( 68 | 'Value provided to ms() must be a string with length between 1 and 99.' 69 | ) 70 | } 71 | 72 | // Регулярное выражение для сопоставления строки с числом и необязательной единицей времени 73 | const match = 74 | /^(?-?(?:\d+)?\.?\d+) *(?milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec( 75 | str 76 | ) 77 | 78 | // Извлечение значения и типа из совпадения 79 | const groups = match?.groups as { value: string; type?: string } | undefined 80 | if (!groups) { 81 | return NaN 82 | } 83 | const n = parseFloat(groups.value) 84 | const type = (groups.type || 'ms').toLowerCase() as Lowercase 85 | 86 | // Преобразование строкового значения в миллисекунды в зависимости от единицы времени 87 | switch (type) { 88 | case 'years': 89 | case 'year': 90 | case 'yrs': 91 | case 'yr': 92 | case 'y': 93 | return n * y 94 | case 'weeks': 95 | case 'week': 96 | case 'w': 97 | return n * w 98 | case 'days': 99 | case 'day': 100 | case 'd': 101 | return n * d 102 | case 'hours': 103 | case 'hour': 104 | case 'hrs': 105 | case 'hr': 106 | case 'h': 107 | return n * h 108 | case 'minutes': 109 | case 'minute': 110 | case 'mins': 111 | case 'min': 112 | case 'm': 113 | return n * m 114 | case 'seconds': 115 | case 'second': 116 | case 'secs': 117 | case 'sec': 118 | case 's': 119 | return n * s 120 | case 'milliseconds': 121 | case 'millisecond': 122 | case 'msecs': 123 | case 'msec': 124 | case 'ms': 125 | return n 126 | default: 127 | throw new Error( 128 | `Ошибка: единица времени ${type} была распознана, но не существует соответствующего случая. Пожалуйста, проверьте введенные данные.` 129 | ) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/auth/two-factor-auth/two-factor-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException 5 | } from '@nestjs/common' 6 | import { TokenType } from '@prisma/__generated__' 7 | 8 | import { MailService } from '@/libs/mail/mail.service' 9 | import { PrismaService } from '@/prisma/prisma.service' 10 | 11 | /** 12 | * Сервис для управления двухфакторной аутентификацией. 13 | */ 14 | @Injectable() 15 | export class TwoFactorAuthService { 16 | /** 17 | * Конструктор сервиса двухфакторной аутентификации. 18 | * @param prismaService - Сервис для работы с базой данных Prisma. 19 | * @param mailService - Сервис для отправки email-сообщений. 20 | */ 21 | public constructor( 22 | private readonly prismaService: PrismaService, 23 | private readonly mailService: MailService 24 | ) {} 25 | 26 | /** 27 | * Проверяет токен двухфакторной аутентификации. 28 | * @param email - Адрес электронной почты пользователя. 29 | * @param code - Код двухфакторной аутентификации, введенный пользователем. 30 | * @returns true, если токен действителен; в противном случае выбрасывает исключения. 31 | * @throws NotFoundException - Если токен не найден. 32 | * @throws BadRequestException - Если код неверен или срок действия токена истек. 33 | */ 34 | public async validateTwoFactorToken(email: string, code: string) { 35 | const existingToken = await this.prismaService.token.findFirst({ 36 | where: { 37 | email, 38 | type: TokenType.TWO_FACTOR 39 | } 40 | }) 41 | 42 | if (!existingToken) { 43 | throw new NotFoundException( 44 | 'Токен двухфакторной аутентификации не найден. Убедитесь, что вы запрашивали токен для данного адреса электронной почты.' 45 | ) 46 | } 47 | 48 | if (existingToken.token !== code) { 49 | throw new BadRequestException( 50 | 'Неверный код двухфакторной аутентификации. Пожалуйста, проверьте введенный код и попробуйте снова.' 51 | ) 52 | } 53 | 54 | const hasExpired = new Date(existingToken.expiresIn) < new Date() 55 | 56 | if (hasExpired) { 57 | throw new BadRequestException( 58 | 'Срок действия токена двухфакторной аутентификации истек. Пожалуйста, запросите новый токен.' 59 | ) 60 | } 61 | 62 | await this.prismaService.token.delete({ 63 | where: { 64 | id: existingToken.id, 65 | type: TokenType.TWO_FACTOR 66 | } 67 | }) 68 | 69 | return true 70 | } 71 | 72 | /** 73 | * Отправляет токен двухфакторной аутентификации на указанный email. 74 | * @param email - Адрес электронной почты пользователя, которому нужно отправить токен. 75 | * @returns true, если токен успешно отправлен. 76 | */ 77 | public async sendTwoFactorToken(email: string) { 78 | const twoFactorToken = await this.generateTwoFactorToken(email) 79 | 80 | await this.mailService.sendTwoFactorTokenEmail( 81 | twoFactorToken.email, 82 | twoFactorToken.token 83 | ) 84 | 85 | return true 86 | } 87 | 88 | /** 89 | * Генерирует новый токен двухфакторной аутентификации. 90 | * @param email - Адрес электронной почты пользователя. 91 | * @returns Объект токена двухфакторной аутентификации. 92 | */ 93 | private async generateTwoFactorToken(email: string) { 94 | const token = Math.floor( 95 | Math.random() * (1000000 - 100000) + 100000 96 | ).toString() 97 | const expiresIn = new Date(new Date().getTime() + 300000) 98 | 99 | const existingToken = await this.prismaService.token.findFirst({ 100 | where: { 101 | email, 102 | type: TokenType.TWO_FACTOR 103 | } 104 | }) 105 | 106 | if (existingToken) { 107 | await this.prismaService.token.delete({ 108 | where: { 109 | id: existingToken.id, 110 | type: TokenType.TWO_FACTOR 111 | } 112 | }) 113 | } 114 | 115 | const twoFactorToken = await this.prismaService.token.create({ 116 | data: { 117 | email, 118 | token, 119 | expiresIn, 120 | type: TokenType.TWO_FACTOR 121 | } 122 | }) 123 | 124 | return twoFactorToken 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | Query, 11 | Req, 12 | Res, 13 | UseGuards 14 | } from '@nestjs/common' 15 | import { ConfigService } from '@nestjs/config' 16 | import { Recaptcha } from '@nestlab/google-recaptcha' 17 | import { Request, Response } from 'express' 18 | 19 | import { AuthService } from './auth.service' 20 | import { LoginDto } from './dto/login.dto' 21 | import { RegisterDto } from './dto/register.dto' 22 | import { AuthProviderGuard } from './guards/provider.guard' 23 | import { ProviderService } from './provider/provider.service' 24 | 25 | /** 26 | * Контроллер для управления авторизацией пользователей. 27 | */ 28 | @Controller('auth') 29 | export class AuthController { 30 | /** 31 | * Конструктор контроллера аутентификации. 32 | * @param authService - Сервис для аутентификации. 33 | * @param configService - Сервис для работы с конфигурацией приложения. 34 | * @param providerService - Сервис для работы с провайдерами аутентификации. 35 | */ 36 | public constructor( 37 | private readonly authService: AuthService, 38 | private readonly configService: ConfigService, 39 | private readonly providerService: ProviderService 40 | ) {} 41 | 42 | /** 43 | * Регистрация нового пользователя. 44 | * @param dto - Объект с данными для регистрации пользователя. 45 | * @returns Ответ от сервиса аутентификации. 46 | */ 47 | @Recaptcha() 48 | @Post('register') 49 | @HttpCode(HttpStatus.OK) 50 | public async register(@Body() dto: RegisterDto) { 51 | return this.authService.register(dto) 52 | } 53 | 54 | /** 55 | * Вход пользователя в систему. 56 | * @param req - Объект запроса Express. 57 | * @param dto - Объект с данными для входа пользователя. 58 | * @returns Ответ от сервиса аутентификации. 59 | */ 60 | @Recaptcha() 61 | @Post('login') 62 | @HttpCode(HttpStatus.OK) 63 | public async login(@Req() req: Request, @Body() dto: LoginDto) { 64 | return this.authService.login(req, dto) 65 | } 66 | 67 | /** 68 | * Обработка колбэка от провайдера аутентификации. 69 | * @param req - Объект запроса Express. 70 | * @param res - Объект ответа Express. 71 | * @param code - Код авторизации, полученный от провайдера. 72 | * @param provider - Название провайдера аутентификации. 73 | * @returns Перенаправление на страницу настроек. 74 | * @throws BadRequestException - Если код авторизации не был предоставлен. 75 | */ 76 | @UseGuards(AuthProviderGuard) 77 | @Get('/oauth/callback/:provider') 78 | public async callback( 79 | @Req() req: Request, 80 | @Res({ passthrough: true }) res: Response, 81 | @Query('code') code: string, 82 | @Param('provider') provider: string 83 | ) { 84 | if (!code) { 85 | throw new BadRequestException( 86 | 'Не был предоставлен код авторизации.' 87 | ) 88 | } 89 | 90 | await this.authService.extractProfileFromCode(req, provider, code) 91 | 92 | return res.redirect( 93 | `${this.configService.getOrThrow('ALLOWED_ORIGIN')}/dashboard/settings` 94 | ) 95 | } 96 | 97 | /** 98 | * Подключение пользователя к провайдеру аутентификации. 99 | * @param provider - Название провайдера аутентификации. 100 | * @returns URL для аутентификации через провайдера. 101 | */ 102 | @UseGuards(AuthProviderGuard) 103 | @Get('/oauth/connect/:provider') 104 | public async connect(@Param('provider') provider: string) { 105 | const providerInstance = this.providerService.findByService(provider) 106 | 107 | return { 108 | url: providerInstance.getAuthUrl() 109 | } 110 | } 111 | 112 | /** 113 | * Завершение сессии пользователя. 114 | * @param req - Объект запроса Express. 115 | * @param res - Объект ответа Express. 116 | * @returns Ответ от сервиса аутентификации. 117 | */ 118 | @Post('logout') 119 | @HttpCode(HttpStatus.OK) 120 | public async logout( 121 | @Req() req: Request, 122 | @Res({ passthrough: true }) res: Response 123 | ) { 124 | return this.authService.logout(req, res) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/auth/email-confirmation/email-confirmation.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | forwardRef, 4 | Inject, 5 | Injectable, 6 | NotFoundException 7 | } from '@nestjs/common' 8 | import { TokenType } from '@prisma/__generated__' 9 | import { Request } from 'express' 10 | import { v4 as uuidv4 } from 'uuid' 11 | 12 | import { MailService } from '@/libs/mail/mail.service' 13 | import { PrismaService } from '@/prisma/prisma.service' 14 | import { UserService } from '@/user/user.service' 15 | 16 | import { AuthService } from '../auth.service' 17 | 18 | import { ConfirmationDto } from './dto/confirmation.dto' 19 | 20 | /** 21 | * Сервис для управления подтверждением электронной почты. 22 | */ 23 | @Injectable() 24 | export class EmailConfirmationService { 25 | /** 26 | * Конструктор сервиса подтверждения электронной почты. 27 | * @param prismaService - Сервис для работы с базой данных Prisma. 28 | * @param mailService - Сервис для отправки email-сообщений. 29 | * @param userService - Сервис для работы с пользователями. 30 | * @param authService - Сервис для аутентификации (внедряется через forwardRef). 31 | */ 32 | public constructor( 33 | private readonly prismaService: PrismaService, 34 | private readonly mailService: MailService, 35 | private readonly userService: UserService, 36 | @Inject(forwardRef(() => AuthService)) 37 | private readonly authService: AuthService 38 | ) {} 39 | 40 | /** 41 | * Обрабатывает новый запрос на подтверждение электронной почты. 42 | * @param req - Объект запроса Express. 43 | * @param dto - DTO с токеном подтверждения. 44 | * @returns Сессия пользователя после успешного подтверждения. 45 | * @throws NotFoundException - Если токен или пользователь не найден. 46 | * @throws BadRequestException - Если токен истек. 47 | */ 48 | public async newVerification(req: Request, dto: ConfirmationDto) { 49 | const existingToken = await this.prismaService.token.findUnique({ 50 | where: { 51 | token: dto.token, 52 | type: TokenType.VERIFICATION 53 | } 54 | }) 55 | 56 | if (!existingToken) { 57 | throw new NotFoundException( 58 | 'Токен подтверждения не найден. Пожалуйста, убедитесь, что у вас правильный токен.' 59 | ) 60 | } 61 | 62 | const hasExpired = new Date(existingToken.expiresIn) < new Date() 63 | 64 | if (hasExpired) { 65 | throw new BadRequestException( 66 | 'Токен подтверждения истек. Пожалуйста, запросите новый токен для подтверждения.' 67 | ) 68 | } 69 | 70 | const existingUser = await this.userService.findByEmail( 71 | existingToken.email 72 | ) 73 | 74 | if (!existingUser) { 75 | throw new NotFoundException( 76 | 'Пользователь не найден. Пожалуйста, проверьте введенный адрес электронной почты и попробуйте снова.' 77 | ) 78 | } 79 | 80 | await this.prismaService.user.update({ 81 | where: { 82 | id: existingUser.id 83 | }, 84 | data: { 85 | isVerified: true 86 | } 87 | }) 88 | 89 | await this.prismaService.token.delete({ 90 | where: { 91 | id: existingToken.id, 92 | type: TokenType.VERIFICATION 93 | } 94 | }) 95 | 96 | return this.authService.saveSession(req, existingUser) 97 | } 98 | 99 | /** 100 | * Отправляет токен подтверждения на указанный email. 101 | * @param email - Адрес электронной почты пользователя. 102 | * @returns true, если токен успешно отправлен. 103 | */ 104 | public async sendVerificationToken(email: string) { 105 | const verificationToken = await this.generateVerificationToken(email) 106 | 107 | await this.mailService.sendConfirmationEmail( 108 | verificationToken.email, 109 | verificationToken.token 110 | ) 111 | 112 | return true 113 | } 114 | 115 | /** 116 | * Генерирует новый токен подтверждения электронной почты. 117 | * @param email - Адрес электронной почты пользователя. 118 | * @returns Объект токена подтверждения. 119 | */ 120 | private async generateVerificationToken(email: string) { 121 | const token = uuidv4() 122 | const expiresIn = new Date(new Date().getTime() + 3600 * 1000) 123 | 124 | const existingToken = await this.prismaService.token.findFirst({ 125 | where: { 126 | email, 127 | type: TokenType.VERIFICATION 128 | } 129 | }) 130 | 131 | if (existingToken) { 132 | await this.prismaService.token.delete({ 133 | where: { 134 | id: existingToken.id, 135 | type: TokenType.VERIFICATION 136 | } 137 | }) 138 | } 139 | 140 | const verificationToken = await this.prismaService.token.create({ 141 | data: { 142 | email, 143 | token, 144 | expiresIn, 145 | type: TokenType.VERIFICATION 146 | } 147 | }) 148 | 149 | return verificationToken 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/auth/password-recovery/password-recovery.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException 5 | } from '@nestjs/common' 6 | import { TokenType } from '@prisma/__generated__' 7 | import { hash } from 'argon2' 8 | import { v4 as uuidv4 } from 'uuid' 9 | 10 | import { MailService } from '@/libs/mail/mail.service' 11 | import { PrismaService } from '@/prisma/prisma.service' 12 | import { UserService } from '@/user/user.service' 13 | 14 | import { NewPasswordDto } from './dto/new-password.dto' 15 | import { ResetPasswordDto } from './dto/reset-password.dto' 16 | 17 | /** 18 | * Сервис для управления восстановлением пароля. 19 | */ 20 | @Injectable() 21 | export class PasswordRecoveryService { 22 | /** 23 | * Конструктор сервиса восстановления пароля. 24 | * @param prismaService - Сервис для работы с базой данных Prisma. 25 | * @param userService - Сервис для работы с пользователями. 26 | * @param mailService - Сервис для отправки email-сообщений. 27 | */ 28 | public constructor( 29 | private readonly prismaService: PrismaService, 30 | private readonly userService: UserService, 31 | private readonly mailService: MailService 32 | ) {} 33 | 34 | /** 35 | * Запрашивает сброс пароля и отправляет токен на указанный email. 36 | * @param dto - DTO с адресом электронной почты пользователя. 37 | * @returns true, если токен успешно отправлен. 38 | * @throws NotFoundException - Если пользователь не найден. 39 | */ 40 | public async reset(dto: ResetPasswordDto) { 41 | const existingUser = await this.userService.findByEmail(dto.email) 42 | 43 | if (!existingUser) { 44 | throw new NotFoundException( 45 | 'Пользователь не найден. Пожалуйста, проверьте введенный адрес электронной почты и попробуйте снова.' 46 | ) 47 | } 48 | 49 | const passwordResetToken = await this.generatePasswordResetToken( 50 | existingUser.email 51 | ) 52 | 53 | await this.mailService.sendPasswordResetEmail( 54 | passwordResetToken.email, 55 | passwordResetToken.token 56 | ) 57 | 58 | return true 59 | } 60 | 61 | /** 62 | * Устанавливает новый пароль для пользователя. 63 | * @param dto - DTO с новым паролем. 64 | * @param token - Токен для сброса пароля. 65 | * @returns true, если пароль успешно изменен. 66 | * @throws NotFoundException - Если токен или пользователь не найден. 67 | * @throws BadRequestException - Если токен истек. 68 | */ 69 | public async new(dto: NewPasswordDto, token: string) { 70 | const existingToken = await this.prismaService.token.findFirst({ 71 | where: { 72 | token, 73 | type: TokenType.PASSWORD_RESET 74 | } 75 | }) 76 | 77 | if (!existingToken) { 78 | throw new NotFoundException( 79 | 'Токен не найден. Пожалуйста, проверьте правильность введенного токена или запросите новый.' 80 | ) 81 | } 82 | 83 | const hasExpired = new Date(existingToken.expiresIn) < new Date() 84 | 85 | if (hasExpired) { 86 | throw new BadRequestException( 87 | 'Токен истек. Пожалуйста, запросите новый токен для подтверждения сброса пароля.' 88 | ) 89 | } 90 | 91 | const existingUser = await this.userService.findByEmail( 92 | existingToken.email 93 | ) 94 | 95 | if (!existingUser) { 96 | throw new NotFoundException( 97 | 'Пользователь не найден. Пожалуйста, проверьте введенный адрес электронной почты и попробуйте снова.' 98 | ) 99 | } 100 | 101 | await this.prismaService.user.update({ 102 | where: { 103 | id: existingUser.id 104 | }, 105 | data: { 106 | password: await hash(dto.password) 107 | } 108 | }) 109 | 110 | await this.prismaService.token.delete({ 111 | where: { 112 | id: existingToken.id, 113 | type: TokenType.PASSWORD_RESET 114 | } 115 | }) 116 | 117 | return true 118 | } 119 | 120 | /** 121 | * Генерирует токен для сброса пароля. 122 | * @param email - Адрес электронной почты пользователя. 123 | * @returns Объект токена сброса пароля. 124 | */ 125 | private async generatePasswordResetToken(email: string) { 126 | const token = uuidv4() 127 | const expiresIn = new Date(new Date().getTime() + 3600 * 1000) 128 | 129 | const existingToken = await this.prismaService.token.findFirst({ 130 | where: { 131 | email, 132 | type: TokenType.PASSWORD_RESET 133 | } 134 | }) 135 | 136 | if (existingToken) { 137 | await this.prismaService.token.delete({ 138 | where: { 139 | id: existingToken.id, 140 | type: TokenType.PASSWORD_RESET 141 | } 142 | }) 143 | } 144 | 145 | const passwordResetToken = await this.prismaService.token.create({ 146 | data: { 147 | email, 148 | token, 149 | expiresIn, 150 | type: TokenType.PASSWORD_RESET 151 | } 152 | }) 153 | 154 | return passwordResetToken 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/auth/provider/services/base-oauth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | UnauthorizedException 5 | } from '@nestjs/common' 6 | 7 | import { TypeBaseProviderOptions } from './types/base-provider-options.types' 8 | import { TypeUserInfo } from './types/user-info.types' 9 | 10 | /** 11 | * Базовый сервис для работы с OAuth-провайдерами. 12 | * 13 | * Этот сервис предоставляет общие методы для аутентификации через OAuth, такие как 14 | * получение URL для авторизации, извлечение информации о пользователе и обработка токенов. 15 | */ 16 | @Injectable() 17 | export class BaseOAuthService { 18 | private BASE_URL: string 19 | 20 | /** 21 | * Конструктор базового сервиса OAuth. 22 | * 23 | * @param options - Опции провайдера, содержащие необходимые параметры для аутентификации. 24 | */ 25 | public constructor(private readonly options: TypeBaseProviderOptions) {} 26 | 27 | /** 28 | * Извлекает информацию о пользователе из данных, полученных от провайдера. 29 | * 30 | * @param data - Данные, полученные от провайдера. 31 | * @returns Объект с информацией о пользователе, включая имя провайдера. 32 | */ 33 | protected async extractUserInfo(data: any): Promise { 34 | return { 35 | ...data, 36 | provider: this.options.name 37 | } 38 | } 39 | 40 | /** 41 | * Формирует URL для авторизации. 42 | * 43 | * @returns URL для авторизации пользователя через OAuth. 44 | */ 45 | public getAuthUrl() { 46 | const query = new URLSearchParams({ 47 | response_type: 'code', 48 | client_id: this.options.client_id, 49 | redirect_uri: this.getRedirectUrl(), 50 | scope: (this.options.scopes ?? []).join(' '), 51 | access_type: 'offline', 52 | prompt: 'select_account' 53 | }) 54 | 55 | return `${this.options.authorize_url}?${query}` 56 | } 57 | 58 | /** 59 | * Находит пользователя по коду авторизации и возвращает информацию о пользователе. 60 | * 61 | * @param code - Код авторизации, полученный от провайдера. 62 | * @returns Объект с информацией о пользователе. 63 | * @throws BadRequestException - Если не удалось получить токены или пользователь. 64 | * @throws UnauthorizedException - Если токен доступа недействителен. 65 | */ 66 | public async findUserByCode(code: string): Promise { 67 | const client_id = this.options.client_id 68 | const client_secret = this.options.client_secret 69 | 70 | const tokenQuery = new URLSearchParams({ 71 | client_id, 72 | client_secret, 73 | code, 74 | redirect_uri: this.getRedirectUrl(), 75 | grant_type: 'authorization_code' 76 | }) 77 | 78 | const tokensRequest = await fetch(this.options.access_url, { 79 | method: 'POST', 80 | body: tokenQuery, 81 | headers: { 82 | 'Content-Type': 'application/x-www-form-urlencoded', 83 | Accept: 'application/json' 84 | } 85 | }) 86 | 87 | if (!tokensRequest.ok) { 88 | throw new BadRequestException( 89 | `Не удалось получить пользователя с ${this.options.profile_url}. Проверьте правильность токена доступа.` 90 | ) 91 | } 92 | 93 | const tokens = await tokensRequest.json() 94 | 95 | if (!tokens.access_token) { 96 | throw new BadRequestException( 97 | `Нет токенов с ${this.options.access_url}. Убедитесь, что код авторизации действителен.` 98 | ) 99 | } 100 | 101 | const userRequest = await fetch(this.options.profile_url, { 102 | headers: { 103 | Authorization: `Bearer ${tokens.access_token}` 104 | } 105 | }) 106 | 107 | if (!userRequest.ok) { 108 | throw new UnauthorizedException( 109 | `Не удалось получить пользователя с ${this.options.profile_url}. Проверьте правильность токена доступа.` 110 | ) 111 | } 112 | 113 | const user = await userRequest.json() 114 | const userData = await this.extractUserInfo(user) 115 | 116 | return { 117 | ...userData, 118 | access_token: tokens.access_token, 119 | refresh_token: tokens.refresh_token, 120 | expires_at: tokens.expires_at || tokens.expires_in, 121 | provider: this.options.name 122 | } 123 | } 124 | 125 | /** 126 | * Возвращает URL для перенаправления после успешной аутентификации. 127 | * 128 | * @returns URL для перенаправления. 129 | */ 130 | private getRedirectUrl() { 131 | return `${this.BASE_URL}/auth/oauth/callback/${this.options.name}` 132 | } 133 | 134 | /** 135 | * Устанавливает базовый URL для сервиса. 136 | * 137 | * @param value - Новый базовый URL. 138 | */ 139 | public set baseUrl(value: string) { 140 | this.BASE_URL = value 141 | } 142 | 143 | /** 144 | * Возвращает имя провайдера. 145 | * 146 | * @returns Имя провайдера. 147 | */ 148 | public get name() { 149 | return this.options.name 150 | } 151 | 152 | /** 153 | * Возвращает URL для доступа. 154 | * 155 | * @returns URL для доступа. 156 | */ 157 | public get access_url() { 158 | return this.options.access_url 159 | } 160 | 161 | /** 162 | * Возвращает URL для профиля. 163 | * 164 | * @returns URL для профиля. 165 | */ 166 | public get profile_url() { 167 | return this.options.profile_url 168 | } 169 | 170 | /** 171 | * Возвращает массив с областями доступа. 172 | * 173 | * @returns Массив областей доступа. 174 | */ 175 | public get scopes() { 176 | return this.options.scopes 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConflictException, 3 | Injectable, 4 | InternalServerErrorException, 5 | NotFoundException, 6 | UnauthorizedException 7 | } from '@nestjs/common' 8 | import { ConfigService } from '@nestjs/config' 9 | import { AuthMethod, User } from '@prisma/__generated__' 10 | import { verify } from 'argon2' 11 | import { Request, Response } from 'express' 12 | 13 | import { PrismaService } from '@/prisma/prisma.service' 14 | import { UserService } from '@/user/user.service' 15 | 16 | import { LoginDto } from './dto/login.dto' 17 | import { RegisterDto } from './dto/register.dto' 18 | import { EmailConfirmationService } from './email-confirmation/email-confirmation.service' 19 | import { ProviderService } from './provider/provider.service' 20 | import { TwoFactorAuthService } from './two-factor-auth/two-factor-auth.service' 21 | 22 | /** 23 | * Сервис для аутентификации и управления сессиями пользователей. 24 | */ 25 | @Injectable() 26 | export class AuthService { 27 | /** 28 | * Конструктор сервиса аутентификации. 29 | * @param prismaService - Сервис для работы с базой данных Prisma. 30 | * @param userService - Сервис для работы с пользователями. 31 | * @param configService - Сервис для работы с конфигурацией приложения. 32 | * @param providerService - Сервис для работы с провайдерами аутентификации. 33 | * @param emailConfirmationService - Сервис для работы с подтверждением email. 34 | * @param twoFactorAuthService - Сервис для работы с двухфакторной аутентификацией. 35 | */ 36 | public constructor( 37 | private readonly prismaService: PrismaService, 38 | private readonly userService: UserService, 39 | private readonly configService: ConfigService, 40 | private readonly providerService: ProviderService, 41 | private readonly emailConfirmationService: EmailConfirmationService, 42 | private readonly twoFactorAuthService: TwoFactorAuthService 43 | ) {} 44 | 45 | /** 46 | * Регистрирует нового пользователя. 47 | * @param dto - Объект с данными для регистрации пользователя. 48 | * @returns Объект с сообщением об успешной регистрации. 49 | * @throws ConflictException - Если пользователь с таким email уже существует. 50 | */ 51 | public async register(dto: RegisterDto) { 52 | const isExists = await this.userService.findByEmail(dto.email) 53 | 54 | if (isExists) { 55 | throw new ConflictException( 56 | 'Регистрация не удалась. Пользователь с таким email уже существует. Пожалуйста, используйте другой email или войдите в систему.' 57 | ) 58 | } 59 | 60 | const newUser = await this.userService.create( 61 | dto.email, 62 | dto.password, 63 | dto.name, 64 | '', 65 | AuthMethod.CREDENTIALS, 66 | false 67 | ) 68 | 69 | await this.emailConfirmationService.sendVerificationToken(newUser.email) 70 | 71 | return { 72 | message: 73 | 'Вы успешно зарегистрировались. Пожалуйста, подтвердите ваш email. Сообщение было отправлено на ваш почтовый адрес.' 74 | } 75 | } 76 | 77 | /** 78 | * Выполняет вход пользователя в систему. 79 | * @param req - Объект запроса Express. 80 | * @param dto - Объект с данными для входа пользователя. 81 | * @returns Объект с пользователем после успешного входа. 82 | * @throws NotFoundException - Если пользователь не найден. 83 | * @throws UnauthorizedException - Если пароль неверный или email не подтвержден. 84 | */ 85 | public async login(req: Request, dto: LoginDto) { 86 | const user = await this.userService.findByEmail(dto.email) 87 | 88 | if (!user || !user.password) { 89 | throw new NotFoundException( 90 | 'Пользователь не найден. Пожалуйста, проверьте введенные данные' 91 | ) 92 | } 93 | 94 | const isValidPassword = await verify(user.password, dto.password) 95 | 96 | if (!isValidPassword) { 97 | throw new UnauthorizedException( 98 | 'Неверный пароль. Пожалуйста, попробуйте еще раз или восстановите пароль, если забыли его.' 99 | ) 100 | } 101 | 102 | if (!user.isVerified) { 103 | await this.emailConfirmationService.sendVerificationToken( 104 | user.email 105 | ) 106 | throw new UnauthorizedException( 107 | 'Ваш email не подтвержден. Пожалуйста, проверьте вашу почту и подтвердите адрес.' 108 | ) 109 | } 110 | 111 | if (user.isTwoFactorEnabled) { 112 | if (!dto.code) { 113 | await this.twoFactorAuthService.sendTwoFactorToken(user.email) 114 | 115 | return { 116 | message: 117 | 'Проверьте вашу почту. Требуется код двухфакторной аутентификации.' 118 | } 119 | } 120 | 121 | await this.twoFactorAuthService.validateTwoFactorToken( 122 | user.email, 123 | dto.code 124 | ) 125 | } 126 | 127 | return this.saveSession(req, user) 128 | } 129 | 130 | /** 131 | * Извлекает профиль пользователя из кода авторизации провайдера. 132 | * @param req - Объект запроса Express. 133 | * @param provider - Название провайдера аутентификации. 134 | * @param code - Код авторизации провайдера. 135 | * @returns Объект с пользователем после успешной аутентификации. 136 | */ 137 | public async extractProfileFromCode( 138 | req: Request, 139 | provider: string, 140 | code: string 141 | ) { 142 | const providerInstance = this.providerService.findByService(provider) 143 | const profile = await providerInstance.findUserByCode(code) 144 | 145 | const account = await this.prismaService.account.findFirst({ 146 | where: { 147 | id: profile.id, 148 | provider: profile.provider 149 | } 150 | }) 151 | 152 | let user = account?.userId 153 | ? await this.userService.findById(account.userId) 154 | : null 155 | 156 | if (user) { 157 | return this.saveSession(req, user) 158 | } 159 | 160 | user = await this.userService.create( 161 | profile.email, 162 | '', 163 | profile.name, 164 | profile.picture, 165 | AuthMethod[profile.provider.toUpperCase()], 166 | true 167 | ) 168 | 169 | if (!account) { 170 | await this.prismaService.account.create({ 171 | data: { 172 | userId: user.id, 173 | type: 'oauth', 174 | provider: profile.provider, 175 | accessToken: profile.access_token, 176 | refreshToken: profile.refresh_token, 177 | expiresAt: profile.expires_at 178 | } 179 | }) 180 | } 181 | 182 | return this.saveSession(req, user) 183 | } 184 | 185 | /** 186 | * Завершает текущую сессию пользователя. 187 | * @param req - Объект запроса Express. 188 | * @param res - Объект ответа Express. 189 | * @returns Промис, который разрешается после завершения сессии. 190 | * @throws InternalServerErrorException - Если возникла проблема при завершении сессии. 191 | */ 192 | public async logout(req: Request, res: Response): Promise { 193 | return new Promise((resolve, reject) => { 194 | req.session.destroy(err => { 195 | if (err) { 196 | return reject( 197 | new InternalServerErrorException( 198 | 'Не удалось завершить сессию. Возможно, возникла проблема с сервером или сессия уже была завершена.' 199 | ) 200 | ) 201 | } 202 | res.clearCookie( 203 | this.configService.getOrThrow('SESSION_NAME') 204 | ) 205 | resolve() 206 | }) 207 | }) 208 | } 209 | 210 | /** 211 | * Сохраняет сессию пользователя. 212 | * @param req - Объект запроса Express. 213 | * @param user - Объект пользователя. 214 | * @returns Промис, который разрешается после сохранения сессии. 215 | * @throws InternalServerErrorException - Если возникла проблема при сохранении сессии. 216 | */ 217 | public async saveSession(req: Request, user: User) { 218 | return new Promise((resolve, reject) => { 219 | req.session.userId = user.id 220 | 221 | req.session.save(err => { 222 | if (err) { 223 | return reject( 224 | new InternalServerErrorException( 225 | 'Не удалось сохранить сессию. Проверьте, правильно ли настроены параметры сессии.' 226 | ) 227 | ) 228 | } 229 | 230 | resolve({ 231 | user 232 | }) 233 | }) 234 | }) 235 | } 236 | } 237 | --------------------------------------------------------------------------------