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
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ JWT_SECRET=секретный_ключ_jwt
JWT_ACCESS_TOKEN_TTL=время_жизни_токена_доступа
JWT_REFRESH_TOKEN_TTL=время_жизни_токена_обновления

# URL Redis
REDIS_URL=redis://localhost:6379

# PostgreSQL Database URL
# Формат: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA
DATABASE_URL="postgresql://postgres:password@host:port/mydb?schema=public"
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"cookie-parser": "^1.4.7",
"dotenv": "^17.3.1",
"escape-html": "^1.0.3",
"ioredis": "^5.10.0",
"nestjs-zod": "^5.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
Expand Down
11 changes: 8 additions & 3 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@ import { ConfigModule } from '@nestjs/config'
import { PrismaModule } from '../prisma/prisma.module'
import { AuthModule } from './auth/auth.module'
import { CustomZodValidationPipe } from './common/providers/zod-validation.provider'
import { RedisModule } from './common/redis/redis.module'

@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), PrismaModule, AuthModule],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
RedisModule,
AuthModule,
],
controllers: [AppController],
providers: [
AppService,
{ provide: APP_PIPE, useClass: CustomZodValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
]
],
})

export class AppModule {}
4 changes: 2 additions & 2 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ export class AuthController {
@ApiOkResponse({ description: 'Пользователь успешно вышел' })
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@Res({ passthrough: true }) res: Response) {
return this.authService.logout(res)
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
return this.authService.logout(req, res)
}

@ApiOperation({
Expand Down
29 changes: 24 additions & 5 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { LoginRequestDto } from './dto/login.dto'
import { isDev } from 'src/utils/is-dev.util'
import { parseTTLToMs } from 'src/utils/ms.util'
import { RedisService } from 'src/common/redis/redis.service'

@Injectable()
export class AuthService {
Expand All @@ -27,6 +28,7 @@
private readonly prismaService: PrismaService,
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly redisService: RedisService,
) {
this.JWT_ACCESS_TOKEN_TTL =
this.configService.getOrThrow<string>('JWT_ACCESS_TOKEN_TTL')
Expand Down Expand Up @@ -58,7 +60,7 @@
},
})

return this.auth(res, user.id)
return await this.auth(res, user.id)

Check warning on line 63 in apps/api/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type error typed assigned to a parameter of type `string`
}

async login(res: Response, dto: LoginRequestDto) {
Expand All @@ -78,13 +80,13 @@
throw new NotFoundException('Пользователь не найден')
}

const isPasswordValid = await verify(user.password, password)

Check warning on line 83 in apps/api/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type error typed assigned to a parameter of type `string`

if (!isPasswordValid) {
throw new NotFoundException('Пользователь не найден')
}

return this.auth(res, user.id)
return await this.auth(res, user.id)

Check warning on line 89 in apps/api/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type error typed assigned to a parameter of type `string`
}

async refresh(req: Request, res: Response) {
Expand All @@ -96,6 +98,12 @@

const payload: JwtPayload = await this.jwtService.verifyAsync(refreshToken)

const storedRefreshToken = await this.redisService.getRefreshToken(payload.id)

if (!storedRefreshToken || storedRefreshToken !== refreshToken) {
throw new UnauthorizedException('Недействительный refresh-токен')
}

if (payload) {
const user = await this.prismaService.user.findUnique({
where: {
Expand All @@ -110,11 +118,20 @@
throw new NotFoundException('Пользователь не найден')
}

return this.auth(res, user.id)
return await this.auth(res, user.id)

Check warning on line 121 in apps/api/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type error typed assigned to a parameter of type `string`
}
}

async logout(res: Response) {
async logout(req: Request, res: Response) {
const refreshToken: string = req.cookies['refreshToken']

if (refreshToken) {
const payload: JwtPayload = this.jwtService.decode(refreshToken)
if (payload?.id) {
await this.redisService.deleteRefreshToken(payload.id)
}
}

this.setCookie(res, 'refreshToken', new Date(0))

return { message: 'Пользователь успешно вышел', success: true }
Expand All @@ -134,9 +151,11 @@
return user
}

private auth(res: Response, userId: string) {
private async auth(res: Response, userId: string) {
const { accessToken, refreshToken } = this.generateTokens(userId)

await this.redisService.setRefreshToken(userId, refreshToken)

this.setCookie(
res,
refreshToken,
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/auth/config/redis.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ConfigService } from '@nestjs/config'
import { Redis } from 'ioredis'

export async function redisConfig(configService: ConfigService): Promise<Redis> {
return new Redis(configService.getOrThrow<string>('REDIS_URL'))
}
1 change: 1 addition & 0 deletions apps/api/src/common/redis/redis.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const REDIS_CLIENT = 'REDIS_CLIENT'
19 changes: 19 additions & 0 deletions apps/api/src/common/redis/redis.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { redisConfig } from 'src/auth/config/redis.config'
import { RedisService } from './redis.service'
import { REDIS_CLIENT } from './redis.constants'

@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
useFactory: redisConfig,
inject: [ConfigService],
},
RedisService,
],
exports: [RedisService],
})
export class RedisModule {}
30 changes: 30 additions & 0 deletions apps/api/src/common/redis/redis.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Redis } from 'ioredis'
import { REDIS_CLIENT } from './redis.constants'
import { parseTTLToMs } from 'src/utils/ms.util'

