feat(backend): add authorization

This commit is contained in:
Sergey Krylov 2025-08-22 05:28:45 +03:00
parent d02d42da83
commit ee7be12428
34 changed files with 1483 additions and 41 deletions

20
compose.yml Normal file
View File

@ -0,0 +1,20 @@
services:
db:
container_name: investments-db
image: postgres:15.2
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5433:5432
volumes:
- postgres_data:/var/lib/posgresql/data
networks:
- investments
volumes:
postgres_data:
networks:
investments:

2
server/.gitignore vendored
View File

@ -54,3 +54,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/generated/prisma

View File

@ -13,6 +13,7 @@ export default [
'@typescript-eslint/no-extraneous-class': 'off',
'@typescript-eslint/parameter-properties': 'off',
'@typescript-eslint/class-methods-use-this': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
},
},
];

838
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,28 @@
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"prisma:validate": "prisma validate",
"prisma:push": "prisma db push",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "11.1.6",
"@nestjs/config": "4.0.2",
"@nestjs/core": "11.1.6",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.6",
"@nestjs/swagger": "11.2.0",
"@prisma/client": "^6.14.0",
"bcrypt": "6.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"cookie-parser": "1.4.7",
"passport-jwt": "4.0.1",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
},
@ -23,12 +38,16 @@
"@nestjs/testing": "11.1.6",
"@swc/cli": "0.7.8",
"@swc/core": "1.13.3",
"@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.9",
"@types/express": "5.0.3",
"@types/node": "22.17.1",
"@types/passport-jwt": "4.0.1",
"@typescript-eslint/parser": "8.39.0",
"eslint": "9.33.0",
"eslint-config-ksv741": "0.2.0",
"jiti": "^2.5.1",
"prisma": "^6.14.0",
"source-map-support": "0.5.21",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",

View File

@ -0,0 +1,19 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(uuid())
email String @unique
password String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("accounts")
}

View File

@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,15 @@
import type { ConfigService } from '@nestjs/config';
import type { JwtModuleOptions } from '@nestjs/jwt';
export function getJwtConfig(configService: ConfigService): JwtModuleOptions {
return {
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
algorithm: 'HS256',
},
verifyOptions: {
algorithms: ['HS256'],
ignoreExpiration: false,
},
};
}

View File

@ -1,8 +1,39 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as process from 'node:process';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './modules/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('Investments Control API')
.setDescription('Documentation for **Investments Control API**')
.setVersion(process.env.npm_package_version ?? '0.0.0')
.setContact('Krylov Sergey', 'https://ksv741.tech', 'ksv741@mail.ru')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('/docs', app, document, {
jsonDocumentUrl: 'docs/swagger.json',
yamlDocumentUrl: 'docs/swagger.yaml',
customSiteTitle: 'Investments Control API',
});
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
app.enableCors({
origin: 'http://localhost:3001',
credentials: true,
exposedHeaders: ['set-cookie'],
});
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
PrismaModule,
AuthModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,109 @@
import {
Body,
Controller, Get, HttpCode, HttpStatus,
Post, Req, Res,
} from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiConflictResponse, ApiNotFoundResponse,
ApiOkResponse,
ApiOperation, ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Account } from '@prisma/client';
import type { Response } from 'express';
import { AuthService } from './auth.service';
import { Authorization } from './decorators/authorization.decorator';
import { Authorized } from './decorators/authorized.decorator';
import {
LoginDto,
LoginValidationDto,
IncorrectLoginDto,
SuccessLoginDto,
} from './dto/login';
import { CurrentProfileDto } from './dto/profile';
import {
RegisterValidationDto,
ConflictRegisterDto,
RegisterDto,
} from './dto/register';
import { Request } from './types/request';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@ApiOperation({
summary: 'Регистрация',
description: 'Регистрация аккаунта',
})
@ApiOkResponse({ description: 'Успешная регистрация', type: RegisterDto })
@ApiBadRequestResponse({
description: 'Ошибка валидации',
type: RegisterValidationDto,
})
@ApiConflictResponse({
description: 'Ошибка создания аккаунта',
type: ConflictRegisterDto,
})
@Post('register')
public async register(@Body() body: RegisterDto) {
return this.authService.register(body);
}
@ApiOperation({
summary: 'Логин',
description: 'Вход в аккаунт',
})
@ApiOkResponse({ description: 'Успешный вход в аккаунт', type: SuccessLoginDto })
@ApiBadRequestResponse({
description: 'Неверный логин или пароль',
type: IncorrectLoginDto,
})
@ApiBadRequestResponse({
description: 'Ошибка валидации',
type: LoginValidationDto,
})
@Post('login')
public async login(
@Res({ passthrough: true }) response: Response,
@Body() body: LoginDto,
) {
return this.authService.login(response, body);
}
@ApiOperation({
summary: 'Логаут',
description: 'Выход из аккаунта',
})
@ApiOkResponse({ description: 'Успешный выход из аккаунт', type: Boolean })
@Authorization()
@Post('logout')
public logout(@Res({ passthrough: true }) response: Response) {
return this.authService.logout(response);
}
@ApiOperation({
summary: 'Получить профиль',
description: 'Получить данные о своем профиле',
})
@ApiOkResponse({ description: 'Успешное обновление токенов', type: CurrentProfileDto })
@ApiUnauthorizedResponse({ description: 'Пользователь не авторизован' })
@Authorization()
@Get('me')
@HttpCode(HttpStatus.OK)
public me(@Authorized('id') user: Account['id']) {
return { id: user };
}
@ApiOperation({ summary: 'Обновление access и refresh токенов' })
@ApiOkResponse({ description: 'Успешное обновление токенов', type: SuccessLoginDto })
@ApiUnauthorizedResponse({ description: 'Пользователь не авторизован' })
@ApiNotFoundResponse({ description: '' })
@Post('refresh')
public async refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.refresh(request, response);
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { getJwtConfig } from '../../configs/jwt.config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategies';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: getJwtConfig,
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

