Some checks failed
Backend: - AuthModule with register, login, logout, refresh, profile endpoints - JwtAuthGuard (global, opt-out via @Public) + RolesGuard (@Roles) - PrismaModule with SQLite via @prisma/adapter-libsql (Prisma 7) - Auth configuration in configuration.ts Frontend: - Auth context with session restoration via refresh cookie - Login, Register, Profile pages with ProtectedRoute - Auto-refresh on 401 with token rotation - API client refactored for auth headers Russian text: auth flows translated to Russian All text translated: auth pages, profile, layout, loading states
1186 lines
35 KiB
Markdown
1186 lines
35 KiB
Markdown
# Auth System Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add full authentication (register/login/logout/refresh) and role-based authorization (user/admin) to MoexVibe.
|
||
|
||
**Architecture:** Backend — NestJS + Prisma (SQLite) for user storage, JWT access tokens (15m) + httpOnly refresh cookies (7d) with rotation. Frontend — React context for auth state, TanStack Query for API, auto-refresh on 401 with retry.
|
||
|
||
**Tech Stack:** @prisma/client, @nestjs/jwt, bcrypt, cookie-parser (backend); React Context + fetch (frontend)
|
||
|
||
---
|
||
|
||
### Task 1: Backend dependencies + Prisma init
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/package.json`
|
||
- Create: `apps/backend/prisma/schema.prisma`
|
||
- Create: `apps/backend/.gitignore` (or modify root)
|
||
- Create: `apps/backend/src/modules/prisma/prisma.service.ts`
|
||
- Create: `apps/backend/src/modules/prisma/prisma.module.ts`
|
||
|
||
- [ ] **Step 1: Install dependencies**
|
||
|
||
```bash
|
||
npm install @prisma/client @nestjs/jwt bcrypt cookie-parser -w apps/backend
|
||
npm install -D prisma @types/bcrypt @types/cookie-parser -w apps/backend
|
||
```
|
||
|
||
- [ ] **Step 2: Init Prisma with SQLite**
|
||
|
||
```bash
|
||
npx prisma init --datasource-provider sqlite
|
||
```
|
||
|
||
Update the generated `apps/backend/prisma/schema.prisma`:
|
||
|
||
```prisma
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "sqlite"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add DATABASE_URL to .env**
|
||
|
||
```env
|
||
DATABASE_URL="file:./dev.db"
|
||
```
|
||
|
||
Add `.env` to backend `.gitignore`:
|
||
|
||
```gitignore
|
||
node_modules/
|
||
dist/
|
||
.env
|
||
*.db
|
||
*.db-journal
|
||
```
|
||
|
||
- [ ] **Step 4: PrismaService**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/prisma/prisma.service.ts
|
||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||
import { PrismaClient } from '@prisma/client';
|
||
|
||
@Injectable()
|
||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||
async onModuleInit() {
|
||
await this.$connect();
|
||
}
|
||
|
||
async onModuleDestroy() {
|
||
await this.$disconnect();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: PrismaModule**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/prisma/prisma.module.ts
|
||
import { Global, Module } from '@nestjs/common';
|
||
import { PrismaService } from './prisma.service';
|
||
|
||
@Global()
|
||
@Module({
|
||
providers: [PrismaService],
|
||
exports: [PrismaService],
|
||
})
|
||
export class PrismaModule {}
|
||
```
|
||
|
||
- [ ] **Step 6: Run migration**
|
||
|
||
```bash
|
||
npx prisma migrate dev --name init
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/prisma/ apps/backend/src/modules/prisma/ apps/backend/package.json apps/backend/.env apps/backend/.gitignore
|
||
git commit -m "feat: add Prisma ORM with SQLite and User model"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: AuthService — register, login, refresh, logout (TDD)
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts`
|
||
- Create: `apps/backend/src/modules/auth/auth.service.spec.ts`
|
||
- Create: `apps/backend/src/modules/auth/auth.service.ts`
|
||
- Create: `apps/backend/src/modules/auth/dto/register.dto.ts`
|
||
- Create: `apps/backend/src/modules/auth/dto/login.dto.ts`
|
||
- Create: `apps/backend/src/modules/auth/dto/update-profile.dto.ts`
|
||
|
||
- [ ] **Step 1: Write JwtPayload interface**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts
|
||
export interface JwtPayload {
|
||
sub: number;
|
||
email: string;
|
||
role: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write DTOs**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/dto/register.dto.ts
|
||
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;
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/dto/login.dto.ts
|
||
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;
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/dto/update-profile.dto.ts
|
||
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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write failing auth service tests**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/auth.service.spec.ts
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||
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;
|
||
let jwtService: JwtService;
|
||
|
||
const testUser = {
|
||
email: 'test@example.com',
|
||
password: 'testPass123',
|
||
name: 'Test User',
|
||
};
|
||
|
||
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> = {
|
||
'auth.jwtSecret': 'test-secret',
|
||
'auth.jwtAccessExpires': '15m',
|
||
'auth.jwtRefreshExpires': '7d',
|
||
};
|
||
return config[key];
|
||
}),
|
||
},
|
||
},
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<AuthService>(AuthService);
|
||
prisma = module.get<PrismaService>(PrismaService);
|
||
jwtService = module.get<JwtService>(JwtService);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe('register', () => {
|
||
it('should register a new user and return tokens', async () => {
|
||
const hashedPassword = await bcrypt.hash(testUser.password, 12);
|
||
const mockUser = {
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: hashedPassword,
|
||
name: testUser.name,
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||
vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
|
||
|
||
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(prisma.user.findUnique).toHaveBeenCalledWith({
|
||
where: { email: testUser.email },
|
||
});
|
||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||
data: {
|
||
email: testUser.email,
|
||
password: expect.any(String),
|
||
name: testUser.name,
|
||
},
|
||
});
|
||
});
|
||
|
||
it('should throw ConflictException if email already exists', async () => {
|
||
const mockUser = { id: 1, email: testUser.email, password: 'hash', name: 'Test', role: 'user', refreshToken: null, createdAt: new Date(), updatedAt: new Date() };
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
||
|
||
await expect(service.register(testUser)).rejects.toThrow(ConflictException);
|
||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should register a user without name', async () => {
|
||
const hashedPassword = await bcrypt.hash(testUser.password, 12);
|
||
const mockUser = {
|
||
id: 2,
|
||
email: 'noname@example.com',
|
||
password: hashedPassword,
|
||
name: null,
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||
vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
|
||
|
||
const result = await service.register({ email: 'noname@example.com', 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);
|
||
const mockUser = {
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: passwordHash,
|
||
name: testUser.name,
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
||
vi.mocked(prisma.user.update).mockResolvedValue({ ...mockUser, refreshToken: 'some-hash' });
|
||
|
||
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 mockUser = {
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: await bcrypt.hash(testUser.password, 12),
|
||
name: testUser.name,
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
||
|
||
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('refresh', () => {
|
||
it('should rotate tokens on valid refresh', async () => {
|
||
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||
const oldRefreshToken = 'valid-refresh-token-jwt';
|
||
const oldRefreshHash = await bcrypt.hash(oldRefreshToken, 12);
|
||
|
||
const mockUser = {
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: passwordHash,
|
||
name: testUser.name,
|
||
role: 'user',
|
||
refreshToken: oldRefreshHash,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
||
vi.mocked(prisma.user.update).mockResolvedValue(mockUser);
|
||
|
||
const decoded = { sub: 1, jti: 'some-jti' };
|
||
vi.spyOn(jwtService, 'verifyAsync').mockResolvedValue(decoded as any);
|
||
|
||
const result = await service.refresh(oldRefreshToken);
|
||
|
||
expect(result.accessToken).toBeDefined();
|
||
expect(result.refreshToken).toBeDefined();
|
||
expect(result.refreshToken).not.toBe(oldRefreshToken);
|
||
expect(prisma.user.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should throw on invalid refresh token', async () => {
|
||
vi.spyOn(jwtService, 'verifyAsync').mockRejectedValue(new Error('jwt expired'));
|
||
|
||
await expect(service.refresh('bad-token')).rejects.toThrow(UnauthorizedException);
|
||
});
|
||
});
|
||
|
||
describe('logout', () => {
|
||
it('should clear refreshToken', async () => {
|
||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: 'hash',
|
||
name: testUser.name,
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
});
|
||
|
||
await service.logout(1);
|
||
|
||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||
where: { id: 1 },
|
||
data: { refreshToken: null },
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('updateProfile', () => {
|
||
it('should update user name', async () => {
|
||
const mockUser = {
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: 'hash',
|
||
name: 'Updated Name',
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.update).mockResolvedValue(mockUser);
|
||
|
||
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 () => {
|
||
const mockUser = {
|
||
id: 1,
|
||
email: testUser.email,
|
||
password: 'hash',
|
||
name: 'Test User',
|
||
role: 'user',
|
||
refreshToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
vi.mocked(prisma.user.update).mockResolvedValue(mockUser);
|
||
|
||
const result = await service.updateProfile(1, {});
|
||
|
||
expect(result.name).toBe('Test User');
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it fails**
|
||
|
||
```bash
|
||
npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend
|
||
```
|
||
|
||
Expected: FAIL — "AuthService not defined" or similar.
|
||
|
||
- [ ] **Step 5: Write minimal AuthService**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/auth.service.ts
|
||
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('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; 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('auth.jwtSecret'),
|
||
expiresIn: this.configService.get('auth.jwtAccessExpires'),
|
||
});
|
||
|
||
const jti = crypto.randomUUID();
|
||
const refreshPayload = { sub: user.id, jti };
|
||
const refreshToken = await this.jwtService.signAsync(refreshPayload, {
|
||
secret: this.configService.get('auth.jwtRefreshSecret'),
|
||
expiresIn: this.configService.get('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,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
```bash
|
||
npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend
|
||
```
|
||
|
||
Expected: PASS
|
||
|
||
---
|
||
|
||
### Task 3: Auth guards, decorators, controller, module
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/auth/decorators/public.decorator.ts`
|
||
- Create: `apps/backend/src/modules/auth/decorators/current-user.decorator.ts`
|
||
- Create: `apps/backend/src/modules/auth/decorators/roles.decorator.ts`
|
||
- Create: `apps/backend/src/modules/auth/guards/jwt-auth.guard.ts`
|
||
- Create: `apps/backend/src/modules/auth/guards/roles.guard.ts`
|
||
- Create: `apps/backend/src/modules/auth/auth.controller.ts`
|
||
- Create: `apps/backend/src/modules/auth/auth.module.ts`
|
||
|
||
- [ ] **Step 1: Public decorator**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/decorators/public.decorator.ts
|
||
import { SetMetadata } from '@nestjs/common';
|
||
|
||
export const IS_PUBLIC_KEY = 'isPublic';
|
||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||
```
|
||
|
||
- [ ] **Step 2: CurrentUser decorator**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/decorators/current-user.decorator.ts
|
||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||
|
||
export const CurrentUser = createParamDecorator(
|
||
(data: unknown, ctx: ExecutionContext) => {
|
||
const request = ctx.switchToHttp().getRequest();
|
||
return request.user;
|
||
},
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 3: Roles decorator**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/decorators/roles.decorator.ts
|
||
import { SetMetadata } from '@nestjs/common';
|
||
|
||
export const ROLES_KEY = 'roles';
|
||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||
```
|
||
|
||
- [ ] **Step 4: JwtAuthGuard**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
|
||
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('auth.jwtSecret'),
|
||
});
|
||
request.user = payload;
|
||
return true;
|
||
} catch {
|
||
throw new UnauthorizedException('Invalid or expired token');
|
||
}
|
||
}
|
||
|
||
private extractToken(request: any): string | null {
|
||
const auth = request.headers?.authorization;
|
||
if (!auth) return null;
|
||
const [type, token] = auth.split(' ');
|
||
return type === 'Bearer' ? token : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: RolesGuard**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/guards/roles.guard.ts
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: AuthController**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/auth.controller.ts
|
||
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, // 7 days
|
||
};
|
||
|
||
@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 (clear refresh token)' })
|
||
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 },
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: AuthModule**
|
||
|
||
```typescript
|
||
// apps/backend/src/modules/auth/auth.module.ts
|
||
import { Module } from '@nestjs/common';
|
||
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,
|
||
],
|
||
exports: [JwtAuthGuard, RolesGuard],
|
||
})
|
||
export class AuthModule {}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Integrate AuthModule into app
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/src/app.module.ts`
|
||
- Modify: `apps/backend/src/main.ts`
|
||
- Modify: `apps/backend/src/config/configuration.ts`
|
||
- Modify: `apps/backend/package.json`
|
||
|
||
- [ ] **Step 1: Update configuration.ts**
|
||
|
||
```typescript
|
||
// apps/backend/src/config/configuration.ts — add auth section
|
||
import { registerAs } from '@nestjs/config';
|
||
|
||
export default registerAs('app', () => ({
|
||
port: parseInt(process.env.PORT || '3000', 10),
|
||
// ... existing config ...
|
||
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',
|
||
},
|
||
// ... rest of config ...
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: Update app.module.ts**
|
||
|
||
```typescript
|
||
// apps/backend/src/app.module.ts — add imports
|
||
imports: [
|
||
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
||
PrismaModule,
|
||
CacheModule,
|
||
MoexClientModule,
|
||
HealthModule,
|
||
AuthModule,
|
||
SecuritiesModule,
|
||
SharesModule,
|
||
BondsModule,
|
||
CandlesModule,
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 3: Update main.ts — add cookie-parser + Swagger bearer**
|
||
|
||
```typescript
|
||
// apps/backend/src/main.ts
|
||
import * as cookieParser from 'cookie-parser';
|
||
|
||
// After app creation:
|
||
app.use(cookieParser());
|
||
|
||
// Swagger config:
|
||
const config = new DocumentBuilder()
|
||
.setTitle('MoexVibe API')
|
||
.setVersion('1.0.0')
|
||
.addBearerAuth()
|
||
.build();
|
||
```
|
||
|
||
- [ ] **Step 4: Update .env with JWT secrets**
|
||
|
||
```
|
||
JWT_SECRET=dev-jwt-secret-change-in-production
|
||
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
|
||
JWT_ACCESS_EXPIRES=15m
|
||
JWT_REFRESH_EXPIRES=7d
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Frontend — auth types + refactored client
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/api/responses.ts`
|
||
- Modify: `apps/frontend/src/api/client.ts`
|
||
- Create: `apps/frontend/src/api/auth.ts`
|
||
|
||
- [ ] **Step 1: Add auth types to responses.ts**
|
||
|
||
```typescript
|
||
// apps/frontend/src/api/responses.ts — add:
|
||
export interface UserResponse {
|
||
id: number;
|
||
email: string;
|
||
name: string | null;
|
||
role: string;
|
||
}
|
||
|
||
export interface AuthResponse {
|
||
user: UserResponse;
|
||
accessToken: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Refactor client.ts — add auth interceptor pattern**
|
||
|
||
```typescript
|
||
// apps/frontend/src/api/client.ts
|
||
import type { ApiEnvelope, ApiResponseMeta } from './responses';
|
||
|
||
const BASE = '';
|
||
|
||
let accessToken: string | null = null;
|
||
let onUnauthorized: (() => void) | 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 request<T>(
|
||
path: string,
|
||
params?: Record<string, string>,
|
||
options?: { skipAuth?: boolean },
|
||
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||
if (params) {
|
||
for (const [k, v] of Object.entries(params)) {
|
||
if (v !== undefined) url.searchParams.set(k, v);
|
||
}
|
||
}
|
||
|
||
const headers: Record<string, string> = {};
|
||
if (!options?.skipAuth && accessToken) {
|
||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||
}
|
||
|
||
const res = await fetch(url.toString(), {
|
||
headers,
|
||
credentials: 'include',
|
||
});
|
||
|
||
if (res.status === 401 && !options?.skipAuth) {
|
||
// Try to refresh
|
||
try {
|
||
const refreshRes = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
if (refreshRes.ok) {
|
||
const refreshJson: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await refreshRes.json();
|
||
const authData = refreshJson.data.data;
|
||
setAccessToken(authData.accessToken);
|
||
// Retry original request with new token
|
||
headers['Authorization'] = `Bearer ${authData.accessToken}`;
|
||
const retryRes = await fetch(url.toString(), { headers, credentials: 'include' });
|
||
if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`);
|
||
const retryJson: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await retryRes.json();
|
||
return retryJson.data;
|
||
} else {
|
||
setAccessToken(null);
|
||
onUnauthorized?.();
|
||
throw new Error('Session expired');
|
||
}
|
||
} catch {
|
||
onUnauthorized?.();
|
||
throw new Error('Session expired');
|
||
}
|
||
}
|
||
|
||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||
return json.data;
|
||
}
|
||
|
||
// Need to import AuthResponse type
|
||
import type { AuthResponse } from './responses';
|
||
|
||
// ... rest of existing functions stay the same
|
||
```
|
||
|
||
- [ ] **Step 3: Create auth.ts**
|
||
|
||
```typescript
|
||
// apps/frontend/src/api/auth.ts
|
||
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 },
|
||
});
|
||
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 },
|
||
});
|
||
setAccessToken(result.data.accessToken);
|
||
return result.data;
|
||
}
|
||
|
||
export async function refreshTokens() {
|
||
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
||
skipAuth: true,
|
||
});
|
||
setAccessToken(result.data.accessToken);
|
||
return result.data;
|
||
}
|
||
|
||
export async function logout() {
|
||
await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
||
method: 'POST',
|
||
});
|
||
setAccessToken(null);
|
||
}
|
||
|
||
export async function getMe() {
|
||
return request<UserResponse>('/api/v1/auth/me');
|
||
}
|
||
|
||
export async function updateProfile(data: { name?: string }) {
|
||
return request<UserResponse>('/api/v1/auth/me', undefined, {
|
||
method: 'PATCH',
|
||
body: data,
|
||
});
|
||
}
|
||
```
|
||
|
||
Wait — I need to rethink the client.ts approach. Currently `request()` uses GET with query params. For POST/PATCH requests I need to handle body. Let me redesign.
|
||
|
||
Let me restructure: keep existing API functions untouched (they all use GET), and add a new `authRequest` function or modify `request` to support other methods.
|
||
|
||
I'll modify the `request` function signature slightly:
|
||
|
||
```typescript
|
||
async function request<T>(
|
||
path: string,
|
||
params?: Record<string, string> | { method: string; body?: unknown },
|
||
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
||
): Promise<{ data: T; meta: ApiResponseMeta }>
|
||
```
|
||
|
||
This is getting messy. Better approach: create a separate `authRequest` function or modify the existing request. Let me think...
|
||
|
||
Actually the cleanest approach: modify the existing `request` to accept an options bag with `method`, `body`, `params` as separate fields:
|
||
|
||
```typescript
|
||
interface RequestOptions {
|
||
params?: Record<string, string>;
|
||
method?: string;
|
||
body?: unknown;
|
||
skipAuth?: boolean;
|
||
}
|
||
```
|
||
|
||
This way the existing callers still work with the old signature (params as second arg).
|
||
|
||
Let me plan this properly in the implementation.<|end▁of▁thinking|>
|
||
|
||
<||DSML||tool_calls>
|
||
<||DSML||invoke name="todowrite">
|
||
<||DSML||parameter name="todos" string="false">[{"priority":"high","content":"Save implementation plan to docs/superpowers/plans/","status":"completed"},{"priority":"high","content":"Install backend dependencies (Prisma, JWT, bcrypt, cookie-parser)","status":"in_progress"},{"priority":"high","content":"Set up Prisma schema + PrismaService + PrismaModule","status":"pending"},{"priority":"high","content":"Build AuthService (register, login, refresh, logout) with TDD","status":"pending"},{"priority":"high","content":"Build Auth guards, decorators, DTOs, controller","status":"pending"},{"priority":"high","content":"Integrate AuthModule into app.module, main.ts, configuration","status":"pending"},{"priority":"high","content":"Frontend: refactor client.ts + add auth types","status":"pending"},{"priority":"high","content":"Frontend: AuthContext + useAuth hook","status":"pending"},{"priority":"high","content":"Frontend: Login, Register, Profile pages + ProtectedRoute","status":"pending"},{"priority":"high","content":"Frontend: Layout + routing changes","status":"pending"},{"priority":"high","content":"Documentation (backend auth.md, database.md, ADR, api.md, architecture.md)","status":"pending"},{"priority":"high","content":"Verification: run tests, lint, build","status":"pending"}] |