@Injectable()
export class RedisService {
private readonly ttlSeconds: number

constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis,
private readonly configService: ConfigService,
) {
const ttl = this.configService.getOrThrow<string>('JWT_REFRESH_TOKEN_TTL')
this.ttlSeconds = parseTTLToMs(ttl) / 1000
}

async setRefreshToken(userId: string, token: string): Promise<void> {
await this.redis.set(`refresh:${userId}`, token, 'EX', this.ttlSeconds)
}

async getRefreshToken(userId: string): Promise<string | null> {
return this.redis.get(`refresh:${userId}`)
}

async deleteRefreshToken(userId: string): Promise<void> {
await this.redis.del(`refresh:${userId}`)
}
}
9 changes: 6 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
version: "3.9"
version: '3.9'

services:
redis:
image: redis:7
container_name: tracker_redis
restart: unless-stopped
ports:
- "6379:6379"
- '6379:6379'
volumes:
- redis_data:/data
command: redis-server --appendonly yes
command: redis-server

# Заменить для включения режима сохранения данных на диск
# command: redis-server --appendonly yes

volumes:
redis_data:
23 changes: 17 additions & 6 deletions docs/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@

- [Prisma](./prisma/README.md) — ORM и миграции

### Кеш и хранилище

- [Redis](./redis/README.md) — Хранение refresh-токенов с TTL, отладка

### Тестирование

- [Обзор](./test/README.md) — Структура, команды
Expand All @@ -25,9 +29,10 @@

## Быстрый старт

1. **Настройка валидации** — [validation/setup](../validation/setup.md)
2. **Swagger** — http://localhost:4000/api/docs
3. **Prisma Studio** — `pnpm prisma:studio`
1. **Запустить Redis** — `pnpm docker:redis-up`
2. **Настройка валидации** — [validation/setup](../validation/setup.md)
3. **Swagger** — http://localhost:4000/api/docs
4. **Prisma Studio** — `pnpm prisma:studio`

## Реализованные эндпоинты

Expand All @@ -36,7 +41,7 @@
### Аутентификация (JWT + Cookie)

- `accessToken` — Bearer-токен, передаётся в `Authorization: Bearer <token>`
- `refreshToken` — HTTP-only cookie, управляется сервером автоматически
- `refreshToken` — HTTP-only cookie, управляется сервером автоматически; хранится в Redis с TTL
- Защищённые маршруты: декоратор `@Authorization()` (= `@UseGuards(JwtGuard)`)
- `@Authorized()` — param-декоратор для получения объекта пользователя из запроса

Expand Down Expand Up @@ -75,8 +80,13 @@ apps/api/
│ │ └── auth.dto.ts # AuthResponse
│ └── interfaces/
│ └── jwt.interface.ts # JwtPayload { id }
├── common/providers/
│ └── zod-validation.provider.ts # Кастомный ValidationPipe
├── common/
│ ├── providers/
│ │ └── zod-validation.provider.ts # Кастомный ValidationPipe
│ └── redis/
│ ├── redis.constants.ts # REDIS_CLIENT токен
│ ├── redis.module.ts # @Global() модуль, создаёт ioredis-клиент
│ └── redis.service.ts # setRefreshToken / getRefreshToken / deleteRefreshToken
├── guards/
│ └── auth.guard.ts # JwtGuard extends AuthGuard('jwt')
├── strategies/
Expand All @@ -97,5 +107,6 @@ apps/api/
| Cookies | cookie-parser |
| Validation | Zod + nestjs-zod |
| Database | PostgreSQL + Prisma 7.x |
| Cache | Redis 7 + ioredis |
| API Docs | @nestjs/swagger |
| Testing | Vitest |
59 changes: 59 additions & 0 deletions docs/backend/redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Redis

## Запуск контейнера

```bash
pnpm docker:redis-up # запустить
pnpm docker:redis-status # проверить статус
pnpm docker:redis-down # остановить
```

## Подключение к контейнеру

### Интерактивный режим (redis-cli)

```bash
docker exec -it tracker_redis redis-cli
```

Команды внутри:

```
KEYS * # все ключи
GET refresh:<userId> # значение по ключу
TTL refresh:<userId> # сколько секунд до удаления
DEL refresh:<userId> # удалить ключ вручную
EXIT # выйти
```

### Одна команда без входа в контейнер

```bash
docker exec tracker_redis redis-cli KEYS "*"
docker exec tracker_redis redis-cli GET refresh:<userId>
```

### Проверить что Redis живой

```bash
docker exec tracker_redis redis-cli PING
# ответ: PONG
```

## Структура ключей

| Ключ | Значение | TTL |
| ------------------ | ----------------- | ------------------------------ |
| `refresh:<userId>` | JWT refresh-токен | `JWT_REFRESH_TOKEN_TTL` из env |

## Переменные окружения

```
REDIS_URL=redis://localhost:6379
```

В продакшене с паролем:

```
REDIS_URL=redis://:password@host:6379
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"author": "",
"license": "UNLICENSED",
"scripts": {
"dev": "turbo run dev",
"dev": "docker compose up redis -d && turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"test:e2e": "turbo run test:e2e",
Expand Down
Loading
Loading