View File

@ -0,0 +1,154 @@
import {
BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Account } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import type { Response } from 'express';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto, SuccessLoginDto } from './dto/login';
import { RegisterDto } from './dto/register';
import { JwtPayload } from './types/jwt-payload';
import { Request } from './types/request';
@Injectable()
export class AuthService {
private readonly COOKIE_DOMAIN: string;
private readonly JWT_ACCESS_TOKEN_TTL: string;
private readonly JWT_REFRESH_TOKEN_TTL: string;
constructor(
private readonly configService: ConfigService,
private readonly prismaService: PrismaService,
private readonly jwtService: JwtService,
) {
this.COOKIE_DOMAIN = this.configService.getOrThrow('COOKIE_DOMAIN');
this.JWT_ACCESS_TOKEN_TTL = this.configService.getOrThrow('JWT_ACCESS_TOKEN_TTL');
this.JWT_REFRESH_TOKEN_TTL = this.configService.getOrThrow('JWT_REFRESH_TOKEN_TTL');
}
public async register(dto: RegisterDto) {
const { password, email } = dto;
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(password, salt);
const existingUser = await this.prismaService.account.findUnique({
where: { email },
});
if (existingUser) {
throw new ConflictException('Пользователь с такой почтой уже зарегистрирован');
}
const user = await this.prismaService.account.create({
data: {
email,
password: hashedPassword,
},
});
return {
id: user.id,
email: user.email,
};
}
public async login(response: Response, dto: LoginDto): Promise<SuccessLoginDto> {
const { email, password } = dto;
const existingUser = await this.prismaService.account.findUnique({
where: { email },
});
if (!existingUser) {
throw new BadRequestException('Неверный логин или пароль');
}
const isMatch = await bcrypt.compare(password, existingUser.password);
if (!isMatch) {
throw new BadRequestException('Неверный логин или пароль');
}
const { accessToken } = this.auth(response, existingUser.id);
return {
accessToken,
};
}
public logout(response: Response) {
this.setCookie(response, 'refreshToken', new Date(0));
return true;
}
public async validate(id: string) {
const existingUser = await this.prismaService.account.findUnique({
where: {
id,
},
});
if (!existingUser) {
throw new NotFoundException();
}
return existingUser;
}
public async refresh(req: Request, res: Response): Promise<SuccessLoginDto> {
const { refreshToken } = req.cookies;
if (!refreshToken) {
throw new UnauthorizedException();
}
const payload: JwtPayload = await this.jwtService.verifyAsync(refreshToken);
const existingUser = await this.prismaService.account.findUnique({
where: { id: payload.id },
});
if (!existingUser) {
throw new NotFoundException('Пользователь не нейден');
}
return this.auth(res, existingUser.id);
}
private auth(response: Response, id: Account['id']) {
const { accessToken, refreshToken } = this.generateTokens(id);
this.setCookie(response, refreshToken, new Date(Date.now() + 60 * 60 * 24 * 7));
return { accessToken };
}
private generateTokens(id: Account['id']) {
const payload: JwtPayload = { id };
const accessToken = this.jwtService.sign(payload, {
expiresIn: this.JWT_ACCESS_TOKEN_TTL,
});
const refreshToken = this.jwtService.sign(payload, {
expiresIn: this.JWT_REFRESH_TOKEN_TTL,
});
return {
accessToken,
refreshToken,
};
}
private setCookie(res: Response, value: string, expires: Date) {
res.cookie('refreshToken', value, {
httpOnly: true,
domain: this.COOKIE_DOMAIN,
expires,
secure: false, // todo
sameSite: 'lax', // todo
});
}
}

View File

@ -0,0 +1,6 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../guards/jwt.guard';
export function Authorization() {
return applyDecorators(UseGuards(JwtGuard));
}

View File

