Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_
REDIS_HOST=127.0.0.1
REDIS_PORT=7000

JWT_AUDIENCE="task-tracker-client"

JWT_ACCESS_SECRET=same-same-same-same-same
JWT_ACCESS_EXPIRES_IN=15m

Expand Down
1 change: 0 additions & 1 deletion libs/bootstrap/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export async function bootstrapApp(options: BootstrapOptions) {

let rootModule = appModule;

// TODO: Improve merging modules (in case of multiple features needed) or migrate to fastify throttle
if (throttlerOptions) {
rootModule = setupThrottler(rootModule, throttlerOptions);
}
Expand Down
5 changes: 5 additions & 0 deletions libs/config/src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export const ConfigSchema = z.object({
.min(1, "CORS_ALLOWED_ORIGINS can't be empty")
.transform((val) => val.split(',').map((s) => s.trim()))
.pipe(z.array(z.string().url('Each origin must be a valid URL'))),
JWT_AUDIENCE: z
.string({
error: 'JWT_AUDIENCE is required',
})
.min(1),
JWT_ACCESS_SECRET: z.string().refine(jwtSecretValidation, {
message:
'JWT_ACCESS_SECRET must be at least 32 characters long OR contain at least 5 words separated by hyphens',
Expand Down
17 changes: 14 additions & 3 deletions libs/health/src/controller/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Controller, Get, HttpException, HttpStatus, Inject, Logger } from '@nestjs/common';
import { Controller, Get, HttpStatus, Inject, Logger } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { HealthService } from '../health.service';
import { GetHealthSwagger, GetPingSwagger } from './health.swagger';
import { ApiTags } from '@nestjs/swagger';
import { BaseException } from '@shared/error';

@SkipThrottle()
@Controller()
Expand All @@ -22,8 +23,18 @@ export class HealthController {

if (pingData.status !== 'up') {
this.logger.error(`${this.serviceName} is unhealthy!`);
throw new HttpException(
`${this.serviceName} service is unhealthy.`,
throw new BaseException(
{
code: 'SERVICE_UNHEALTHY',
message: `Сервис ${this.serviceName} временно недоступен или работает некорректно`,
details: [
{
target: this.serviceName,
status: pingData.status,
timestamp: new Date().toISOString(),
},
],
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
Expand Down
29 changes: 15 additions & 14 deletions libs/health/src/controller/health.controlller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,21 @@ describe('HealthController', () => {
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});

describe('checkHealth', () => {
it('should return "healthy" when service status is "up"', async () => {
healthServiceMock.getHealthData.mockResolvedValue({ status: 'up' });

await expect(controller.checkHealth()).resolves.toBe('healthy');
});

it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => {
healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' });

await expect(controller.checkHealth()).rejects.toMatchObject({
status: HttpStatus.SERVICE_UNAVAILABLE,
response: `${SERVICE_NAME} service is unhealthy.`,
});
it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => {
healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' });

await expect(controller.checkHealth()).rejects.toMatchObject({
status: HttpStatus.SERVICE_UNAVAILABLE,
response: {
code: 'SERVICE_UNHEALTHY',
message: expect.stringContaining(SERVICE_NAME),
details: expect.arrayContaining([
expect.objectContaining({
status: 'down',
target: SERVICE_NAME,
}),
]),
},
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface ISessionRepository {
create(data: SessionInsert): Promise<SessionSelect>;
findById(id: string): Promise<SessionSelect | null>;
findAllByUserId(userId: string): Promise<SessionSelect[]>;
revoke(id: string): Promise<void>;
revoke(id: string): Promise<boolean>;
revokeAllByUserId(userId: string, exceptSessionId?: string): Promise<void>;
deleteExpired(): Promise<number>;
}
22 changes: 10 additions & 12 deletions src/modules/auth/repository/session.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { eq, and, ne, lt, desc } from 'drizzle-orm';
import * as schema from '../entities';
import { DATABASE_SERVICE, DatabaseService } from '@libs/database';
import {
ISessionRepository,
type SessionInsert,
SessionSelect,
} from './session.repository.interface';
import { ISessionRepository, type SessionInsert } from './session.repository.interface';

@Injectable()
export class SessionRepository implements ISessionRepository {
Expand All @@ -15,12 +11,12 @@ export class SessionRepository implements ISessionRepository {
private readonly db: DatabaseService<typeof schema>,
) {}

async create(data: SessionInsert): Promise<SessionSelect> {
async create(data: SessionInsert) {
const [result] = await this.db.insert(schema.sessions).values(data).returning();
return result;
}

async findById(id: string): Promise<SessionSelect | null> {
async findById(id: string) {
const [result] = await this.db
.select()
.from(schema.sessions)
Expand All @@ -30,22 +26,24 @@ export class SessionRepository implements ISessionRepository {
return result || null;
}

async findAllByUserId(userId: string): Promise<SessionSelect[]> {
async findAllByUserId(userId: string) {
return this.db
.select()
.from(schema.sessions)
.where(and(eq(schema.sessions.userId, userId), eq(schema.sessions.isRevoked, false)))
.orderBy(desc(schema.sessions.createdAt));
}

async revoke(id: string): Promise<void> {
await this.db
async revoke(id: string) {
const { rowCount } = await this.db
.update(schema.sessions)
.set({ isRevoked: true, updatedAt: new Date() })
.where(eq(schema.sessions.id, id));

return (rowCount ?? 0) > 0;
}

async revokeAllByUserId(userId: string, exceptSessionId?: string): Promise<void> {
async revokeAllByUserId(userId: string, exceptSessionId?: string) {
const filters = [eq(schema.sessions.userId, userId)];

if (exceptSessionId) {
Expand All @@ -58,7 +56,7 @@ export class SessionRepository implements ISessionRepository {
.where(and(...filters));
}

async deleteExpired(): Promise<number> {
async deleteExpired() {
const result = await this.db
.delete(schema.sessions)
.where(lt(schema.sessions.expiresAt, new Date()));
Expand Down
142 changes: 89 additions & 53 deletions src/modules/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { SignInDto, SignUpDto, VerifyDto } from '../dtos';
Expand All @@ -18,6 +12,7 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queues, RegisterCodeEvent } from '@shared/workers';
import type { Queue } from 'bullmq';
import { MailJobs } from '@shared/workers/enum';
import { BaseException } from '@shared/error';

@Injectable()
export class AuthService {
Expand All @@ -39,20 +34,27 @@ export class AuthService {
const cachedData = await this.redis.get(redisKey);

if (cachedData) {
throw new BadRequestException({
code: 'REGISTRATION_IN_PROGRESS',
message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.',
});
throw new BaseException(
{
code: 'REGISTRATION_IN_PROGRESS',
message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.',
details: [{ target: 'email', message: 'Verification code already sent' }],
},
HttpStatus.BAD_REQUEST,
);
}

const isExists = await this.findUserCommand.execute({ email: dto.email });

if (isExists) {
throw new ConflictException({
code: 'USER_ALREADY_EXISTS',
message: 'Email уже занят другим аккаунтом',
details: { email: dto.email },
});
throw new BaseException(
{
code: 'USER_ALREADY_EXISTS',
message: 'Email уже занят другим аккаунтом',
details: [{ target: 'email', value: dto.email }],
},
HttpStatus.CONFLICT,
);
}

const hashPass = await argon.hash(dto.password);
Expand Down Expand Up @@ -95,10 +97,13 @@ export class AuthService {
const cachedData = await this.redis.get(redisKey);

if (!cachedData) {
throw new BadRequestException({
code: 'REGISTRATION_EXPIRED',
message: 'Срок регистрации истек или email не найден. Попробуйте снова.',
});
throw new BaseException(
{
code: 'REGISTRATION_EXPIRED',
message: 'Срок регистрации истек или email не найден. Попробуйте снова.',
},
HttpStatus.GONE,
);
}

const userData = JSON.parse(cachedData);
Expand All @@ -114,10 +119,14 @@ export class AuthService {
});

if (!verifyResult.valid) {
throw new BadRequestException({
code: 'INVALID_OTP',
message: 'Неверный или истекший код подтверждения',
});
throw new BaseException(
{
code: 'INVALID_OTP',
message: 'Неверный или истекший код подтверждения',
details: [{ target: 'code', message: 'OTP code is invalid or expired' }],
},
HttpStatus.BAD_REQUEST,
);
}

const user = await this.createUserCommand.execute({
Expand Down Expand Up @@ -145,19 +154,25 @@ export class AuthService {
const { user, security } = await this.findUserCommand.execute({ email: dto.email });

if (!user || !security) {
throw new UnauthorizedException({
code: 'INVALID_CREDENTIALS',
message: 'Неверный email или пароль',
});
throw new BaseException(
{
code: 'INVALID_CREDENTIALS',
message: 'Неверный email или пароль',
},
HttpStatus.UNAUTHORIZED,
);
}

const isPasswordValid = await argon.verify(security.passwordHash, dto.password);

if (!isPasswordValid) {
throw new UnauthorizedException({
code: 'INVALID_CREDENTIALS',
message: 'Неверный email или пароль',
});
throw new BaseException(
{
code: 'INVALID_CREDENTIALS',
message: 'Неверный email или пароль',
},
HttpStatus.UNAUTHORIZED,
);
}

const { id } = await this.sessionRepo.create({
Expand All @@ -181,30 +196,39 @@ export class AuthService {
public refresh = async (token: string, metadata: DeviceMetadata) => {
const payload = await this.tokenService.validateToken(token, 'refresh');

if (!payload || !payload.jti) {
throw new UnauthorizedException({
code: 'INVALID_TOKEN',
message: 'Сессия недействительна или истекла',
});
if (!payload?.jti) {
throw new BaseException(
{
code: 'INVALID_TOKEN',
message: 'Сессия недействительна или истекла',
},
HttpStatus.UNAUTHORIZED,
);
}

const session = await this.sessionRepo.findById(payload.jti);

if (!session || session.isRevoked) {
throw new UnauthorizedException({
code: 'SESSION_REVOKED',
message: 'Ваша сессия была отозвана или завершена',
});
throw new BaseException(
{
code: 'SESSION_REVOKED',
message: 'Ваша сессия была отозвана или завершена',
},
HttpStatus.UNAUTHORIZED,
);
}

const { user } = await this.findUserCommand.execute({ id: session.userId });

if (!user) {
await this.sessionRepo.revoke(session.id);
throw new UnauthorizedException({
code: 'USER_NOT_FOUND',
message: 'Аккаунт пользователя не найден',
});
throw new BaseException(
{
code: 'USER_NOT_FOUND',
message: 'Аккаунт пользователя не найден',
},
HttpStatus.UNAUTHORIZED,
);
}

await this.sessionRepo.revoke(session.id);
Expand All @@ -228,20 +252,32 @@ export class AuthService {
const payload = await this.tokenService.validateToken(token, 'refresh');

if (!payload?.jti) {
throw new UnauthorizedException({ code: 'SESSION_EXPIRED', message: 'Сессия истекла' });
throw new BaseException(
{
code: 'SESSION_EXPIRED',
message: 'Сессия уже истекла',
},
HttpStatus.UNAUTHORIZED,
);
}

const session = await this.sessionRepo.findById(payload.jti);

if (!session) {
throw new UnauthorizedException({
code: 'SESSION_NOT_FOUND',
message: 'Сессия не найдена',
});
if (session) {
const isRevoked = await this.sessionRepo.revoke(session.id);

if (!isRevoked) {
throw new BaseException(
{
code: 'SIGNOUT_FAILED',
message: 'Не удалось завершить сессию на сервере. Попробуйте позже.',
details: [{ target: 'database', message: 'Session revocation failed' }],
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}

await this.sessionRepo.revoke(session.id);

return { success: true, message: 'Успешно вышли из системы!' };
};
}
Loading
Loading