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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ KPX_API_KEY=
ORACLE_WEIGHT_EIA=0.40
ORACLE_WEIGHT_ENTSOE=0.35
ORACLE_WEIGHT_KPX=0.25

# Logging
LOG_LEVEL=debug

# CORS
CORS_ORIGIN=http://localhost:5173
29 changes: 28 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ jobs:
--health-timeout 5s
--health-retries 5

redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -65,10 +75,27 @@ jobs:
- name: Build frontend
run: pnpm --filter frontend build

- name: Run backend tests
- name: Run backend unit tests
run: pnpm --filter backend test
env:
DATABASE_URL: postgresql://etp:etp_password@localhost:5432/etp_dev
JWT_SECRET: ci-test-secret-key-for-testing
REDIS_HOST: localhost
REDIS_PORT: 6379

- name: Run frontend tests
run: pnpm --filter frontend test

- name: Run database migrations
run: cd backend && npx prisma migrate deploy
env:
DATABASE_URL: postgresql://etp:etp_password@localhost:5432/etp_dev

- name: Run E2E tests
run: pnpm --filter backend test:e2e
env:
DATABASE_URL: postgresql://etp:etp_password@localhost:5432/etp_dev
JWT_SECRET: ci-test-secret-key-for-testing
REDIS_HOST: localhost
REDIS_PORT: 6379
NODE_ENV: test
17 changes: 17 additions & 0 deletions backend/jest.e2e.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Config } from 'jest';

const config: Config = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testRegex: '.e2e-spec.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
testEnvironment: 'node',
moduleNameMapper: {
'^@etp/shared(.*)$': '<rootDir>/../shared/src$1',
},
testTimeout: 30000,
};

export default config;
13 changes: 12 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"prisma:studio": "prisma studio",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
"test:cov": "jest --coverage",
"test:e2e": "jest --config jest.e2e.config.ts"
},
"dependencies": {
"@etp/shared": "workspace:*",
Expand All @@ -36,8 +37,14 @@
"bullmq": "^5.69.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.8.1",
"helmet": "^8.1.0",
"ioredis": "^5.9.3",
"nestjs-pino": "^4.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
Expand All @@ -51,14 +58,18 @@
"@nestjs/schematics": "^10.2.0",
"@nestjs/testing": "^11.1.13",
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@types/uuid": "^11.0.0",
"jest": "^30.2.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.3.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.0",
"ts-node": "^10.9.0",
Expand Down
9 changes: 9 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { CommonModule } from './common/common.module';
import { RedisModule } from './common/redis/redis.module';
import { getLoggerConfig } from './common/logger/logger.config';
import { BlockchainModule } from './blockchain/blockchain.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
Expand All @@ -15,14 +19,19 @@ import { AnalyticsModule } from './analytics/analytics.module';
import { OracleModule } from './oracle/oracle.module';
import { TokenModule } from './token/token.module';
import { HealthModule } from './health/health.module';
import { validate } from './config/env.validation';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../.env',
validate,
}),
LoggerModule.forRoot(getLoggerConfig()),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
ScheduleModule.forRoot(),
RedisModule,
PrismaModule,
CommonModule,
BlockchainModule,
Expand Down
5 changes: 5 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller, Post, Get, Body, Param, UseGuards, Req, Delete } from '@nestjs/common';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
Expand All @@ -11,12 +12,14 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('register')
@Throttle({ default: { ttl: 60000, limit: 5 } })
@ApiOperation({ summary: '회원가입 (DID 자동 발급)' })
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}