@ -0,0 +1,14 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import type { Account } from '@prisma/client';
import type { Request } from '../types/request';
export const Authorized = createParamDecorator(
(data: keyof Account | undefined, ctx: ExecutionContext) => {
const req: Request = ctx.switchToHttp().getRequest();
const { user } = req;
return data ? user[data] : user;
},
);

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class IncorrectLoginDto {
@ApiProperty({ example: 400 })
statusCode: number;
@ApiProperty({ example: 'Неверный логин или пароль' })
message: string[] | string;
@ApiProperty({ example: 'Bad Request' })
error: string;
}

View File

@ -0,0 +1,4 @@
export { IncorrectLoginDto } from './incorrect-login.dto';
export { LoginValidationDto } from './login-validation.dto';
export { LoginDto } from './login.dto';
export { SuccessLoginDto } from './success-login.dto';

View File

@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
export class LoginValidationDto {
@ApiProperty({ example: 400 })
statusCode: number;
@ApiProperty({
example: [
'Email должен быть строкой.',
'Некорректный формат email.',
'Email обязателен для заполнения.',
'Пароль должен быть строкой.',
'Пароль обязателен для заполнения.',
'Пароль должен содержать минимум 6 символов.',
],
type: [String],
})
message: string[];
@ApiProperty({ example: 'Bad Request' })
error: string;
}

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsString,
MinLength,
} from 'class-validator';
export class LoginDto {
@ApiProperty({
description: 'Email',
example: 'mail@example.com',
type: String,
})
@IsString({ message: 'Email должен быть строкой.' })
@IsEmail({}, { message: 'Некорректный формат email.' })
@IsNotEmpty({ message: 'Email обязателен для заполнения.' })
email: string;
@ApiProperty({
description: 'Пароль',
example: '123456',
type: String,
})
@IsString({ message: 'Пароль должен быть строкой.' })
@IsNotEmpty({ message: 'Пароль обязателен для заполнения.' })
@MinLength(6, {
message: 'Пароль должен содержать минимум 6 символов.',
})
password: string;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class SuccessLoginDto {
@ApiProperty({
description: 'Access токен',
type: String,
})
accessToken: string;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { Account } from '@prisma/client';
export class CurrentProfileDto {
@ApiProperty({
description: 'Индетификатор аккаунта',
type: String,
})
id: Account['id'];
}

View File

@ -0,0 +1 @@
export { CurrentProfileDto } from './current-profile.dto';

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class ConflictRegisterDto {
@ApiProperty({ example: 400 })
statusCode: number;
@ApiProperty({ example: 'Пользователь с такой почтой уже зарегистрирован' })
message: string[] | string;
@ApiProperty({ example: 'Conflict' })
error: string;
}

View File

@ -0,0 +1,3 @@
export { ConflictRegisterDto } from './conflict-register.dto';
export { RegisterValidationDto } from './register-validation.dto';
export { RegisterDto } from './register.dto';

View File

@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
export class RegisterValidationDto {
@ApiProperty({ example: 400 })
statusCode: number;
@ApiProperty({
example: [
'Email должен быть строкой.',
'Некорректный формат email.',
'Email обязателен для заполнения.',
'Пароль должен быть строкой.',
'Пароль обязателен для заполнения.',
'Пароль должен содержать минимум 6 символов.',
],
type: [String],
})
message: string[];
@ApiProperty({ example: 'Bad Request' })
error: string;
}

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsString,
MinLength,
} from 'class-validator';
export class RegisterDto {
@ApiProperty({
description: 'Email',
example: 'mail@example.com',
type: String,
})
@IsString({ message: 'Email должен быть строкой.' })
@IsEmail({}, { message: 'Некорректный формат email.' })
@IsNotEmpty({ message: 'Email обязателен для заполнения.' })
email: string;
@ApiProperty({
description: 'Пароль',
example: '123456',
type: String,
})
@IsString({ message: 'Пароль должен быть строкой.' })
@IsNotEmpty({ message: 'Пароль обязателен для заполнения.' })
@MinLength(6, {
message: 'Пароль должен содержать минимум 6 символов.',
})
password: string;
}

View File

@ -0,0 +1,3 @@
import { AuthGuard } from '@nestjs/passport';
export class JwtGuard extends AuthGuard('jwt') {}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthService } from '../auth.service';
type JwtPayload = {
id: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow('JWT_SECRET'),
algorithms: ['HS256'],
});
}
async validate(payload: JwtPayload) {
return this.authService.validate(payload.id);
}
}

View File

@ -0,0 +1,5 @@
import type { Account } from '@prisma/client';
export type JwtPayload = {
id: Account['id'];
};

View File

@ -0,0 +1,10 @@
import type { Account } from '@prisma/client';
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export interface Request {
user: Account;
cookies: {
[key: string]: unknown;
refreshToken?: string;
};
}

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,14 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
// import { PrismaClient } from '../../../prisma/generated/prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
public async onModuleInit() {
await this.$connect();
}
public async onModuleDestroy() {
await this.$disconnect;
}
}