feat: add authentication system (JWT + RBAC) #3

Merged
ksv741 merged 1 commits from feature/auth-system into main 2026-06-13 22:26:34 +03:00
48 changed files with 4434 additions and 23 deletions
Showing only changes of commit c2478277e6 - Show all commits

View File

@ -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
View 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

View File

@ -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"

View 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"],
},
});

View File

@ -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");

View 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"

View 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
}

View File

@ -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,

View File

@ -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',
},
}));

View File

@ -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);

View 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 },
};
}
}

View 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 {}

View 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);
});
});
});

View 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,
};
}
}

View File

@ -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;
});

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View 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;
}

View 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;
}

View 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;
}

View 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;
}
}

View 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);
}
}

View File

@ -0,0 +1,5 @@
export interface JwtPayload {
sub: number;
email: string;
role: string;
}

View File

@ -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 {

View 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 {}

View 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();
}
}

View File

@ -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'),
},
},
});

View 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)

View File

@ -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`

View File

@ -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`

View 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) |

View 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
```

View File

@ -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/

View File

@ -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',
],
},
],

View 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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 />

View 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}</>;
}

View 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>
);
}

View 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;
}

View File

@ -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>,
);

View 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',
};

View 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>
);
}

View 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',
};

View File

@ -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>
);

File diff suppressed because it is too large Load Diff

1423
package-lock.json generated

File diff suppressed because it is too large Load Diff