@Post('login')
@Throttle({ default: { ttl: 60000, limit: 5 } })
@ApiOperation({ summary: '이메일/비밀번호 로그인' })
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
Expand Down Expand Up @@ -57,12 +60,14 @@ export class AuthController {
// ========== DID 챌린지-응답 인증 ==========

@Post('did/challenge')
@Throttle({ default: { ttl: 60000, limit: 3 } })
@ApiOperation({ summary: 'DID 로그인 챌린지 요청 (1단계)' })
createDIDChallenge(@Body('did') did: string) {
return this.authService.createDIDChallenge(did);
}

@Post('did/login')
@Throttle({ default: { ttl: 60000, limit: 5 } })
@ApiOperation({ summary: 'DID 로그인 서명 제출 (2단계)' })
loginWithDID(
@Body('did') did: string,
Expand Down
35 changes: 25 additions & 10 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { DIDBlockchainService } from '../blockchain/did-blockchain.service';
import { DIDSignatureService } from './services/did-signature.service';
import { REDIS_CLIENT } from '../common/redis/redis.module';

jest.mock('bcrypt');

Expand Down Expand Up @@ -49,6 +50,12 @@ describe('AuthService', () => {
}),
};

const mockRedis = {
get: jest.fn(),
set: jest.fn().mockResolvedValue('OK'),
del: jest.fn().mockResolvedValue(1),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand All @@ -57,6 +64,7 @@ describe('AuthService', () => {
{ provide: JwtService, useValue: mockJwtService },
{ provide: DIDBlockchainService, useValue: mockDidService },
{ provide: DIDSignatureService, useValue: mockSignatureService },
{ provide: REDIS_CLIENT, useValue: mockRedis },
],
}).compile();

Expand Down Expand Up @@ -255,7 +263,7 @@ describe('AuthService', () => {
});

describe('createDIDChallenge', () => {
it('should create challenge for valid DID', async () => {
it('should create challenge and store in Redis', async () => {
mockPrisma.dIDCredential.findUnique.mockResolvedValue({
did: 'did:etp:user-1',
status: 'ACTIVE',
Expand All @@ -265,6 +273,12 @@ describe('AuthService', () => {
const result = await service.createDIDChallenge('did:etp:user-1');
expect(result.challenge).toBe('mock-challenge-hex');
expect(result.did).toBe('did:etp:user-1');
expect(mockRedis.set).toHaveBeenCalledWith(
'did:challenge:did:etp:user-1',
expect.any(String),
'EX',
300,
);
});

it('should reject invalid DID', async () => {
Expand All @@ -278,15 +292,13 @@ describe('AuthService', () => {

describe('loginWithDID', () => {
it('should login with valid DID challenge-response', async () => {
// 먼저 챌린지 생성
mockPrisma.dIDCredential.findUnique.mockResolvedValue({
did: 'did:etp:user-1',
status: 'ACTIVE',
user: { id: 'user-1', name: '테스트' },
});
await service.createDIDChallenge('did:etp:user-1');
// Redis에 챌린지가 저장되어 있다고 mock
const challengeData = {
challenge: 'mock-challenge-hex',
expiresAt: new Date(Date.now() + 300000).toISOString(),
};
mockRedis.get.mockResolvedValue(JSON.stringify(challengeData));

// DID 로그인
mockPrisma.user.findUnique.mockResolvedValue({
id: 'user-1',
email: 'test@example.com',
Expand All @@ -298,9 +310,12 @@ describe('AuthService', () => {
const result = await service.loginWithDID('did:etp:user-1', 'valid-sig');
expect(result.accessToken).toBe('mock-jwt-token');
expect(result.authMethod).toBe('DID');
expect(mockRedis.del).toHaveBeenCalledWith('did:challenge:did:etp:user-1');
});

it('should reject without challenge', async () => {
it('should reject without challenge in Redis', async () => {
mockRedis.get.mockResolvedValue(null);

await expect(
service.loginWithDID('did:etp:unknown', 'sig'),
).rejects.toThrow();
Expand Down
28 changes: 19 additions & 9 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import {
Injectable,
Inject,
UnauthorizedException,
ConflictException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import Redis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { DIDBlockchainService } from '../blockchain/did-blockchain.service';
import { DIDSignatureService } from './services/did-signature.service';
import { REDIS_CLIENT } from '../common/redis/redis.module';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly challengeStore = new Map<string, { challenge: string; expiresAt: Date }>();

constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly didService: DIDBlockchainService,
private readonly didSignatureService: DIDSignatureService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}

async register(dto: RegisterDto) {
Expand Down Expand Up @@ -174,6 +177,7 @@ export class AuthService {

/**
* DID 챌린지 생성 (DID 기반 로그인 1단계)
* Redis에 5분 TTL로 저장하여 서버 재시작/스케일아웃에도 안전
*/
async createDIDChallenge(did: string) {
const credential = await this.prisma.dIDCredential.findUnique({
Expand All @@ -186,10 +190,14 @@ export class AuthService {
}

const { challenge, expiresAt } = this.didSignatureService.generateChallenge();
this.challengeStore.set(did, { challenge, expiresAt });

// 5분 후 자동 삭제
setTimeout(() => this.challengeStore.delete(did), 5 * 60 * 1000);
// Redis에 저장 (5분 TTL)
await this.redis.set(
`did:challenge:${did}`,
JSON.stringify({ challenge, expiresAt: expiresAt.toISOString() }),
'EX',
300,
);

return { challenge, expiresAt, did };
}
Expand All @@ -198,13 +206,15 @@ export class AuthService {
* DID 챌린지-응답 검증 (DID 기반 로그인 2단계)
*/
async loginWithDID(did: string, signature: string) {
const stored = this.challengeStore.get(did);
if (!stored) {
const raw = await this.redis.get(`did:challenge:${did}`);
if (!raw) {
throw new BadRequestException('챌린지가 존재하지 않습니다. 먼저 챌린지를 요청하세요.');
}

if (new Date() > stored.expiresAt) {
this.challengeStore.delete(did);
const stored = JSON.parse(raw) as { challenge: string; expiresAt: string };

if (new Date() > new Date(stored.expiresAt)) {
await this.redis.del(`did:challenge:${did}`);
throw new BadRequestException('챌린지가 만료되었습니다');
}

Expand All @@ -218,7 +228,7 @@ export class AuthService {
throw new UnauthorizedException(`DID 인증 실패: ${result.message}`);
}

this.challengeStore.delete(did);
await this.redis.del(`did:challenge:${did}`);

const user = await this.prisma.user.findUnique({
where: { id: result.userId },
Expand Down
5 changes: 3 additions & 2 deletions backend/src/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { IsEmail, IsString } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
@IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요' })
email: string;

@ApiProperty({ example: 'password123' })
@IsString()
@IsNotEmpty({ message: '비밀번호를 입력해주세요' })
password: string;
}
Loading
Loading