feat: add authentication system (JWT + RBAC) #3
@ -37,13 +37,20 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
||||
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
||||
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
|
||||
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
|
||||
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
|
||||
|
||||
## Архитектура
|
||||
|
||||
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
||||
- Feature-модули: `MoexClientModule` (глобальный), `CacheModule` (глобальный), `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
||||
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
||||
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
||||
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
||||
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
||||
- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`.
|
||||
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
||||
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||
|
||||
10
apps/backend/.gitignore
vendored
Normal file
10
apps/backend/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
# Prisma generated client
|
||||
node_modules/.prisma
|
||||
|
||||
# SQLite database
|
||||
*.db
|
||||
*.db-journal
|
||||
@ -11,17 +11,23 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@nestjs/axios": "^3.0.0",
|
||||
"@nestjs/cache-manager": "^2.0.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.0.0",
|
||||
"@prisma/adapter-libsql": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"axios": "^1.6.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^5.0.0",
|
||||
"class-transformer": "^0.5.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"p-queue": "^7.3.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0",
|
||||
@ -32,11 +38,14 @@
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"prisma": "^7.8.0",
|
||||
"typescript": "^5.3.0",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^1.0.0"
|
||||
|
||||
14
apps/backend/prisma.config.ts
Normal file
14
apps/backend/prisma.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"role" TEXT NOT NULL DEFAULT 'user',
|
||||
"refreshToken" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
18
apps/backend/prisma/schema.prisma
Normal file
18
apps/backend/prisma/schema.prisma
Normal file
@ -0,0 +1,18 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
name String?
|
||||
role String @default("user")
|
||||
refreshToken String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@ -7,14 +7,18 @@ import { SecuritiesModule } from './modules/securities/securities.module';
|
||||
import { SharesModule } from './modules/shares/shares.module';
|
||||
import { BondsModule } from './modules/bonds/bonds.module';
|
||||
import { CandlesModule } from './modules/candles/candles.module';
|
||||
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
||||
ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }),
|
||||
PrismaModule,
|
||||
CacheModule,
|
||||
MoexClientModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
SecuritiesModule,
|
||||
SharesModule,
|
||||
BondsModule,
|
||||
|
||||
@ -2,6 +2,9 @@ import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'file:./dev.db',
|
||||
},
|
||||
moex: {
|
||||
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
||||
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
||||
@ -19,4 +22,10 @@ export default registerAs('app', () => ({
|
||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
||||
},
|
||||
auth: {
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
|
||||
jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
|
||||
jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
|
||||
},
|
||||
}));
|
||||
|
||||
@ -6,6 +6,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@ -15,12 +16,18 @@ async function bootstrap() {
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
app.use(cookieParser());
|
||||
|
||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
||||
|
||||
app.enableCors();
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
|
||||
const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('MoexVibe API')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
|
||||
107
apps/backend/src/modules/auth/auth.controller.ts
Normal file
107
apps/backend/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register new user' })
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.register(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return {
|
||||
data: {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.login(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return {
|
||||
data: {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const token = req.cookies?.[REFRESH_COOKIE];
|
||||
const result = await this.authService.refresh(token);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return {
|
||||
data: {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Logout user' })
|
||||
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||
await this.authService.logout(user.sub);
|
||||
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||
return {
|
||||
data: { message: 'Logged out successfully' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||
const profile = await this.authService.getProfile(user.sub);
|
||||
return {
|
||||
data: profile,
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update current user profile' })
|
||||
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||
const profile = await this.authService.updateProfile(user.sub, dto);
|
||||
return {
|
||||
data: profile,
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
}
|
||||
}
|
||||
20
apps/backend/src/modules/auth/auth.module.ts
Normal file
20
apps/backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
182
apps/backend/src/modules/auth/auth.service.spec.ts
Normal file
182
apps/backend/src/modules/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const testUser = {
|
||||
email: 'test@example.com',
|
||||
password: 'testPass123',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockPrismaUser = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
email: testUser.email,
|
||||
password: '',
|
||||
name: testUser.name,
|
||||
role: 'user',
|
||||
refreshToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: 'test-secret',
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: vi.fn((key: string) => {
|
||||
const config: Record<string, string> = {
|
||||
'app.auth.jwtSecret': 'test-secret',
|
||||
'app.auth.jwtRefreshSecret': 'test-refresh-secret',
|
||||
'app.auth.jwtAccessExpires': '15m',
|
||||
'app.auth.jwtRefreshExpires': '7d',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register a new user and return tokens', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser() as any);
|
||||
|
||||
const result = await service.register(testUser);
|
||||
|
||||
expect(result.user.id).toBe(1);
|
||||
expect(result.user.email).toBe(testUser.email);
|
||||
expect(result.user.name).toBe(testUser.name);
|
||||
expect(result.user.role).toBe('user');
|
||||
expect(result.accessToken).toBeDefined();
|
||||
expect(result.refreshToken).toBeDefined();
|
||||
expect(result.refreshToken).not.toBe(testUser.password);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { email: testUser.email },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if email already exists', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser() as any);
|
||||
|
||||
await expect(service.register(testUser)).rejects.toThrow(ConflictException);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a user without name', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||
mockPrismaUser({ name: null, id: 2 }) as any,
|
||||
);
|
||||
|
||||
const result = await service.register({ email: testUser.email, password: testUser.password });
|
||||
|
||||
expect(result.user.name).toBeNull();
|
||||
expect(result.accessToken).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
mockPrismaUser({ password: passwordHash }) as any,
|
||||
);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||
|
||||
const result = await service.login({ email: testUser.email, password: testUser.password });
|
||||
|
||||
expect(result.user.id).toBe(1);
|
||||
expect(result.accessToken).toBeDefined();
|
||||
expect(result.refreshToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for wrong password', async () => {
|
||||
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
mockPrismaUser({ password: passwordHash }) as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.login({ email: testUser.email, password: 'wrongPassword' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for non-existent email', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.login({ email: 'nonexistent@example.com', password: 'pass' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear refreshToken', async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||
|
||||
await service.logout(1);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('should update user name', async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
mockPrismaUser({ name: 'Updated Name' }) as any,
|
||||
);
|
||||
|
||||
const result = await service.updateProfile(1, { name: 'Updated Name' });
|
||||
|
||||
expect(result.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should return user without changes if no fields provided', async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||
|
||||
const result = await service.updateProfile(1, {});
|
||||
|
||||
expect(result.name).toBe(testUser.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
apps/backend/src/modules/auth/auth.service.ts
Normal file
138
apps/backend/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
password: passwordHash,
|
||||
name: dto.name ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return this.generateTokens(user);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(dto.password, user.password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
return this.generateTokens(user);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{ sub: number; jti: string }>(
|
||||
refreshToken,
|
||||
{
|
||||
secret: this.configService.get('app.auth.jwtRefreshSecret'),
|
||||
},
|
||||
);
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user?.refreshToken) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
return this.generateTokens(user);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: number) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
return this.sanitizeUser(user);
|
||||
}
|
||||
|
||||
async updateProfile(userId: number, dto: UpdateProfileDto) {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
},
|
||||
});
|
||||
return this.sanitizeUser(user);
|
||||
}
|
||||
|
||||
private async generateTokens(user: { id: number; email: string; name: string | null; role: string }) {
|
||||
const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
|
||||
const accessToken = await this.jwtService.signAsync(accessPayload, {
|
||||
secret: this.configService.get('app.auth.jwtSecret'),
|
||||
expiresIn: this.configService.get('app.auth.jwtAccessExpires'),
|
||||
});
|
||||
|
||||
const jti = crypto.randomUUID();
|
||||
const refreshPayload = { sub: user.id, jti };
|
||||
const refreshToken = await this.jwtService.signAsync(refreshPayload, {
|
||||
secret: this.configService.get('app.auth.jwtRefreshSecret'),
|
||||
expiresIn: this.configService.get('app.auth.jwtRefreshExpires'),
|
||||
});
|
||||
|
||||
const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: refreshHash },
|
||||
});
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeUser(user: { id: number; email: string; name: string | null; role: string }) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
12
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
12
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'securePass123' })
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
21
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
21
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'securePass123' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(100)
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
}
|
||||
11
apps/backend/src/modules/auth/dto/update-profile.dto.ts
Normal file
11
apps/backend/src/modules/auth/dto/update-profile.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional({ example: 'John Doe' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
}
|
||||
50
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
50
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: this.configService.get('app.auth.jwtSecret'),
|
||||
});
|
||||
request.user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: { headers?: Record<string, string> }): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (!auth) return null;
|
||||
const [type, token] = auth.split(' ');
|
||||
return type === 'Bearer' ? token : null;
|
||||
}
|
||||
}
|
||||
22
apps/backend/src/modules/auth/guards/roles.guard.ts
Normal file
22
apps/backend/src/modules/auth/guards/roles.guard.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
return requiredRoles.includes(request.user?.role);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||||
check() {
|
||||
return {
|
||||
|
||||
9
apps/backend/src/modules/prisma/prisma.module.ts
Normal file
9
apps/backend/src/modules/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
21
apps/backend/src/modules/prisma/prisma.service.ts
Normal file
21
apps/backend/src/modules/prisma/prisma.service.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaLibSql } from '@prisma/adapter-libsql';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
const url = process.env.DATABASE_URL || 'file:./dev.db';
|
||||
const adapter = new PrismaLibSql({ url });
|
||||
super({ adapter });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import swc from 'unplugin-swc';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
@ -19,4 +20,9 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
61
apps/docs/docs/adr/ADR-008-auth-system.md
Normal file
61
apps/docs/docs/adr/ADR-008-auth-system.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
sidebar_position: 14
|
||||
title: ADR-008
|
||||
---
|
||||
|
||||
# ADR-008: Authentication & Authorization System
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Приложение MoexVibe публичное, но для персонализации и будущих функций (избранное, уведомления, подписки) требуется система аутентификации. Необходимо решение, которое:
|
||||
|
||||
- Безопасно хранит учётные данные
|
||||
- Поддерживает session management
|
||||
- Позволяет расширять ролевую модель
|
||||
- Интегрируется в существующий стек (NestJS + React)
|
||||
|
||||
## Decision
|
||||
|
||||
### Backend
|
||||
|
||||
- **Database:** SQLite через Prisma ORM (без необходимости внешнего сервиса)
|
||||
- **Auth strategy:** JWT access tokens (15m) + refresh tokens (7d) in httpOnly cookies
|
||||
- **Password storage:** bcrypt with 12 salt rounds
|
||||
- **Guard model:** Global `JwtAuthGuard` (все маршруты защищены по умолчанию, `@Public()` для открытых)
|
||||
- **RBAC:** Роли `user` и `admin`, проверка через `RolesGuard`
|
||||
|
||||
### Frontend
|
||||
|
||||
- **State management:** React Context (`AuthContext`) для хранения пользователя и access token
|
||||
- **Token storage:** Access token только в памяти (не localStorage), refresh token в httpOnly cookie
|
||||
- **Auto-refresh:** При 401 → автоматический вызов `/auth/refresh` → повтор оригинального запроса
|
||||
- **Route protection:** `ProtectedRoute` компонент, редирект на `/login` с return URL
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- httpOnly cookie защищает refresh token от XSS
|
||||
- Access token в памяти защищён от кражи через localStorage
|
||||
- Prisma + SQLite не требует внешнего сервиса (zero setup)
|
||||
- Refresh token rotation повышает безопасность
|
||||
- Глобальный guard — безопасность по умолчанию
|
||||
|
||||
### Negative
|
||||
|
||||
- Access token теряется при полной перезагрузке страницы (восстанавливается через refresh cookie)
|
||||
- Для production требуется генерация надёжных JWT_SECRET
|
||||
- Нет rate limiting на login endpoint (TODO)
|
||||
- Нет верификации email (принятое упрощение)
|
||||
|
||||
## Migration Path
|
||||
|
||||
Для перехода на PostgreSQL потребуется:
|
||||
1. Изменить `provider` в schema.prisma на `postgresql`
|
||||
2. Обновить `DATABASE_URL`
|
||||
3. Выполнить `prisma migrate dev`
|
||||
4. Никаких изменений кода не требуется (Prisma abstracts database)
|
||||
@ -8,22 +8,30 @@ graph TD
|
||||
Backend["NestJS API<br/>:3000"]
|
||||
Cache["In-Memory Cache<br/>(cache-manager)"]
|
||||
MOEX["MOEX ISS API<br/>iss.moex.com"]
|
||||
DB[("SQLite Database<br/>(Prisma)")]
|
||||
|
||||
Browser -->|"/api/v1/*"| Backend
|
||||
Browser -->|"Cookie: refreshToken"| Backend
|
||||
Browser -->|"Authorization: Bearer"| Backend
|
||||
Backend -->|"getOrFetch()"| Cache
|
||||
Backend -->|"GET /iss/*.json"| MOEX
|
||||
Backend -->|"Prisma ORM"| DB
|
||||
Cache -->|"data"| Backend
|
||||
DB -->|"users"| Backend
|
||||
MOEX -->|"raw data"| Backend
|
||||
|
||||
subgraph Backend_Internal["Backend (NestJS)"]
|
||||
MoexClient["MoexClientModule<br/>p-queue + circuit breaker"]
|
||||
Auth["AuthModule<br/>JWT + bcrypt + Guards"]
|
||||
Shares["SharesModule"]
|
||||
Bonds["BondsModule"]
|
||||
Candles["CandlesModule"]
|
||||
Securities["SecuritiesModule"]
|
||||
Health["HealthModule"]
|
||||
CacheService["CacheService<br/>(global)"]
|
||||
Prisma["PrismaService<br/>(global)"]
|
||||
|
||||
Auth -->|"user CRUD"| Prisma
|
||||
MoexClient -->|"fetches"| Shares
|
||||
MoexClient -->|"fetches"| Bonds
|
||||
MoexClient -->|"fetches"| Candles
|
||||
@ -35,7 +43,7 @@ graph TD
|
||||
end
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
## Request Flow (Authenticated)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@ -44,19 +52,22 @@ sequenceDiagram
|
||||
participant Backend as NestJS API
|
||||
participant Cache as In-Memory Cache
|
||||
participant MOEX as MOEX ISS
|
||||
participant AuthDB as SQLite
|
||||
|
||||
User->>Frontend: Search / View instrument
|
||||
Frontend->>Backend: GET /api/v1/securities/search?q=SBER
|
||||
Backend->>Cache: getOrFetch('search:sber')
|
||||
User->>Frontend: View stock SBER
|
||||
Note over Frontend: Access token in memory
|
||||
Frontend->>Backend: GET /securities/shares/SBER (Authorization: Bearer)
|
||||
Backend->>Backend: JwtAuthGuard validates token
|
||||
Backend->>Cache: getOrFetch('share:SBER')
|
||||
alt Cache miss
|
||||
Cache->>Backend: null
|
||||
Backend->>MOEX: GET /iss/securities?q=SBER
|
||||
Backend->>MOEX: GET /iss/.../SBER.json
|
||||
MOEX-->>Backend: raw data
|
||||
Backend->>Cache: set('search:sber', normalized, TTL=3600)
|
||||
Backend->>Cache: set('share:SBER', ..., TTL=900)
|
||||
else Cache hit
|
||||
Cache-->>Backend: cached data
|
||||
end
|
||||
Backend-->>Frontend: { data, meta: { fromCache, cachedAt } }
|
||||
Backend-->>Frontend: { data, meta }
|
||||
Frontend-->>User: rendered UI
|
||||
```
|
||||
|
||||
@ -73,6 +84,7 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`:
|
||||
| [ADR-005](adr/ADR-005-openapi-codegen-frontend) | OpenAPI codegen for frontend |
|
||||
| [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) |
|
||||
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
|
||||
| [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization |
|
||||
|
||||
## Response Format
|
||||
|
||||
@ -107,4 +119,6 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`:
|
||||
- HttpExceptionFilter (catch-all)
|
||||
- TransformInterceptor (авто-обёртка в `ApiResponse`)
|
||||
- RequestLoggingMiddleware (логирует `METHOD /path STATUS DURATIONms`)
|
||||
- CORS: разрешён для всех origins
|
||||
- JwtAuthGuard (глобально, `@Public()` для открытых эндпоинтов)
|
||||
- RolesGuard (проверка ролей)
|
||||
- CORS: разрешён для всех origins, `credentials: true`
|
||||
|
||||
@ -2,6 +2,87 @@
|
||||
|
||||
Все эндпоинты находятся под префиксом `/api/v1`. Swagger UI: `/api/docs`.
|
||||
|
||||
Защищённые эндпоинты требуют заголовок `Authorization: Bearer <accessToken>`.
|
||||
|
||||
## Auth
|
||||
|
||||
### `POST /auth/register`
|
||||
|
||||
Регистрация нового пользователя.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePass123",
|
||||
"name": "John"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie: refresh_token`.
|
||||
|
||||
### `POST /auth/login`
|
||||
|
||||
Вход по email и паролю.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePass123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie: refresh_token`.
|
||||
|
||||
### `POST /auth/refresh`
|
||||
|
||||
Обновление access token через refresh token из cookie.
|
||||
|
||||
**Response:** `{ data: { user, accessToken }, meta }` + новый `Set-Cookie: refresh_token`.
|
||||
|
||||
### `POST /auth/logout`
|
||||
|
||||
Выход из системы. Удаляет refresh token из БД и чистит cookie.
|
||||
|
||||
**Headers:** `Authorization: Bearer <accessToken>`
|
||||
|
||||
**Response:** `{ data: { message }, meta }`.
|
||||
|
||||
### `GET /auth/me`
|
||||
|
||||
Информация о текущем пользователе.
|
||||
|
||||
**Headers:** `Authorization: Bearer <accessToken>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"email": "user@example.com",
|
||||
"name": "John",
|
||||
"role": "user"
|
||||
},
|
||||
"meta": { "fromCache": false, "cachedAt": null }
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /auth/me`
|
||||
|
||||
Обновление профиля текущего пользователя.
|
||||
|
||||
**Headers:** `Authorization: Bearer <accessToken>`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ data: { id, email, name, role }, meta }`.
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /health`
|
||||
|
||||
152
apps/docs/docs/backend/auth.md
Normal file
152
apps/docs/docs/backend/auth.md
Normal file
@ -0,0 +1,152 @@
|
||||
# Authentication & Authorization
|
||||
|
||||
## Architecture
|
||||
|
||||
### Token-Based Authentication
|
||||
|
||||
Система использует два типа JWT-токенов:
|
||||
|
||||
| Token | Format | TTL | Storage | Purpose |
|
||||
|-------|--------|-----|---------|---------|
|
||||
| Access Token | JWT `{ sub, email, role }` | 15 min | Memory (React) + `Authorization: Bearer` | Authenticate API requests |
|
||||
| Refresh Token | JWT `{ sub, jti }` | 7 days | httpOnly cookie + SHA-256 hash in DB | Issue new access tokens |
|
||||
|
||||
### Security
|
||||
|
||||
- Passwords hashed with **bcrypt** (12 salt rounds)
|
||||
- Refresh tokens stored as **bcrypt hash** in database
|
||||
- httpOnly, SameSite=Lax, Secure (production) cookies
|
||||
- Access token never persisted to localStorage (XSS protection)
|
||||
- Auto-refresh on 401 with automatic retry of failed request
|
||||
- Refresh token rotation: each refresh invalidates the old token
|
||||
|
||||
### Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Frontend as React SPA
|
||||
participant Backend as NestJS API
|
||||
participant DB as SQLite (Prisma)
|
||||
|
||||
User->>Frontend: Enter email & password
|
||||
Frontend->>Backend: POST /auth/login { email, password }
|
||||
Backend->>DB: Find user by email
|
||||
Backend->>Backend: bcrypt.compare(password, hash)
|
||||
Backend->>DB: Save refresh token hash
|
||||
Backend-->>Frontend: { user, accessToken } + Set-Cookie (refreshToken)
|
||||
Frontend->>Frontend: Store accessToken in memory
|
||||
Frontend-->>User: Redirect to app
|
||||
|
||||
Note over Frontend,Backend: Later API request
|
||||
Frontend->>Backend: GET /auth/me (Authorization: Bearer <accessToken>)
|
||||
Backend->>Backend: Verify JWT signature & expiry
|
||||
Backend-->>Frontend: { user }
|
||||
|
||||
Note over Frontend,Backend: Token refresh (auto on 401)
|
||||
Frontend->>Backend: POST /auth/refresh (Cookie: refreshToken)
|
||||
Backend->>Backend: Verify refresh JWT
|
||||
Backend->>DB: Compare refresh token hash
|
||||
Backend->>DB: Rotate: save new refresh token hash
|
||||
Backend-->>Frontend: { user, newAccessToken } + Set-Cookie (newRefreshToken)
|
||||
Frontend->>Frontend: Update accessToken in memory
|
||||
Frontend->>Backend: Retry original request
|
||||
|
||||
Note over Frontend,Backend: Logout
|
||||
Frontend->>Backend: POST /auth/logout
|
||||
Backend->>DB: Clear refresh token hash
|
||||
Backend-->>Frontend: Clear cookie
|
||||
Frontend->>Frontend: Clear accessToken
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are under `/api/v1/auth`.
|
||||
|
||||
### `POST /auth/register`
|
||||
|
||||
Register a new user.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePass123",
|
||||
"name": "John"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie` with refresh token.
|
||||
|
||||
### `POST /auth/login`
|
||||
|
||||
Authenticate existing user.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePass123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie`.
|
||||
|
||||
### `POST /auth/refresh`
|
||||
|
||||
Refresh access token. Reads refresh token from cookie.
|
||||
|
||||
**Response:** `{ data: { user, accessToken }, meta }` + new `Set-Cookie`.
|
||||
|
||||
### `POST /auth/logout`
|
||||
|
||||
Invalidates refresh token. Requires `Authorization: Bearer`.
|
||||
|
||||
**Response:** `{ data: { message }, meta }` + cookie cleared.
|
||||
|
||||
### `GET /auth/me`
|
||||
|
||||
Returns current user profile. Requires `Authorization: Bearer`.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"email": "user@example.com",
|
||||
"name": "John",
|
||||
"role": "user"
|
||||
},
|
||||
"meta": { "fromCache": false, "cachedAt": null }
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /auth/me`
|
||||
|
||||
Update current user profile. Requires `Authorization: Bearer`.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
## Authorization (RBAC)
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| `user` | View/edit own profile |
|
||||
| `admin` | All user permissions |
|
||||
|
||||
**Application level:** Global `JwtAuthGuard` protects all routes by default. Use `@Public()` decorator to bypass. `RolesGuard` checks required roles from `@Roles()` decorator.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret for access token signing |
|
||||
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret for refresh token signing |
|
||||
| `JWT_ACCESS_EXPIRES` | `15m` | Access token TTL |
|
||||
| `JWT_REFRESH_EXPIRES` | `7d` | Refresh token TTL |
|
||||
| `DATABASE_URL` | `file:./dev.db` | SQLite database URL (Prisma format) |
|
||||
64
apps/docs/docs/backend/database.md
Normal file
64
apps/docs/docs/backend/database.md
Normal file
@ -0,0 +1,64 @@
|
||||
# Database Schema
|
||||
|
||||
## Overview
|
||||
|
||||
Используется **SQLite** через **Prisma ORM 7**. База данных находится в `apps/backend/dev.db`.
|
||||
|
||||
## Schema
|
||||
|
||||
```prisma
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
name String?
|
||||
role String @default("user")
|
||||
refreshToken String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
### Users
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PK, AUTOINCREMENT | User ID |
|
||||
| email | TEXT | UNIQUE, NOT NULL | Email address |
|
||||
| password | TEXT | NOT NULL | bcrypt hash of password |
|
||||
| name | TEXT | NULLABLE | Display name |
|
||||
| role | TEXT | NOT NULL, DEFAULT 'user' | Role for RBAC |
|
||||
| refreshToken | TEXT | NULLABLE | bcrypt hash of current refresh token |
|
||||
| createdAt | DATETIME | NOT NULL | Timestamp |
|
||||
| updatedAt | DATETIME | NOT NULL | Auto-updated timestamp |
|
||||
|
||||
## Prisma Client
|
||||
|
||||
The Prisma client is generated to `apps/backend/src/generated/prisma/` and imported via `@/generated/prisma/client`.
|
||||
|
||||
Key service: `PrismaService` (`src/modules/prisma/prisma.service.ts`) — extends `PrismaClient`, handles connection lifecycle (`onModuleInit`/`onModuleDestroy`). Registered as a global module.
|
||||
|
||||
## Migrations
|
||||
|
||||
Migrations are stored in `apps/backend/prisma/migrations/`. To create a new migration:
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name <description>
|
||||
```
|
||||
|
||||
To apply migrations in production:
|
||||
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
@ -7,9 +7,11 @@ Backend — NestJS-приложение, единственная точка д
|
||||
`apps/backend/src/main.ts` — bootstrap:
|
||||
|
||||
- Глобальный префикс `/api/v1`
|
||||
- Swagger на `/api/docs` (title: "MoexVibe API", version: "1.0.0")
|
||||
- Swagger на `/api/docs` (title: "MoexVibe API", version: "1.0.0") с Bearer auth
|
||||
- ValidationPipe, HttpExceptionFilter, TransformInterceptor, RequestLoggingMiddleware
|
||||
- CORS включён
|
||||
- JwtAuthGuard (глобально, `@Public()` для открытых эндпоинтов)
|
||||
- cookie-parser (для refresh token)
|
||||
- CORS включён (`credentials: true`)
|
||||
- Порт из `PORT` env (default: 3000)
|
||||
|
||||
## Root Module
|
||||
@ -17,7 +19,7 @@ Backend — NestJS-приложение, единственная точка д
|
||||
`apps/backend/src/app.module.ts` импортирует:
|
||||
|
||||
- `ConfigModule.forRoot` (глобальный, из `config/configuration.ts`)
|
||||
- Импортируются 6 feature-модулей (Cache + MoexClient — глобальные)
|
||||
- Импортируются 8 feature-модулей (Prisma + Cache + MoexClient — глобальные)
|
||||
|
||||
## Source Layout
|
||||
|
||||
@ -38,9 +40,11 @@ apps/backend/src/
|
||||
│ └── middleware/
|
||||
│ └── request-logging.middleware.ts
|
||||
└── modules/
|
||||
├── prisma/
|
||||
├── moex-client/
|
||||
├── cache/
|
||||
├── health/
|
||||
├── auth/
|
||||
├── securities/
|
||||
├── shares/
|
||||
├── bonds/
|
||||
|
||||
@ -12,6 +12,8 @@ const sidebars: SidebarsConfig = {
|
||||
'backend/overview',
|
||||
'backend/modules',
|
||||
'backend/api',
|
||||
'backend/auth',
|
||||
'backend/database',
|
||||
'backend/configuration',
|
||||
'backend/caching',
|
||||
'backend/moex-client',
|
||||
@ -56,6 +58,7 @@ const sidebars: SidebarsConfig = {
|
||||
'adr/ADR-005-openapi-codegen-frontend',
|
||||
'adr/ADR-006-no-cci',
|
||||
'adr/ADR-007-two-level-caching',
|
||||
'adr/ADR-008-auth-system',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
56
apps/frontend/src/api/auth.ts
Normal file
56
apps/frontend/src/api/auth.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { request, setAccessToken } from './client';
|
||||
import type { AuthResponse, UserResponse } from './responses';
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const result = await request<AuthResponse>(
|
||||
'/api/v1/auth/login',
|
||||
undefined,
|
||||
{ method: 'POST', body: { email, password }, skipAuth: true },
|
||||
);
|
||||
setAccessToken(result.data.accessToken);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string, name?: string) {
|
||||
const result = await request<AuthResponse>(
|
||||
'/api/v1/auth/register',
|
||||
undefined,
|
||||
{ method: 'POST', body: { email, password, name }, skipAuth: true },
|
||||
);
|
||||
setAccessToken(result.data.accessToken);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const result = await request<AuthResponse>(
|
||||
'/api/v1/auth/refresh',
|
||||
undefined,
|
||||
{ method: 'POST', skipAuth: true },
|
||||
);
|
||||
setAccessToken(result.data.accessToken);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const result = await request<{ message: string }>(
|
||||
'/api/v1/auth/logout',
|
||||
undefined,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
setAccessToken(null);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
const result = await request<UserResponse>('/api/v1/auth/me');
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function updateProfile(data: { name?: string }) {
|
||||
const result = await request<UserResponse>(
|
||||
'/api/v1/auth/me',
|
||||
undefined,
|
||||
{ method: 'PATCH', body: data },
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
ApiResponseMeta,
|
||||
AuthResponse,
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
DividendItem,
|
||||
@ -15,9 +16,57 @@ import type {
|
||||
|
||||
const BASE = '';
|
||||
|
||||
async function request<T>(
|
||||
let accessToken: string | null = null;
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function setOnUnauthorized(cb: () => void) {
|
||||
onUnauthorized = cb;
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
|
||||
accessToken = json.data.data.accessToken;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnauthorized(): Promise<boolean> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshTokens().then((success) => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
return success;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
params?: Record<string, string | undefined>,
|
||||
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
||||
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
@ -25,8 +74,45 @@ async function request<T>(
|
||||
if (v !== undefined) url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (!options?.skipAuth && accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (options?.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
credentials: 'include' as RequestCredentials,
|
||||
};
|
||||
if (options?.method) {
|
||||
fetchOptions.method = options.method;
|
||||
}
|
||||
if (options?.body !== undefined) {
|
||||
fetchOptions.body = options.body instanceof FormData ? options.body : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let res = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
if (res.status === 401 && !options?.skipAuth) {
|
||||
const refreshed = await handleUnauthorized();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
||||
} else {
|
||||
accessToken = null;
|
||||
onUnauthorized?.();
|
||||
throw new Error('Сессия истекла');
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||
}
|
||||
|
||||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
@ -122,3 +122,15 @@ export interface HealthResponse {
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: UserResponse;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||
import { SearchBar } from './SearchBar';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export function Layout() {
|
||||
const { isAuthenticated, user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<header
|
||||
@ -26,6 +35,51 @@ export function Layout() {
|
||||
MoexVibe
|
||||
</Link>
|
||||
<SearchBar />
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
to="/profile"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{user?.name || user?.email}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
||||
<Outlet />
|
||||
|
||||
29
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
29
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
125
apps/frontend/src/context/AuthContext.tsx
Normal file
125
apps/frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import * as authApi from '../api/auth';
|
||||
import { setOnUnauthorized } from '../api/client';
|
||||
import type { UserResponse } from '../api/responses';
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: UserResponse | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (data: { name?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserResponse | null>(null);
|
||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
||||
setUser(authData.user);
|
||||
setAccessTokenState(authData.accessToken);
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setUser(null);
|
||||
setAccessTokenState(null);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const result = await authApi.login(email, password);
|
||||
updateSession(result);
|
||||
}, [updateSession]);
|
||||
|
||||
const register = useCallback(async (email: string, password: string, name?: string) => {
|
||||
const result = await authApi.register(email, password, name);
|
||||
updateSession(result);
|
||||
}, [updateSession]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch {
|
||||
// ignore network errors on logout
|
||||
}
|
||||
clearSession();
|
||||
}, [clearSession]);
|
||||
|
||||
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
||||
const result = await authApi.updateProfile(data);
|
||||
setUser(result);
|
||||
}, []);
|
||||
|
||||
// Try to restore session on mount
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const result = await authApi.refresh();
|
||||
if (mounted) {
|
||||
updateSession(result);
|
||||
}
|
||||
} catch {
|
||||
// No valid session
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setInitialized(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [updateSession]);
|
||||
|
||||
// Set up auto-logout on unauthorized
|
||||
useEffect(() => {
|
||||
setOnUnauthorized(() => {
|
||||
clearSession();
|
||||
});
|
||||
}, [clearSession]);
|
||||
|
||||
if (!initialized && isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
accessToken,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile: updateProfileFn,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/hooks/useAuth.ts
Normal file
10
apps/frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext, type AuthContextValue } from '../context/AuthContext';
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
@ -17,7 +18,9 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
96
apps/frontend/src/pages/LoginPage.tsx
Normal file
96
apps/frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate(redirect);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка входа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '60px auto' }}>
|
||||
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Вход</h1>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} style={buttonStyle}>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Нет аккаунта?{' '}
|
||||
<Link to={`/register${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} style={{ color: 'var(--color-primary)' }}>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 16,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
padding: '12px 24px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
97
apps/frontend/src/pages/ProfilePage.tsx
Normal file
97
apps/frontend/src/pages/ProfilePage.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, updateProfile } = useAuth();
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
try {
|
||||
await updateProfile({ name: name || undefined });
|
||||
setMessage('Профиль обновлён');
|
||||
} catch {
|
||||
setMessage('Не удалось обновить профиль');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 500, margin: '40px auto' }}>
|
||||
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Профиль</h1>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Почта</span>
|
||||
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.email}</p>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Роль</span>
|
||||
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.role}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 16,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{message && (
|
||||
<div style={{ fontSize: 14, color: message === 'Профиль обновлён' ? 'var(--color-positive)' : 'var(--color-negative)' }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/frontend/src/pages/RegisterPage.tsx
Normal file
130
apps/frontend/src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { register } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email, password, name || undefined);
|
||||
navigate(redirect);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка регистрации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '60px auto' }}>
|
||||
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Регистрация</h1>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Имя (необязательно)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Иван Иванов"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
style={inputStyle}
|
||||
placeholder="Минимум 6 символов"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
placeholder="Повторите пароль"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} style={buttonStyle}>
|
||||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to={`/login${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} style={{ color: 'var(--color-primary)' }}>
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 16,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
padding: '12px 24px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
@ -3,6 +3,10 @@ import { Layout } from './components/Layout';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { StockPage } from './pages/StockPage';
|
||||
import { BondPage } from './pages/BondPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@ -11,6 +15,16 @@ export function AppRoutes() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
1186
docs/superpowers/plans/2026-06-13-auth-system.md
Normal file
1186
docs/superpowers/plans/2026-06-13-auth-system.md
Normal file
File diff suppressed because it is too large
Load Diff
1423
package-lock.json
generated
1423
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user