feat(backend): 소셜 로그인·JWT 인증과 회원·파일 API 추가

앱의 기록을 서버로 옮기려면 회원과 인증이 먼저 있어야 한다.

- 소셜 로그인: 앱이 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다
  (카카오·네이버는 사용자 정보 API, 구글·Apple 은 id_token JWKS 검증)
- 소셜 앱 키가 생기기 전까지 쓰는 개발용 로그인(기기 키) — 운영에서는 닫힌다
- 액세스 JWT(1시간) + 리프레시 토큰(해시 저장·회전·재사용 탐지)
- 전역 JwtAuthGuard — 기본으로 모든 라우트가 로그인을 요구하고 @Public() 으로만 연다
- 내 정보 조회·수정·탈퇴(모든 기록과 파일 정리), 파일 소유자 기록과 내려받기,
  업로드 시 매직 바이트로 내용 형식 검사
- 컬럼은 snake_case 이름 규칙, 테스트 환경은 기동 시 마이그레이션 실행 (CI e2e 는 빈 DB)
- 템플릿 예제 사용자 API(in-memory)를 실제 회원 API 로 교체하고 e2e 계약 테스트를 옮겼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 17:06:43 +09:00
parent 1f7693898c
commit 7e0e4c36b5
53 changed files with 2943 additions and 274 deletions
+9
View File
@@ -6,7 +6,9 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { AuthModule } from '@/modules/auth/auth.module';
import { HealthModule } from '@/modules/health/health.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
@@ -60,6 +62,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
HealthModule,
UploadModule,
UserModule,
AuthModule,
],
providers: [
ShutdownLogger,
@@ -67,6 +70,12 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// 전역 인증 — 모든 라우트가 기본으로 로그인을 요구한다. 예외는 @Public() 으로 연다.
// (rate limit 이 먼저 돌아 토큰 없는 대량 요청도 한도에 걸린다)
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule implements NestModule {
@@ -0,0 +1,24 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
/** 액세스 토큰에서 꺼낸 로그인 사용자 — JwtStrategy.validate 가 만든다 */
export interface AuthUser {
readonly userId: string;
}
/**
* 로그인한 사용자를 핸들러 인자로 받는다.
* findMine(@CurrentUser() user: AuthUser) { ... }
* @Public() 라우트에서는 쓰지 않는다 — 사용자가 없으면 AUTH_001 이다.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthUser => {
const request = context
.switchToHttp()
.getRequest<Request & { user?: AuthUser }>();
if (!request.user) throw new BusinessException('AUTH_001');
return request.user;
},
);
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* 로그인 없이 부를 수 있는 엔드포인트에 붙인다.
* 전역 JwtAuthGuard 가 기본으로 모든 라우트를 보호하므로, 예외만 이 데코레이터로 연다
* (로그인·토큰 갱신·헬스체크 등). 빠뜨려서 열리는 일은 없고, 빠뜨리면 닫힌다.
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,35 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '@/common/decorators/public.decorator';
import { BusinessException } from '@/common/errors/business.exception';
/**
* 전역 인증 가드 (app.module.ts 의 APP_GUARD).
* 모든 라우트는 기본으로 액세스 토큰이 필요하고, @Public() 이 붙은 라우트만 연다.
* 토큰이 없거나 틀렸거나 만료됐으면 원인을 가리지 않고 AUTH_001 로 응답한다 (.claude/rules/security.md).
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
override canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
override handleRequest<TUser>(err: unknown, user: TUser | false): TUser {
if (err || !user) {
// passport 가 던진 예외(영문 메시지)를 그대로 내보내지 않고 에러 코드로 바꾼다
throw new BusinessException('AUTH_001');
}
return user;
}
}
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAuthUsersFiles1791705189542 implements MigrationInterface {
name = 'AddAuthUsersFiles1791705189542';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "stored_name" character varying(64) NOT NULL, "original_name" character varying(255) NOT NULL, "mime_type" character varying(100) NOT NULL, "size" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_dea1609c1d56367529ace1e1530" UNIQUE ("stored_name"), CONSTRAINT "PK_6c16b9093a142e0e7613b04a3d9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_4bc1db1f4f34ec9415acd88afd" ON "files" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "nickname" character varying(20), "photo_file_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "social_accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "provider" character varying(10) NOT NULL, "subject" character varying(255) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_820de75e7b1d6872a798f40850a" UNIQUE ("provider", "subject"), CONSTRAINT "PK_e9e58d2d8e9fafa20af914d9750" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_05a0f282d3bed93ca048a7e54d" ON "social_accounts" ("user_id") `,
);
await queryRunner.query(
`CREATE TABLE "refresh_tokens" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "token_hash" character(64) NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_a7838d2ba25be1342091b6695f1" UNIQUE ("token_hash"), CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3ddc983c5f7bcf132fd8732c3f" ON "refresh_tokens" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "files" ADD CONSTRAINT "FK_4bc1db1f4f34ec9415acd88afdb" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "FK_e4313545e4345b359e66bdbebd8" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "social_accounts" ADD CONSTRAINT "FK_05a0f282d3bed93ca048a7e54dd" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_3ddc983c5f7bcf132fd8732c3f4" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_3ddc983c5f7bcf132fd8732c3f4"`,
);
await queryRunner.query(
`ALTER TABLE "social_accounts" DROP CONSTRAINT "FK_05a0f282d3bed93ca048a7e54dd"`,
);
await queryRunner.query(
`ALTER TABLE "users" DROP CONSTRAINT "FK_e4313545e4345b359e66bdbebd8"`,
);
await queryRunner.query(
`ALTER TABLE "files" DROP CONSTRAINT "FK_4bc1db1f4f34ec9415acd88afdb"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_3ddc983c5f7bcf132fd8732c3f"`,
);
await queryRunner.query(`DROP TABLE "refresh_tokens"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_05a0f282d3bed93ca048a7e54d"`,
);
await queryRunner.query(`DROP TABLE "social_accounts"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_4bc1db1f4f34ec9415acd88afd"`,
);
await queryRunner.query(`DROP TABLE "files"`);
}
}
@@ -0,0 +1,63 @@
import {
accessTokenTtlMs,
isDevLoginEnabled,
refreshTokenTtlMs,
resolveJwtSecret,
socialAuthConfig,
} from './auth.config';
describe('인증 설정', () => {
it('토큰 유효 시간은 env 값을, 없거나 틀리면 기본값(1시간·14일)을 쓴다', () => {
expect(accessTokenTtlMs({ JWT_ACCESS_EXPIRES_IN: '15m' })).toBe(900_000);
expect(accessTokenTtlMs({})).toBe(3_600_000);
expect(accessTokenTtlMs({ JWT_ACCESS_EXPIRES_IN: '' })).toBe(3_600_000);
expect(refreshTokenTtlMs({ JWT_REFRESH_EXPIRES_IN: '30d' })).toBe(
2_592_000_000,
);
expect(refreshTokenTtlMs({ JWT_REFRESH_EXPIRES_IN: '2주' })).toBe(
1_209_600_000,
);
});
it('JWT_SECRET 이 비어 있으면 개발용 키로 대신한다 (운영은 env 검증이 막는다)', () => {
expect(resolveJwtSecret({ JWT_SECRET: 'real-secret' })).toBe('real-secret');
expect(resolveJwtSecret({ JWT_SECRET: ' ' })).toMatch(/dev-only/);
});
it('개발용 로그인은 운영에서 항상 꺼진다', () => {
expect(isDevLoginEnabled({ NODE_ENV: 'development' })).toBe(true);
expect(isDevLoginEnabled({ NODE_ENV: 'test' })).toBe(true);
expect(
isDevLoginEnabled({ NODE_ENV: 'development', AUTH_DEV_LOGIN: 'false' }),
).toBe(false);
expect(
isDevLoginEnabled({ NODE_ENV: 'production', AUTH_DEV_LOGIN: 'true' }),
).toBe(false);
});
it('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
expect(socialAuthConfig({})).toEqual({
kakaoAppId: null,
naverClientId: null,
googleClientIds: [],
appleClientIds: [],
});
});
it('카카오 앱 ID 는 숫자, 구글·Apple 은 콤마 목록으로 읽는다', () => {
expect(
socialAuthConfig({
KAKAO_APP_ID: '123456',
NAVER_CLIENT_ID: 'naver-client',
GOOGLE_CLIENT_IDS: 'ios.apps.googleusercontent.com, android.apps',
APPLE_CLIENT_IDS: 'net.wandergo.rup,',
}),
).toEqual({
kakaoAppId: 123456,
naverClientId: 'naver-client',
googleClientIds: ['ios.apps.googleusercontent.com', 'android.apps'],
appleClientIds: ['net.wandergo.rup'],
});
expect(socialAuthConfig({ KAKAO_APP_ID: 'abc' }).kakaoAppId).toBeNull();
});
});
+83
View File
@@ -0,0 +1,83 @@
import { parseDurationMs } from '@/shared/utils/duration';
/**
* 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값).
* 값은 모듈을 불러올 때가 아니라 쓸 때 읽는다 (ConfigModule 이 env 파일을 읽은 뒤여야 한다).
* 형식 검증은 shared/config/env.validation.ts 가 기동 시점에 한다.
*/
type Env = Readonly<Record<string, string | undefined>>;
/** 액세스 토큰 기본 유효 시간 — 짧게 둔다 (.claude/rules/security.md) */
const DEFAULT_ACCESS_TTL_MS = 3_600_000; // 1시간
/** 리프레시 토큰 기본 유효 시간 */
const DEFAULT_REFRESH_TTL_MS = 1_209_600_000; // 14일
/**
* 개발·테스트 전용 서명 키 — JWT_SECRET 을 비워 둔 로컬·CI 에서만 쓴다.
* 운영은 env.validation 이 JWT_SECRET 을 필수로 요구하므로 이 값으로 기동할 수 없다.
*/
const DEV_ONLY_JWT_SECRET = 'rup-dev-only-jwt-secret-not-for-production';
function readEnv(env: Env, key: string): string | undefined {
const value = env[key]?.trim();
return value === '' ? undefined : value;
}
export function resolveJwtSecret(env: Env = process.env): string {
return readEnv(env, 'JWT_SECRET') ?? DEV_ONLY_JWT_SECRET;
}
export function accessTokenTtlMs(env: Env = process.env): number {
return parseDurationMs(env.JWT_ACCESS_EXPIRES_IN) ?? DEFAULT_ACCESS_TTL_MS;
}
export function refreshTokenTtlMs(env: Env = process.env): number {
return parseDurationMs(env.JWT_REFRESH_EXPIRES_IN) ?? DEFAULT_REFRESH_TTL_MS;
}
/**
* 개발용 로그인(소셜 앱 키 없이 기기 키로 가입) 허용 여부.
* 운영에서는 어떤 값을 주어도 꺼진다. 개발·테스트에서는 AUTH_DEV_LOGIN=false 로 끌 수 있다.
*/
export function isDevLoginEnabled(env: Env = process.env): boolean {
if (env.NODE_ENV === 'production') return false;
return readEnv(env, 'AUTH_DEV_LOGIN') !== 'false';
}
/** 콤마로 나눈 목록 (빈 칸 제거) */
function readList(env: Env, key: string): string[] {
return (readEnv(env, key) ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
}
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
export interface SocialAuthConfig {
/** 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다 */
readonly kakaoAppId: number | null;
/** 네이버 로그인 사용 여부 (클라이언트 ID 가 있으면 켠다) */
readonly naverClientId: string | null;
/** 구글 OAuth 클라이언트 ID 들 (iOS·Android·웹) — id_token 의 aud */
readonly googleClientIds: readonly string[];
/** Apple 번들 ID·서비스 ID 들 — id_token 의 aud */
readonly appleClientIds: readonly string[];
}
export function socialAuthConfig(env: Env = process.env): SocialAuthConfig {
const kakao = Number(readEnv(env, 'KAKAO_APP_ID'));
return {
kakaoAppId: Number.isInteger(kakao) && kakao > 0 ? kakao : null,
naverClientId: readEnv(env, 'NAVER_CLIENT_ID') ?? null,
googleClientIds: readList(env, 'GOOGLE_CLIENT_IDS'),
appleClientIds: readList(env, 'APPLE_CLIENT_IDS'),
};
}
/** 액세스 토큰 페이로드의 종류 표시 — 다른 용도의 JWT 를 액세스 토큰으로 쓰지 못하게 한다 */
export const ACCESS_TOKEN_TYPE = 'access';
/** 액세스 토큰 서명 알고리즘 — 다른 알고리즘(none 등)으로 서명한 토큰은 받지 않는다 */
export const ACCESS_TOKEN_ALGORITHM = 'HS256';
@@ -0,0 +1,83 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiNoContentResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { Public } from '@/common/decorators/public.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AuthService } from './auth.service';
import { AuthResultDto, AuthTokensDto } from './dto/auth-result.dto';
import { DevLoginDto, RefreshTokenDto, SocialLoginDto } from './dto/login.dto';
/**
* 로그인처럼 브루트포스 표적이 되는 엔드포인트는 전역 한도보다 엄격하게 막는다 (.claude/rules/security.md).
* 분당 10회 — 정상 사용(로그인 한 번, 갱신 몇 번)에는 걸리지 않는다.
*/
const AUTH_THROTTLE = { default: { ttl: 60_000, limit: 10 } };
// 인증 — 경로는 리소스 이름으로 둔다 (세션 만들기 = 로그인, 토큰 만들기 = 갱신, 토큰 폐기 = 로그아웃)
@ApiTags('Auth')
@Public()
@Throttle(AUTH_THROTTLE)
@ApiErrorResponse('REQ_001', '요청 한도 초과')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('sessions')
@ApiOperation({
summary: '소셜 로그인 (처음이면 가입)',
description:
'앱이 소셜 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다. 실패 원인은 구분하지 않는다.',
})
@ApiStandardResponse(AuthResultDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('AUTH_001', '토큰 확인 실패')
@ApiErrorResponse('SYS_001', '소셜 제공자 장애')
login(@Body() dto: SocialLoginDto): Promise<AuthResultDto> {
return this.authService.socialLogin(dto);
}
@Post('dev-sessions')
@ApiOperation({
summary: '개발용 로그인 (운영에서는 열리지 않음)',
description:
'소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 써서 가입·로그인한다.',
})
@ApiStandardResponse(AuthResultDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('RES_001', '운영 환경 — 엔드포인트 없음')
devLogin(@Body() dto: DevLoginDto): Promise<AuthResultDto> {
return this.authService.devLogin(dto);
}
@Post('tokens')
@ApiOperation({
summary: '토큰 갱신',
description:
'리프레시 토큰은 한 번만 쓸 수 있다. 새 한 쌍으로 바꿔 보관한다.',
})
@ApiStandardResponse(AuthTokensDto, { status: 201, description: '새 토큰' })
@ApiErrorResponse('AUTH_001', '만료·폐기·없는 토큰')
refresh(@Body() dto: RefreshTokenDto): Promise<AuthTokensDto> {
return this.authService.refresh(dto.refreshToken);
}
@Post('token-revocation')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '로그아웃 (리프레시 토큰 폐기)' })
@ApiNoContentResponse({
description: '폐기 완료 (이미 폐기된 토큰이어도 성공)',
})
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
await this.authService.logout(dto.refreshToken);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '@/modules/user/user.module';
import { ACCESS_TOKEN_ALGORITHM, resolveJwtSecret } from './auth.config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { JwtStrategy } from './jwt.strategy';
import { RefreshTokenCleanup } from './refresh-token.cleanup';
import { IdTokenVerifier } from './social/id-token.verifier';
import { SocialVerifierService } from './social/social-verifier.service';
@Module({
imports: [
TypeOrmModule.forFeature([RefreshToken]),
PassportModule,
// 서명 키는 쓸 때 읽는다 — ConfigModule 이 env 파일을 읽은 뒤여야 한다
JwtModule.registerAsync({
useFactory: () => ({
secret: resolveJwtSecret(),
signOptions: { algorithm: ACCESS_TOKEN_ALGORITHM },
}),
}),
UserModule,
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
SocialVerifierService,
IdTokenVerifier,
RefreshTokenCleanup,
],
})
export class AuthModule {}
@@ -0,0 +1,218 @@
import { JwtService } from '@nestjs/jwt';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { UserService } from '@/modules/user/user.service';
import { sha256Hex } from '@/shared/utils/hash';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { SocialVerifierService } from './social/social-verifier.service';
const USER_ID = '0b6f3c4e-2a1d-4f5e-8c9b-7a6d5e4f3c2b';
const SIGNED_IN = {
user: {
id: USER_ID,
nickname: '집사',
photoFileId: null,
provider: 'kakao' as const,
createdAt: '2026-10-11T00:00:00.000Z',
},
isNewUser: true,
};
function storedToken(overrides: Partial<RefreshToken> = {}): RefreshToken {
return {
id: 'rt-1',
userId: USER_ID,
tokenHash: sha256Hex('old-token'),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
createdAt: new Date(),
...overrides,
};
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('예외가 나지 않았다');
}
describe('AuthService', () => {
const repo = {
findOneBy: jest.fn(),
update: jest.fn(),
save: jest.fn(),
create: jest.fn((v: Partial<RefreshToken>) => v),
};
const users = { findOrCreateBySocial: jest.fn() };
const verifier = { verify: jest.fn() };
const jwt = new JwtService({ secret: 'test-secret' });
const service = new AuthService(
jwt,
users as unknown as UserService,
verifier as unknown as SocialVerifierService,
repo as unknown as Repository<RefreshToken>,
);
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv, NODE_ENV: 'test' };
users.findOrCreateBySocial.mockResolvedValue(SIGNED_IN);
repo.save.mockImplementation((v: unknown) => Promise.resolve(v));
});
afterAll(() => {
process.env = originalEnv;
});
describe('로그인', () => {
it('개발용 로그인은 기기 키를 소셜 id 로 써서 가입시키고 토큰을 준다', async () => {
const result = await service.devLogin({
provider: 'kakao',
deviceKey: 'device-key-0000000001',
nickname: ' 집사 ',
});
expect(users.findOrCreateBySocial).toHaveBeenCalledWith({
provider: 'kakao',
subject: 'dev:device-key-0000000001',
nickname: '집사',
});
expect(result.user.id).toBe(USER_ID);
expect(result.isNewUser).toBe(true);
expect(
jwt.verify<{ sub: string; typ: string }>(result.accessToken),
).toMatchObject({ sub: USER_ID, typ: 'access' });
});
it('리프레시 토큰은 원문이 아니라 해시로 저장한다', async () => {
const result = await service.devLogin({
provider: 'kakao',
deviceKey: 'device-key-0000000001',
});
const [[saved]] = repo.save.mock.calls as [[Partial<RefreshToken>]];
expect(saved.tokenHash).toBe(sha256Hex(result.refreshToken));
expect(saved.tokenHash).not.toContain(result.refreshToken);
});
it('운영에서는 개발용 로그인이 없는 엔드포인트처럼 응답한다', async () => {
process.env.NODE_ENV = 'production';
await expect(
codeOf(
service.devLogin({ provider: 'kakao', deviceKey: 'device-key-01' }),
),
).resolves.toBe('RES_001');
expect(users.findOrCreateBySocial).not.toHaveBeenCalled();
});
it('소셜 로그인은 제공자가 이름을 주지 않을 때만 앱이 보낸 이름을 쓴다', async () => {
verifier.verify.mockResolvedValueOnce({
provider: 'apple',
subject: 'a-1',
nickname: null,
});
await service.socialLogin({
provider: 'apple',
token: 't',
nickname: '두부맘',
});
expect(users.findOrCreateBySocial).toHaveBeenLastCalledWith(
expect.objectContaining({ nickname: '두부맘' }),
);
verifier.verify.mockResolvedValueOnce({
provider: 'kakao',
subject: 'k-1',
nickname: '카카오이름',
});
await service.socialLogin({
provider: 'kakao',
token: 't',
nickname: '앱이름',
});
expect(users.findOrCreateBySocial).toHaveBeenLastCalledWith(
expect.objectContaining({ nickname: '카카오이름' }),
);
});
});
describe('토큰 갱신', () => {
it('유효한 토큰이면 폐기하고 새 한 쌍을 준다', async () => {
repo.findOneBy.mockResolvedValue(storedToken());
repo.update.mockResolvedValue({ affected: 1 });
const tokens = await service.refresh('old-token');
expect(repo.findOneBy).toHaveBeenCalledWith({
tokenHash: sha256Hex('old-token'),
});
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rt-1' }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
expect(tokens.refreshToken).not.toBe('old-token');
expect(tokens.accessTokenExpiresIn).toBe(3600);
});
it('없는 토큰이면 AUTH_001', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(codeOf(service.refresh('nope'))).resolves.toBe('AUTH_001');
});
it('만료된 토큰이면 AUTH_001 이고 새 토큰을 만들지 않는다', async () => {
repo.findOneBy.mockResolvedValue(
storedToken({ expiresAt: new Date(Date.now() - 1) }),
);
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.save).not.toHaveBeenCalled();
});
it('폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (탈취 대응)', async () => {
repo.findOneBy.mockResolvedValue(
storedToken({ revokedAt: new Date(Date.now() - 1000) }),
);
repo.update.mockResolvedValue({ affected: 2 });
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ userId: USER_ID }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
expect(repo.save).not.toHaveBeenCalled();
});
it('같은 토큰으로 동시에 갱신하면 늦은 쪽은 AUTH_001', async () => {
repo.findOneBy.mockResolvedValue(storedToken());
repo.update.mockResolvedValue({ affected: 0 });
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.save).not.toHaveBeenCalled();
});
});
it('로그아웃은 토큰 해시로 폐기한다', async () => {
repo.update.mockResolvedValue({ affected: 0 });
await expect(service.logout('old-token')).resolves.toBeUndefined();
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ tokenHash: sha256Hex('old-token') }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { VerifiedSocialAccount } from '@/modules/user/entities/social-account.entity';
import { SignedInUser, UserService } from '@/modules/user/user.service';
import { randomToken, sha256Hex } from '@/shared/utils/hash';
import {
ACCESS_TOKEN_TYPE,
accessTokenTtlMs,
isDevLoginEnabled,
refreshTokenTtlMs,
} from './auth.config';
import { AuthResultDto, AuthTokensDto } from './dto/auth-result.dto';
import { DevLoginDto, SocialLoginDto } from './dto/login.dto';
import { RefreshToken } from './entities/refresh-token.entity';
import { SocialVerifierService } from './social/social-verifier.service';
export interface AccessTokenPayload {
readonly sub: string;
readonly typ: typeof ACCESS_TOKEN_TYPE;
}
/** 리프레시 토큰 원문 길이 (바이트) */
const REFRESH_TOKEN_BYTES = 32;
/** 개발용 로그인의 소셜 id 접두어 — 실제 소셜 id 와 겹치지 않게 한다 */
const DEV_SUBJECT_PREFIX = 'dev:';
const MS_PER_SECOND = 1_000;
// 로그인·토큰 갱신·로그아웃
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly jwt: JwtService,
private readonly users: UserService,
private readonly verifier: SocialVerifierService,
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
/** 소셜 로그인 — 제공자에게 토큰을 확인하고, 처음이면 가입시킨다 */
async socialLogin(dto: SocialLoginDto): Promise<AuthResultDto> {
const verified = await this.verifier.verify(dto.provider, dto.token);
return this.signIn({
...verified,
nickname: verified.nickname ?? dto.nickname?.trim() ?? null,
});
}
/**
* 개발용 로그인 — 기기 키를 소셜 계정 대신 쓴다. 운영에서는 없는 엔드포인트처럼 응답한다.
* 소셜 앱 키가 준비되면 앱은 socialLogin 으로 바꾼다.
*/
async devLogin(dto: DevLoginDto): Promise<AuthResultDto> {
if (!isDevLoginEnabled()) {
throw new BusinessException('RES_001', {
context: { reason: 'dev-login-disabled' },
});
}
return this.signIn({
provider: dto.provider,
subject: `${DEV_SUBJECT_PREFIX}${dto.deviceKey}`,
nickname: dto.nickname?.trim() || null,
});
}
/**
* 토큰 갱신 — 리프레시 토큰을 폐기하고 새 한 쌍을 준다(회전).
* 이미 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 리프레시 토큰을 모두 폐기한다.
*/
async refresh(refreshToken: string): Promise<AuthTokensDto> {
const stored = await this.refreshTokens.findOneBy({
tokenHash: sha256Hex(refreshToken),
});
if (!stored) {
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-refresh-token' },
});
}
if (stored.revokedAt !== null) {
await this.revokeAll(stored.userId);
this.logger.warn(
`폐기된 리프레시 토큰 재사용 — 회원 ${stored.userId} 의 토큰을 모두 폐기`,
);
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-reused', userId: stored.userId },
});
}
if (stored.expiresAt.getTime() <= Date.now()) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-expired', userId: stored.userId },
});
}
// 같은 토큰으로 동시에 갱신하면 한쪽만 성공해야 한다 — 아직 유효한 경우에만 폐기한다
const revoked = await this.refreshTokens.update(
{ id: stored.id, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
if (!revoked.affected) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-raced', userId: stored.userId },
});
}
return this.issueTokens(stored.userId);
}
/** 로그아웃 — 이 기기의 리프레시 토큰을 폐기한다. 이미 없거나 폐기된 토큰이어도 성공으로 본다 */
async logout(refreshToken: string): Promise<void> {
await this.refreshTokens.update(
{ tokenHash: sha256Hex(refreshToken), revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
private async signIn(account: VerifiedSocialAccount): Promise<AuthResultDto> {
const signedIn: SignedInUser =
await this.users.findOrCreateBySocial(account);
const tokens = await this.issueTokens(signedIn.user.id);
return { ...tokens, ...signedIn };
}
private async issueTokens(userId: string): Promise<AuthTokensDto> {
const accessTtlMs = accessTokenTtlMs();
const payload: AccessTokenPayload = { sub: userId, typ: ACCESS_TOKEN_TYPE };
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: Math.floor(accessTtlMs / MS_PER_SECOND),
});
const refreshToken = randomToken(REFRESH_TOKEN_BYTES);
const expiresAt = new Date(Date.now() + refreshTokenTtlMs());
await this.refreshTokens.save(
this.refreshTokens.create({
userId,
tokenHash: sha256Hex(refreshToken),
expiresAt,
revokedAt: null,
}),
);
return {
accessToken,
accessTokenExpiresIn: Math.floor(accessTtlMs / MS_PER_SECOND),
refreshToken,
refreshTokenExpiresAt: expiresAt.toISOString(),
};
}
private async revokeAll(userId: string): Promise<void> {
await this.refreshTokens.update(
{ userId, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
}
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { UserResponseDto } from '@/modules/user/dto/user-response.dto';
/** 발급한 토큰 한 쌍 */
export class AuthTokensDto {
@ApiProperty({ description: '액세스 토큰 — Authorization: Bearer 로 보낸다' })
accessToken!: string;
@ApiProperty({ description: '액세스 토큰 유효 시간 (초)', example: 3600 })
accessTokenExpiresIn!: number;
@ApiProperty({
description:
'리프레시 토큰 — 한 번 쓰면 폐기되고 새 토큰이 온다. 앱은 secure storage 에 보관한다',
})
refreshToken!: string;
@ApiProperty({ description: '리프레시 토큰 만료 시각 (ISO 8601 UTC)' })
refreshTokenExpiresAt!: string;
}
/** 로그인 결과 */
export class AuthResultDto extends AuthTokensDto {
@ApiProperty({ description: '로그인한 회원', type: UserResponseDto })
user!: UserResponseDto;
@ApiProperty({ description: '이번에 처음 가입했는지', example: false })
isNewUser!: boolean;
}
+76
View File
@@ -0,0 +1,76 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { SOCIAL_PROVIDERS } from '@/modules/user/entities/social-account.entity';
import type { SocialProvider } from '@/modules/user/entities/social-account.entity';
import { NICKNAME_MAX_LENGTH } from '@/modules/user/entities/user.entity';
/** 소셜 토큰 최대 길이 — 제공자 토큰은 수백~2천 자 정도다. 과도한 입력만 막는다 */
const SOCIAL_TOKEN_MAX_LENGTH = 4096;
/** 개발용 로그인의 기기 키 — 앱이 처음 실행할 때 만든 무작위 값 */
const DEVICE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
class LoginBaseDto {
@ApiProperty({
description: '소셜 로그인 제공자',
enum: SOCIAL_PROVIDERS,
example: 'kakao',
})
@IsIn(SOCIAL_PROVIDERS, { message: '지원하지 않는 로그인 방식입니다.' })
provider!: SocialProvider;
@ApiPropertyOptional({
description: `처음 가입할 때 쓸 이름 (제공자가 이름을 주지 않는 경우 — Apple 등). 최대 ${NICKNAME_MAX_LENGTH}자`,
example: '집사',
})
@IsOptional()
@IsString({ message: '이름은 문자로 입력해 주세요.' })
@MaxLength(NICKNAME_MAX_LENGTH, {
message: `이름은 ${NICKNAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
nickname?: string;
}
/** 소셜 로그인 — 앱이 소셜 SDK 로 받은 토큰을 넘긴다 */
export class SocialLoginDto extends LoginBaseDto {
@ApiProperty({
description:
'카카오·네이버는 액세스 토큰, 구글·Apple 은 id_token. 서버가 제공자에게 직접 확인한다',
})
@IsString({ message: '로그인 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '로그인 정보가 올바르지 않습니다.' })
@MaxLength(SOCIAL_TOKEN_MAX_LENGTH, {
message: '로그인 정보가 올바르지 않습니다.',
})
token!: string;
}
/**
* 개발용 로그인 — 소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 쓴다.
* 운영에서는 엔드포인트가 열리지 않는다.
*/
export class DevLoginDto extends LoginBaseDto {
@ApiProperty({
description: '기기 키 (영문·숫자·-·_ 16~128자). 같은 키면 같은 회원이다',
example: 'f3b1c2d4e5a6978812345678',
})
@Matches(DEVICE_KEY_PATTERN, { message: '기기 정보가 올바르지 않습니다.' })
deviceKey!: string;
}
/** 토큰 갱신·로그아웃 — 리프레시 토큰을 넘긴다 */
export class RefreshTokenDto {
@ApiProperty({ description: '로그인 때 받은 리프레시 토큰' })
@IsString({ message: '로그인 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '로그인 정보가 올바르지 않습니다.' })
@MaxLength(256, { message: '로그인 정보가 올바르지 않습니다.' })
refreshToken!: string;
}
@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/**
* 리프레시 토큰 — 원문은 저장하지 않고 SHA-256 해시만 둔다 (DB 가 새어도 토큰으로 쓸 수 없다).
* 한 번 쓰면 폐기하고 새 토큰을 발급한다(회전). 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 토큰을 모두 폐기한다.
*/
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@Index()
@Column({ type: 'uuid' })
userId!: string;
/** 토큰 원문의 SHA-256 (hex) */
@Column({ type: 'char', length: 64, unique: true })
tokenHash!: string;
@Column({ type: 'timestamptz' })
expiresAt!: Date;
/** 회전·로그아웃으로 폐기한 때 — 유효하면 null */
@Column({ type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
+38
View File
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UserService } from '@/modules/user/user.service';
import {
ACCESS_TOKEN_ALGORITHM,
ACCESS_TOKEN_TYPE,
resolveJwtSecret,
} from './auth.config';
/** Authorization: Bearer 액세스 토큰을 확인해 요청에 로그인 사용자를 붙인다 */
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly users: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: resolveJwtSecret(),
algorithms: [ACCESS_TOKEN_ALGORITHM],
ignoreExpiration: false,
});
}
async validate(payload: { sub?: unknown; typ?: unknown }): Promise<AuthUser> {
if (typeof payload.sub !== 'string' || payload.typ !== ACCESS_TOKEN_TYPE) {
throw new BusinessException('AUTH_001');
}
// 탈퇴한 회원의 토큰은 만료 전이라도 거절한다
if (!(await this.users.exists(payload.sub))) {
throw new BusinessException('AUTH_001', {
context: { reason: 'user-gone', userId: payload.sub },
});
}
return { userId: payload.sub };
}
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
/**
* 만료된 리프레시 토큰을 매일 지운다.
* 폐기된 토큰은 만료 전까지 남겨 둔다 — 재사용(탈취)을 알아보려면 기록이 있어야 한다.
*/
@Injectable()
export class RefreshTokenCleanup {
private readonly logger = new Logger(RefreshTokenCleanup.name);
constructor(
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async removeExpired(): Promise<void> {
const result = await this.refreshTokens.delete({
expiresAt: LessThan(new Date()),
});
if (result.affected) {
this.logger.log(`만료된 리프레시 토큰 ${result.affected}개 정리`);
}
}
}
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JsonWebKey, createPublicKey } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { fetchProviderJson, pick } from './social-http';
/** 공개키 목록을 다시 받아 오는 주기 — 제공자는 키를 주기적으로 바꾼다 */
const JWKS_CACHE_TTL_MS = 3_600_000; // 1시간
/** 제공자 id_token 의 서명 알고리즘 (구글·Apple 모두 RS256) */
const ID_TOKEN_ALGORITHM = 'RS256';
export interface IdTokenRules {
/** 제공자의 공개키 목록 주소 (JWKS) */
readonly jwksUrl: string;
/** 발급자(iss) 로 허용하는 값 */
readonly issuers: readonly string[];
/** 수신자(aud) 로 허용하는 값 — 우리 앱의 클라이언트 ID */
readonly audiences: readonly string[];
}
interface CachedKeys {
readonly keys: ReadonlyMap<string, JsonWebKey>;
readonly fetchedAt: number;
}
/** JWT 헤더에서 서명 키 id(kid)와 알고리즘을 꺼낸다 — 형식이 틀리면 null */
export function readJwtHeader(
token: string,
): { kid: string; alg: string } | null {
const [encoded] = token.split('.');
if (!encoded) return null;
try {
const header: unknown = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf8'),
);
const kid = pick(header, 'kid');
const alg = pick(header, 'alg');
return typeof kid === 'string' && typeof alg === 'string'
? { kid, alg }
: null;
} catch {
return null;
}
}
/**
* 구글·Apple 의 id_token 을 확인한다 — 서명(JWKS)·발급자·수신자·만료.
* 확인에 실패하면 원인을 가리지 않고 AUTH_001 이다.
*/
@Injectable()
export class IdTokenVerifier {
private readonly cache = new Map<string, CachedKeys>();
constructor(private readonly jwt: JwtService) {}
async verify(
token: string,
rules: IdTokenRules,
): Promise<Record<string, unknown>> {
const header = readJwtHeader(token);
if (!header || header.alg !== ID_TOKEN_ALGORITHM) {
throw new BusinessException('AUTH_001', {
context: { reason: 'invalid-id-token-header' },
});
}
const jwk = await this.findKey(rules.jwksUrl, header.kid);
const publicKey = createPublicKey({ key: jwk, format: 'jwk' })
.export({ type: 'spki', format: 'pem' })
.toString();
try {
return await this.jwt.verifyAsync<Record<string, unknown>>(token, {
publicKey,
algorithms: [ID_TOKEN_ALGORITHM],
// 호출한 쪽이 비어 있지 않은 목록만 넘긴다 (설정이 비면 그 전에 거절한다)
issuer: [...rules.issuers] as [string, ...string[]],
audience: [...rules.audiences] as [string, ...string[]],
});
} catch (error) {
throw new BusinessException('AUTH_001', {
context: {
reason: 'id-token-rejected',
detail: error instanceof Error ? error.message : String(error),
},
});
}
}
/** kid 에 맞는 공개키 — 없으면 목록을 한 번 새로 받아 본다 (키가 막 바뀐 경우) */
private async findKey(jwksUrl: string, kid: string): Promise<JsonWebKey> {
const cached = this.cache.get(jwksUrl);
const fresh =
cached !== undefined && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS;
const key = fresh ? cached.keys.get(kid) : undefined;
if (key) return key;
const reloaded = await this.loadKeys(jwksUrl);
const found = reloaded.get(kid);
if (!found) {
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-signing-key', kid },
});
}
return found;
}
private async loadKeys(
jwksUrl: string,
): Promise<ReadonlyMap<string, JsonWebKey>> {
const body = await fetchProviderJson(jwksUrl);
const list = pick(body, 'keys');
const keys = new Map<string, JsonWebKey>();
if (Array.isArray(list)) {
for (const item of list as unknown[]) {
const kid = pick(item, 'kid');
if (typeof kid === 'string' && pick(item, 'kty') === 'RSA') {
keys.set(kid, item as JsonWebKey);
}
}
}
this.cache.set(jwksUrl, { keys, fetchedAt: Date.now() });
return keys;
}
}
@@ -0,0 +1,74 @@
import { BusinessException } from '@/common/errors/business.exception';
/** 소셜 제공자 API 응답을 기다리는 최대 시간 */
const SOCIAL_HTTP_TIMEOUT_MS = 5_000;
/** 4xx — 토큰이 틀렸거나 만료됐다 (사용자 책임). 5xx·네트워크 오류는 제공자 장애다 */
const CLIENT_ERROR_MIN = 400;
const SERVER_ERROR_MIN = 500;
/**
* 소셜 제공자 API 를 부르고 JSON 본문을 돌려준다.
* - 토큰 거절(4xx) → AUTH_001 (원인을 구분해 알리지 않는다 — .claude/rules/security.md)
* - 제공자 장애(5xx·시간 초과·네트워크) → SYS_001 (사용자에게 "다시 로그인" 이 아니라 "잠시 후" 로 안내)
*/
export async function fetchProviderJson(
url: string,
init: { headers?: Record<string, string> } = {},
): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
headers: init.headers,
signal: AbortSignal.timeout(SOCIAL_HTTP_TIMEOUT_MS),
});
} catch (error) {
throw new BusinessException('SYS_001', {
context: {
url,
reason: error instanceof Error ? error.message : String(error),
},
});
}
if (response.status >= SERVER_ERROR_MIN) {
throw new BusinessException('SYS_001', {
context: { url, status: response.status },
});
}
if (response.status >= CLIENT_ERROR_MIN) {
throw new BusinessException('AUTH_001', {
context: { url, status: response.status },
});
}
try {
return (await response.json()) as unknown;
} catch {
throw new BusinessException('SYS_001', {
context: { url, reason: 'invalid-json' },
});
}
}
/** 응답 객체에서 경로를 따라 값을 꺼낸다 — 형식이 다르면 undefined */
export function pick(source: unknown, ...path: string[]): unknown {
let current: unknown = source;
for (const key of path) {
if (typeof current !== 'object' || current === null) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
/** 문자열·숫자 id 를 문자열로 — 카카오는 숫자, 네이버는 문자열로 준다 */
export function idString(value: unknown): string | null {
if (typeof value === 'string' && value !== '') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return null;
}
/** 비어 있지 않은 문자열만 */
export function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
@@ -0,0 +1,194 @@
import { BusinessException } from '@/common/errors/business.exception';
import { IdTokenVerifier } from './id-token.verifier';
import { readJwtHeader } from './id-token.verifier';
import { SocialVerifierService } from './social-verifier.service';
/** fetch 를 정해진 응답으로 바꿔 끼운다 — 외부 네트워크에 의존하지 않는다 */
function mockFetch(
responses: Record<string, { status?: number; body: unknown }>,
): jest.SpyInstance {
return jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
const found = responses[url];
if (!found) return Promise.reject(new Error(`unexpected ${url}`));
return Promise.resolve(
new Response(JSON.stringify(found.body), { status: found.status ?? 200 }),
);
});
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('예외가 나지 않았다');
}
describe('SocialVerifierService', () => {
const idTokens = { verify: jest.fn() };
const service = new SocialVerifierService(
idTokens as unknown as IdTokenVerifier,
);
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
idTokens.verify.mockReset();
});
afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
});
describe('카카오', () => {
beforeEach(() => {
process.env.KAKAO_APP_ID = '1001';
});
it('우리 앱의 토큰이면 카카오 id 와 닉네임을 돌려준다', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
body: { id: 42, app_id: 1001 },
},
'https://kapi.kakao.com/v2/user/me': {
body: { id: 42, kakao_account: { profile: { nickname: '콩이맘' } } },
},
});
await expect(service.verify('kakao', 'token')).resolves.toEqual({
provider: 'kakao',
subject: '42',
nickname: '콩이맘',
});
});
it('다른 앱에서 발급된 토큰이면 AUTH_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
body: { id: 42, app_id: 9999 },
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
});
it('카카오가 토큰을 거절하면(401) AUTH_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
status: 401,
body: { code: -401 },
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
});
it('카카오 장애(5xx)는 로그인 실패가 아니라 SYS_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
status: 503,
body: {},
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'SYS_001',
);
});
it('앱 ID 가 설정되지 않았으면 제공자를 부르지 않고 AUTH_001', async () => {
delete process.env.KAKAO_APP_ID;
const fetchSpy = mockFetch({});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('네이버', () => {
it('성공 응답이면 네이버 id 와 닉네임을 돌려준다', async () => {
process.env.NAVER_CLIENT_ID = 'client';
mockFetch({
'https://openapi.naver.com/v1/nid/me': {
body: {
resultcode: '00',
response: { id: 'naver-abc', nickname: '두부' },
},
},
});
await expect(service.verify('naver', 'token')).resolves.toEqual({
provider: 'naver',
subject: 'naver-abc',
nickname: '두부',
});
});
});
describe('구글·Apple', () => {
it('구글 id_token 의 sub 와 name 을 쓴다', async () => {
process.env.GOOGLE_CLIENT_IDS = 'client-a,client-b';
idTokens.verify.mockResolvedValue({ sub: 'g-1', name: '집사' });
await expect(service.verify('google', 'id-token')).resolves.toEqual({
provider: 'google',
subject: 'g-1',
nickname: '집사',
});
expect(idTokens.verify).toHaveBeenCalledWith(
'id-token',
expect.objectContaining({ audiences: ['client-a', 'client-b'] }),
);
});
it('Apple 은 이름을 주지 않는다', async () => {
process.env.APPLE_CLIENT_IDS = 'net.wandergo.rup';
idTokens.verify.mockResolvedValue({ sub: 'apple-1' });
await expect(service.verify('apple', 'id-token')).resolves.toEqual({
provider: 'apple',
subject: 'apple-1',
nickname: null,
});
});
it('클라이언트 ID 가 없으면 id_token 을 확인하지 않고 AUTH_001', async () => {
await expect(codeOf(service.verify('google', 'id-token'))).resolves.toBe(
'AUTH_001',
);
expect(idTokens.verify).not.toHaveBeenCalled();
});
});
});
describe('readJwtHeader', () => {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString('base64url');
it('kid 와 alg 를 꺼낸다', () => {
expect(readJwtHeader(`${encode({ kid: 'k1', alg: 'RS256' })}.x.y`)).toEqual(
{ kid: 'k1', alg: 'RS256' },
);
});
it('형식이 틀리면 null', () => {
expect(readJwtHeader('')).toBeNull();
expect(readJwtHeader('not-json.x.y')).toBeNull();
expect(readJwtHeader(`${encode({ alg: 'RS256' })}.x.y`)).toBeNull();
});
});
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
SocialProvider,
VerifiedSocialAccount,
} from '@/modules/user/entities/social-account.entity';
import { SocialAuthConfig, socialAuthConfig } from '../auth.config';
import { IdTokenVerifier } from './id-token.verifier';
import {
fetchProviderJson,
idString,
nonEmptyString,
pick,
} from './social-http';
/** 제공자 API 주소 */
const KAKAO_TOKEN_INFO_URL = 'https://kapi.kakao.com/v1/user/access_token_info';
const KAKAO_ME_URL = 'https://kapi.kakao.com/v2/user/me';
const NAVER_ME_URL = 'https://openapi.naver.com/v1/nid/me';
const NAVER_SUCCESS_CODE = '00';
const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
const GOOGLE_ISSUERS = ['accounts.google.com', 'https://accounts.google.com'];
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
const APPLE_ISSUERS = ['https://appleid.apple.com'];
/**
* 앱이 소셜 SDK 로 받은 토큰을 제공자에게 직접 확인한다. 앱이 보낸 사용자 id 는 믿지 않는다.
* - 카카오·네이버: 액세스 토큰으로 사용자 정보 API 를 부른다
* - 구글·Apple: id_token 의 서명·발급자·수신자를 확인한다
* 그 제공자의 앱 정보가 설정되지 않았으면 로그인을 거절한다 (AUTH_001).
*/
@Injectable()
export class SocialVerifierService {
constructor(private readonly idTokens: IdTokenVerifier) {}
verify(
provider: SocialProvider,
token: string,
): Promise<VerifiedSocialAccount> {
const config = socialAuthConfig();
switch (provider) {
case 'kakao':
return this.verifyKakao(token, config);
case 'naver':
return this.verifyNaver(token, config);
case 'google':
return this.verifyGoogle(token, config);
case 'apple':
return this.verifyApple(token, config);
}
}
private async verifyKakao(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.kakaoAppId === null)
throw notConfigured('kakao', 'KAKAO_APP_ID');
const headers = { Authorization: `Bearer ${token}` };
// 다른 앱에서 발급된 토큰으로 로그인하는 것을 막는다 — 토큰의 앱 ID 가 우리 앱이어야 한다
const info = await fetchProviderJson(KAKAO_TOKEN_INFO_URL, { headers });
if (pick(info, 'app_id') !== config.kakaoAppId) {
throw rejected('kakao', 'app-id-mismatch');
}
const me = await fetchProviderJson(KAKAO_ME_URL, { headers });
const subject = idString(pick(me, 'id'));
if (!subject) throw rejected('kakao', 'missing-id');
return {
provider: 'kakao',
subject,
nickname:
nonEmptyString(pick(me, 'kakao_account', 'profile', 'nickname')) ??
nonEmptyString(pick(me, 'properties', 'nickname')),
};
}
/**
* ⚠️ 네이버는 액세스 토큰이 어느 앱에서 발급됐는지 확인하는 API 가 없다.
* 클라이언트 ID 를 설정해야 켜지게 해 두었고, 더 엄격히 하려면 인가 코드를 서버가 교환하는 방식으로 바꾼다.
*/
private async verifyNaver(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.naverClientId === null) {
throw notConfigured('naver', 'NAVER_CLIENT_ID');
}
const me = await fetchProviderJson(NAVER_ME_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (pick(me, 'resultcode') !== NAVER_SUCCESS_CODE) {
throw rejected('naver', 'result-code');
}
const subject = idString(pick(me, 'response', 'id'));
if (!subject) throw rejected('naver', 'missing-id');
return {
provider: 'naver',
subject,
nickname:
nonEmptyString(pick(me, 'response', 'nickname')) ??
nonEmptyString(pick(me, 'response', 'name')),
};
}
private async verifyGoogle(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.googleClientIds.length === 0) {
throw notConfigured('google', 'GOOGLE_CLIENT_IDS');
}
const claims = await this.idTokens.verify(token, {
jwksUrl: GOOGLE_JWKS_URL,
issuers: GOOGLE_ISSUERS,
audiences: config.googleClientIds,
});
const subject = idString(claims.sub);
if (!subject) throw rejected('google', 'missing-sub');
return {
provider: 'google',
subject,
nickname: nonEmptyString(claims.name),
};
}
/** Apple 은 id_token 에 이름을 싣지 않는다 — 앱이 첫 로그인 때 받은 이름을 따로 보낸다 */
private async verifyApple(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.appleClientIds.length === 0) {
throw notConfigured('apple', 'APPLE_CLIENT_IDS');
}
const claims = await this.idTokens.verify(token, {
jwksUrl: APPLE_JWKS_URL,
issuers: APPLE_ISSUERS,
audiences: config.appleClientIds,
});
const subject = idString(claims.sub);
if (!subject) throw rejected('apple', 'missing-sub');
return { provider: 'apple', subject, nickname: null };
}
}
function rejected(provider: SocialProvider, reason: string): BusinessException {
return new BusinessException('AUTH_001', { context: { provider, reason } });
}
/** 설정 누락은 운영자가 고칠 일이다 — 로그에 어떤 값이 빠졌는지 남긴다 */
function notConfigured(
provider: SocialProvider,
envKey: string,
): BusinessException {
return new BusinessException('AUTH_001', {
context: { provider, reason: `not-configured:${envKey}` },
});
}
@@ -15,6 +15,7 @@ import {
} from '@nestjs/terminus';
import type { Response } from 'express';
import { Public } from '@/common/decorators/public.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
@@ -32,7 +33,9 @@ const DB_PING_TIMEOUT_MS = 5_000;
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
* 모니터링 도구는 로그인하지 않으므로 인증에서도 제외한다 (@Public).
*/
@Public()
@SkipThrottle()
@SkipTransform()
@ApiTags('Health')
@@ -1,12 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
import { StoredFile } from '../entities/stored-file.entity';
/** 업로드 결과 — 저장 경로(절대경로)와 저장 파일명은 노출하지 않는다 */
export class UploadResponseDto {
@ApiProperty({
description: '서버가 생성한 파일 식별자 (저장 파일명)',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
description: '파일 ID — 아이 사진 등 다른 기록에서 이 값으로 참조한다',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f',
})
fileName!: string;
id!: string;
@ApiProperty({
description: '업로드 당시 원본 파일명',
@@ -19,4 +21,13 @@ export class UploadResponseDto {
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
size!: number;
static from(file: StoredFile): UploadResponseDto {
return {
id: file.id,
originalName: file.originalName,
mimeType: file.mimeType,
size: file.size,
};
}
}
@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/**
* 업로드한 파일의 메타데이터. 실제 파일은 UPLOAD_DIR 에 [storedName] 으로 저장된다.
* 다른 엔티티는 이 id 를 참조하며, 참조가 남아 있는 파일은 FK 때문에 지워지지 않는다.
*/
@Entity('files')
export class StoredFile {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 올린 사람 — 회원이 탈퇴하면 파일 기록도 함께 지운다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 서버가 만든 저장 파일명 (UUID + 검증된 확장자) — 경로 조립에는 이 값만 쓴다 */
@Column({ type: 'varchar', length: 64, unique: true })
storedName!: string;
/** 올릴 때의 원본 파일명 — 표시용 메타데이터일 뿐 경로에 쓰지 않는다 */
@Column({ type: 'varchar', length: 255 })
originalName!: string;
@Column({ type: 'varchar', length: 100 })
mimeType!: string;
@Column({ type: 'integer' })
size!: number;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -1,4 +1,9 @@
import { allowedExtensionList, isAllowedFile } from './upload.config';
import {
allowedExtensionList,
isAllowedFile,
isImageMimeType,
matchesFileSignature,
} from './upload.config';
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
@@ -28,3 +33,48 @@ describe('파일 업로드 검증', () => {
}
});
});
describe('파일 시그니처 검사', () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d,
]);
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
const webp = Buffer.from('RIFF\u0000\u0000\u0000\u0000WEBP', 'latin1');
it('내용이 형식과 맞으면 통과한다', () => {
expect(matchesFileSignature('image/png', png)).toBe(true);
expect(matchesFileSignature('image/jpeg', jpeg)).toBe(true);
expect(matchesFileSignature('image/webp', webp)).toBe(true);
expect(matchesFileSignature('image/gif', Buffer.from('GIF89a......'))).toBe(
true,
);
expect(
matchesFileSignature('application/pdf', Buffer.from('%PDF-1.7 ...')),
).toBe(true);
});
it('이름·MIME 이 이미지여도 내용이 HTML 이면 거부한다', () => {
const html = Buffer.from('<html><script>');
expect(matchesFileSignature('image/png', html)).toBe(false);
expect(matchesFileSignature('image/jpeg', html)).toBe(false);
});
it('다른 형식의 내용이면 거부한다', () => {
expect(matchesFileSignature('image/png', jpeg)).toBe(false);
});
it('허용 목록에 없는 MIME 은 거부한다', () => {
expect(matchesFileSignature('text/html', Buffer.from('<html>'))).toBe(
false,
);
});
});
describe('사진 형식', () => {
it('허용된 이미지 형식만 사진으로 쓴다', () => {
expect(isImageMimeType('image/png')).toBe(true);
expect(isImageMimeType('application/pdf')).toBe(false);
expect(isImageMimeType('image/svg+xml')).toBe(false);
});
});
@@ -42,6 +42,56 @@ export function isAllowedFile(originalName: string, mimeType: string): boolean {
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
/** 파일 앞부분에서 형식을 판별하는 데 읽는 바이트 수 */
export const SIGNATURE_BYTES = 12;
/**
* 형식별 파일 시그니처(매직 바이트) — 확장자·MIME 은 클라이언트가 정하므로 내용으로 한 번 더 확인한다.
* 이름이 .png 인데 내용이 HTML 인 파일을 여기서 걸러낸다 (.claude/rules/security.md 의 알려진 한계).
*/
const FILE_SIGNATURES: ReadonlyMap<string, (head: Buffer) => boolean> = new Map(
[
[
'image/jpeg',
(h: Buffer) => h.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
],
[
'image/png',
(h: Buffer) =>
h
.subarray(0, 8)
.equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
),
],
[
'image/gif',
(h: Buffer) =>
['GIF87a', 'GIF89a'].includes(h.subarray(0, 6).toString('latin1')),
],
[
'image/webp',
(h: Buffer) =>
h.subarray(0, 4).toString('latin1') === 'RIFF' &&
h.subarray(8, 12).toString('latin1') === 'WEBP',
],
[
'application/pdf',
(h: Buffer) => h.subarray(0, 5).toString('latin1') === '%PDF-',
],
],
);
/** 파일 앞부분([head])이 [mimeType] 형식의 시그니처와 맞는지 */
export function matchesFileSignature(mimeType: string, head: Buffer): boolean {
return FILE_SIGNATURES.get(mimeType)?.(head) ?? false;
}
/** 사진으로 쓸 수 있는 형식인지 — 아이·프로필·다이어리 사진은 이미지만 받는다 */
export function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith('image/') && ALLOWED_FILE_TYPES.has(mimeType);
}
/** 사용자에게 안내할 허용 확장자 목록 */
export function allowedExtensionList(): string {
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
@@ -1,23 +1,43 @@
import {
Controller,
Get,
Header,
Param,
ParseUUIDPipe,
Post,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 본인만 받으므로 private) */
const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
// 파일 업로드·내려받기 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이,
// 내용(시그니처) 검사와 소유자 기록은 UploadService 가 담당한다.
@ApiTags('Upload')
@ApiBearerAuth()
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@@ -41,7 +61,11 @@ export class UploadController {
})
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
@ApiErrorResponse('AUTH_001', '로그인 필요')
upload(
@CurrentUser() user: AuthUser,
@UploadedFile() file?: Express.Multer.File,
): Promise<UploadResponseDto> {
if (!file) {
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
@@ -49,6 +73,27 @@ export class UploadController {
});
}
return this.uploadService.toResponse(file);
return this.uploadService.register(file, user.userId);
}
@Get(':id')
@SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다
@Header('Cache-Control', FILE_CACHE_CONTROL)
@ApiOperation({ summary: '내 파일 내려받기' })
@ApiOkResponse({ description: '파일 본문 (Content-Type 은 서버가 정한다)' })
@ApiErrorResponse('RES_001', '파일이 없거나 내 파일이 아님')
async download(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<StreamableFile> {
const file = await this.uploadService.download(id, user.userId);
// 업로드 디렉터리를 정적으로 서빙하지 않는다 — 형식은 서버 기록으로 정하고,
// 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md)
return new StreamableFile(file.stream, {
type: file.mimeType,
length: file.size,
disposition: `attachment; filename="${file.downloadName}"`,
});
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { TypeOrmModule } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { mkdirSync } from 'fs';
@@ -11,6 +12,7 @@ import {
UPLOAD_DIR,
isAllowedFile,
} from './upload.config';
import { StoredFile } from './entities/stored-file.entity';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@@ -19,6 +21,7 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
@Module({
imports: [
TypeOrmModule.forFeature([StoredFile]),
MulterModule.register({
storage: diskStorage({
destination: UPLOAD_DIR,
+134 -14
View File
@@ -1,36 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { InjectRepository } from '@nestjs/typeorm';
import { createReadStream } from 'fs';
import { open, unlink } from 'fs/promises';
import { join } from 'path';
import { Readable } from 'stream';
import { Repository } from 'typeorm';
import { UPLOAD_DIR } from './upload.config';
import { BusinessException } from '@/common/errors/business.exception';
import { isForeignKeyViolation } from '@/shared/utils/db-error';
import { StoredFile } from './entities/stored-file.entity';
import {
SIGNATURE_BYTES,
UPLOAD_DIR,
isImageMimeType,
matchesFileSignature,
} from './upload.config';
import { UploadResponseDto } from './dto/upload-response.dto';
// 업로드된 파일의 메타데이터를 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
/** 내려줄 파일 — 컨트롤러가 StreamableFile 로 감싼다 */
export interface FileDownload {
readonly stream: Readable;
readonly mimeType: string;
readonly size: number;
readonly downloadName: string;
}
// 업로드된 파일의 기록과 실제 파일을 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 내용 검사·기록·정리를 맡는다.
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
toResponse(file: Express.Multer.File): UploadResponseDto {
constructor(
@InjectRepository(StoredFile)
private readonly files: Repository<StoredFile>,
) {}
/** multer 가 저장한 파일의 내용을 확인하고 [ownerId] 의 파일로 기록한다 */
async register(
file: Express.Multer.File,
ownerId: string,
): Promise<UploadResponseDto> {
if (!(await this.hasValidSignature(file))) {
await this.unlinkQuietly(file.filename);
throw new BusinessException('FILE_001', {
context: { mimeType: file.mimetype, reason: 'signature-mismatch' },
});
}
const saved = await this.files.save(
this.files.create({
ownerId,
storedName: file.filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
}),
);
this.logger.log(
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
`파일 업로드 완료: ${saved.id} (${file.size} bytes, ${file.mimetype})`,
);
return UploadResponseDto.from(saved);
}
/** 내 파일을 내려받는다 — 남의 파일이나 없는 파일은 똑같이 RES_001 */
async download(fileId: string, ownerId: string): Promise<FileDownload> {
const file = await this.files.findOneBy({ id: fileId, ownerId });
if (!file) {
throw new BusinessException('RES_001', { context: { fileId } });
}
return {
fileName: file.filename,
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
originalName: file.originalname,
mimeType: file.mimetype,
stream: createReadStream(join(UPLOAD_DIR, file.storedName)),
mimeType: file.mimeType,
size: file.size,
// 원본 이름은 경로·헤더 조립에 쓰지 않는다 — 서버가 만든 이름으로 내려준다
downloadName: file.storedName,
};
}
/**
* 저장된 파일을 삭제한다.
* 사진으로 쓸 파일인지 확인한다 — 내 파일이고 이미지여야 한다.
* null 은 "사진 없음" 이라 그대로 통과시킨다.
*/
async assertOwnedImage(
fileId: string | null | undefined,
ownerId: string,
): Promise<void> {
if (fileId === null || fileId === undefined) return;
const file = await this.files.findOneBy({ id: fileId, ownerId });
if (!file || !isImageMimeType(file.mimeType)) {
throw new BusinessException('VAL_001', {
context: { fileId, reason: 'not-owned-image' },
});
}
}
/**
* 더 이상 쓰지 않는 파일을 지운다 (사진을 바꾸거나 기록을 지운 뒤).
* 다른 기록이 아직 참조하면 FK 가 막으므로 그대로 둔다 — 같은 파일을 두 곳에서 써도 안전하다.
*/
async releaseIfUnused(fileIds: ReadonlyArray<string | null>): Promise<void> {
for (const id of new Set(fileIds)) {
if (id === null) continue;
const file = await this.files.findOneBy({ id });
if (!file) continue;
try {
await this.files.delete({ id });
} catch (error) {
if (isForeignKeyViolation(error)) continue;
throw error;
}
await this.unlinkQuietly(file.storedName);
}
}
/** 회원의 저장 파일 이름 — 탈퇴할 때 기록을 지운 뒤 실제 파일을 정리하려고 미리 받아 둔다 */
async storedNamesOf(ownerId: string): Promise<string[]> {
const files = await this.files.find({
where: { ownerId },
select: { storedName: true },
});
return files.map((f) => f.storedName);
}
/**
* 저장된 파일을 디스크에서 지운다. 실패해도 요청은 성공으로 끝낸다 —
* 가리키는 기록이 없는 파일은 다시 내려줄 길이 없다. 로그만 남긴다.
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
*/
async remove(fileName: string): Promise<void> {
await unlink(join(UPLOAD_DIR, fileName));
async unlinkQuietly(storedName: string): Promise<void> {
try {
await unlink(join(UPLOAD_DIR, storedName));
} catch (error) {
this.logger.warn(
`업로드 파일 삭제 실패: ${storedName} (${error instanceof Error ? error.message : String(error)})`,
);
}
}
private async hasValidSignature(file: Express.Multer.File): Promise<boolean> {
const handle = await open(join(UPLOAD_DIR, file.filename), 'r');
try {
const head = Buffer.alloc(SIGNATURE_BYTES);
const { bytesRead } = await handle.read(head, 0, SIGNATURE_BYTES, 0);
return matchesFileSignature(file.mimetype, head.subarray(0, bytesRead));
} finally {
await handle.close();
}
}
}
@@ -1,20 +0,0 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 사용자 생성 DTO 샘플 — 모든 검증 메시지는 한국어, Swagger 메타 필수
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
@IsString()
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
name!: string;
}
@@ -0,0 +1,41 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateIf,
} from 'class-validator';
import { NICKNAME_MAX_LENGTH } from '../entities/user.entity';
/** 내 프로필 수정 — 보낸 값만 바꾼다 */
export class UpdateMeDto {
@ApiPropertyOptional({
description: `닉네임 (1~${NICKNAME_MAX_LENGTH}자, 앞뒤 공백 제거)`,
example: '콩이 보호자',
})
// 보내지 않으면 그대로 둔다. null 은 받지 않는다 (닉네임을 지우는 기능은 없다)
@ValidateIf((dto: UpdateMeDto) => dto.nickname !== undefined)
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString({ message: '닉네임은 문자로 입력해 주세요.' })
@IsNotEmpty({ message: '닉네임을 입력해 주세요.' })
@MaxLength(NICKNAME_MAX_LENGTH, {
message: `닉네임은 ${NICKNAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
nickname?: string;
@ApiPropertyOptional({
description:
'프로필 사진 파일 ID (POST /api/files 로 올린 이미지). null 이면 사진을 지운다',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
}
@@ -1,13 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
/** 사용자 응답 DTO — Swagger 스키마와 클라이언트 타입의 기준이 된다. */
import { SOCIAL_PROVIDERS } from '../entities/social-account.entity';
import type { SocialProvider } from '../entities/social-account.entity';
import { User } from '../entities/user.entity';
/** 내 정보 응답 — 앱 마이페이지·인사말에 쓴다 */
export class UserResponseDto {
@ApiProperty({ description: '사용자 ID', example: 1 })
id!: number;
@ApiProperty({
description: '회원 ID',
example: '0b6f3c4e-2a1d-4f5e-8c9b-7a6d5e4f3c2b',
})
id!: string;
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
email!: string;
@ApiProperty({
description: '닉네임 — 모르면 null (앱은 "집사" 로 부른다)',
example: '콩이 보호자',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
name!: string;
@ApiProperty({
description:
'프로필 사진 파일 ID — GET /api/files/{id} 로 받는다. 없으면 null',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({
description: '로그인한 소셜 계정 종류',
enum: SOCIAL_PROVIDERS,
example: 'kakao',
})
provider!: SocialProvider;
@ApiProperty({ description: '가입 시각 (ISO 8601 UTC)' })
createdAt!: string;
static from(user: User, provider: SocialProvider): UserResponseDto {
return {
id: user.id,
nickname: user.nickname,
photoFileId: user.photoFileId,
provider,
createdAt: user.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/** 지원하는 소셜 로그인 제공자 — 앱(flutter SocialProvider)의 이름과 같다 */
export const SOCIAL_PROVIDERS = ['naver', 'kakao', 'google', 'apple'] as const;
export type SocialProvider = (typeof SOCIAL_PROVIDERS)[number];
/** 제공자 확인을 마친 소셜 계정 */
export interface VerifiedSocialAccount {
readonly provider: SocialProvider;
/** 제공자 쪽 사용자 고유 id */
readonly subject: string;
/** 제공자가 알려 준 이름 — 처음 가입할 때 닉네임으로 쓴다. 모르면 null */
readonly nickname: string | null;
}
/** 소셜 계정 — (제공자, 제공자 쪽 사용자 id) 하나가 회원 한 명이다 */
@Entity('social_accounts')
@Unique(['provider', 'subject'])
export class SocialAccount {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@Index()
@Column({ type: 'uuid' })
userId!: string;
@Column({ type: 'varchar', length: 10 })
provider!: SocialProvider;
/** 제공자가 발급한 사용자 고유 id — 이메일 등 다른 개인정보는 저장하지 않는다 */
@Column({ type: 'varchar', length: 255 })
subject!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
/** 닉네임 최대 길이 — 앱 프로필 편집 화면과 맞춘다 */
export const NICKNAME_MAX_LENGTH = 20;
/** 회원 — 소셜 계정(SocialAccount)으로 가입한다 */
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 앱에서 부르는 이름 — 소셜 계정 이름으로 시작하고 앱에서 바꿀 수 있다. 모르면 null */
@Column({ type: 'varchar', length: NICKNAME_MAX_LENGTH, nullable: true })
nickname!: string | null;
/**
* 프로필 사진. 참조 중인 파일은 지울 수 없게 둔다(NO ACTION) —
* UploadService.releaseIfUnused 가 이 제약으로 "아직 쓰는 파일"을 가린다.
*/
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
+41 -34
View File
@@ -1,59 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Query,
HttpCode,
HttpStatus,
Patch,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { UpdateMeDto } from './dto/update-me.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { UserService } from './user.service';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
// 내 정보 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('User')
@ApiBearerAuth()
@Controller('users')
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('users/me')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
@ApiStandardResponse(UserResponseDto, {
status: 200,
description: '사용자 목록 조회 성공',
isArray: true,
})
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
findMany(@Query() query: CursorPaginationQueryDto) {
return this.userService.findMany(query.limit, query.cursor);
@ApiOperation({ summary: '내 정보 조회' })
@ApiStandardResponse(UserResponseDto, { description: '내 정보 조회 성공' })
getMe(@CurrentUser() user: AuthUser): Promise<UserResponseDto> {
return this.userService.getMe(user.userId);
}
@Get(':id')
@ApiOperation({ summary: '사용자 단건 조회' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
@Patch()
@ApiOperation({ summary: '내 프로필 수정 (닉네임·사진)' })
@ApiStandardResponse(UserResponseDto, { description: '수정한 내 정보' })
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 이미지 파일이 아님')
updateMe(
@CurrentUser() user: AuthUser,
@Body() dto: UpdateMeDto,
): Promise<UserResponseDto> {
return this.userService.updateMe(user.userId, dto);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '회원탈퇴',
description:
'회원과 모든 기록(아이·일정·다이어리·명함·사진)을 지운다. 되돌릴 수 없다.',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
@ApiNoContentResponse({ description: '탈퇴 완료' })
async withdraw(@CurrentUser() user: AuthUser): Promise<void> {
await this.userService.withdraw(user.userId);
}
}
+6
View File
@@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UploadModule } from '@/modules/upload/upload.module';
import { SocialAccount } from './entities/social-account.entity';
import { User } from './entities/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [TypeOrmModule.forFeature([User, SocialAccount]), UploadModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
+120 -43
View File
@@ -1,58 +1,135 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { UpdateMeDto } from './dto/update-me.dto';
import { UserResponseDto } from './dto/user-response.dto';
import {
SocialAccount,
VerifiedSocialAccount,
} from './entities/social-account.entity';
import { NICKNAME_MAX_LENGTH, User } from './entities/user.entity';
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
/** 가입·로그인 결과 */
export interface SignedInUser {
readonly user: UserResponseDto;
readonly isNewUser: boolean;
}
// 회원 — 가입(소셜 계정 연결)·내 정보·탈퇴
@Injectable()
export class UserService {
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
private readonly logger = new Logger(UserService.name);
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
@InjectRepository(SocialAccount)
private readonly accounts: Repository<SocialAccount>,
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
/** 액세스 토큰의 회원이 아직 있는지 — 탈퇴한 회원의 토큰은 만료 전이라도 거절한다 */
exists(userId: string): Promise<boolean> {
return this.users.existsBy({ id: userId });
}
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
const afterId = cursor ? Number(cursor) : 0;
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
const last = page.at(-1);
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
/** 소셜 계정으로 회원을 찾고, 처음이면 가입시킨다 */
async findOrCreateBySocial(
account: VerifiedSocialAccount,
): Promise<SignedInUser> {
const found = await this.findBySocial(account);
if (found) return { user: found, isNewUser: false };
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext && last ? String(last.id) : null,
total: sorted.length,
});
}
create(dto: CreateUserDto): UserResponseDto {
const duplicated = [...this.users.values()].some(
(user) => user.email === dto.email,
);
if (duplicated) {
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
throw new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
context: { email: dto.email },
try {
const user = await this.dataSource.transaction(async (manager) => {
const created = await manager.save(
manager.create(User, {
nickname: account.nickname?.slice(0, NICKNAME_MAX_LENGTH) || null,
photoFileId: null,
}),
);
await manager.save(
manager.create(SocialAccount, {
userId: created.id,
provider: account.provider,
subject: account.subject,
}),
);
return created;
});
this.logger.log(`회원 가입: ${user.id} (${account.provider})`);
return {
user: UserResponseDto.from(user, account.provider),
isNewUser: true,
};
} catch (error) {
// 같은 계정으로 동시에 가입하면 유니크 제약에 걸린다 — 먼저 만들어진 회원으로 로그인한다
if (!isUniqueViolation(error)) throw error;
const raced = await this.findBySocial(account);
if (!raced) throw error;
return { user: raced, isNewUser: false };
}
}
async getMe(userId: string): Promise<UserResponseDto> {
const [user, account] = await Promise.all([
this.users.findOneBy({ id: userId }),
this.accounts.findOne({ where: { userId }, order: { createdAt: 'ASC' } }),
]);
if (!user || !account) {
throw new BusinessException('RES_001', { context: { userId } });
}
return UserResponseDto.from(user, account.provider);
}
async updateMe(userId: string, dto: UpdateMeDto): Promise<UserResponseDto> {
const user = await this.users.findOneBy({ id: userId });
if (!user) throw new BusinessException('RES_001', { context: { userId } });
const previousPhoto = user.photoFileId;
if (dto.photoFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.photoFileId, userId);
user.photoFileId = dto.photoFileId;
}
if (dto.nickname !== undefined) user.nickname = dto.nickname;
await this.users.save(user);
if (previousPhoto !== user.photoFileId) {
await this.uploads.releaseIfUnused([previousPhoto]);
}
return this.getMe(userId);
}
/**
* 회원탈퇴 — 회원과 그 회원의 모든 기록(아이·일정·다이어리·명함·파일 기록)을 지운다.
* 기록은 FK(ON DELETE CASCADE)로 함께 지워지고, 실제 파일은 그 뒤에 디스크에서 정리한다.
*/
async withdraw(userId: string): Promise<void> {
const storedNames = await this.uploads.storedNamesOf(userId);
const result = await this.users.delete({ id: userId });
if (!result.affected) {
throw new BusinessException('RES_001', { context: { userId } });
}
const id = this.users.size + 1;
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
for (const name of storedNames) await this.uploads.unlinkQuietly(name);
this.logger.log(`회원 탈퇴: ${userId} (파일 ${storedNames.length}개 정리)`);
}
private async findBySocial(
account: VerifiedSocialAccount,
): Promise<UserResponseDto | null> {
const found = await this.accounts.findOne({
where: { provider: account.provider, subject: account.subject },
relations: { user: true },
});
return found?.user
? UserResponseDto.from(found.user, found.provider)
: null;
}
}
+13 -2
View File
@@ -1,6 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
import { parsePositiveInt } from '@/shared/utils/env';
/**
@@ -30,6 +31,15 @@ export function isProductionEnv(): boolean {
return process.env.NODE_ENV === 'production';
}
/**
* 기동할 때 미적용 마이그레이션을 실행할지.
* 운영과 테스트(e2e — 빈 DB 에서 시작한다)는 자동으로 실행하고,
* 개발은 `migration:run` 으로 직접 적용한다 (생성한 SQL 을 확인한 뒤 적용하도록).
*/
function shouldRunMigrations(): boolean {
return isProductionEnv() || process.env.NODE_ENV === 'test';
}
/**
* 스키마 자동 동기화 사용 여부.
*
@@ -69,9 +79,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password,
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
migrationsRun: shouldRunMigrations(),
logging: !isProduction,
// 컬럼은 snake_case, 엔티티 속성은 camelCase
namingStrategy: new SnakeNamingStrategy(),
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
@@ -12,6 +12,7 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'a-production-secret-that-is-long-enough-123',
...extra,
});
@@ -27,6 +28,18 @@ describe('validateEnv', () => {
).toThrow(/PORT 는 양의 정수/);
});
it('토큰 만료 기간 형식이 틀리면 실패한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '1시간',
}),
).toThrow(/JWT_ACCESS_EXPIRES_IN 는 15m · 1h · 14d/);
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '14d' }),
).not.toThrow();
});
it('빈 문자열은 통과한다 (compose 가 미정의 변수를 빈 값으로 주입한다)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
@@ -39,6 +52,15 @@ describe('validateEnv', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('JWT_SECRET 이 없거나 짧으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'JWT_SECRET'))).toThrow(
/JWT_SECRET 가 설정되지 않았습니다/,
);
expect(() => validateEnv(prod({ JWT_SECRET: 'short-secret' }))).toThrow(
/JWT_SECRET 는 32자 이상/,
);
});
it('필수 DB 설정이 없으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
/DB_PASSWORD 가 설정되지 않았습니다/,
@@ -7,6 +7,8 @@
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { parseDurationMs } from '@/shared/utils/duration';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
@@ -16,6 +18,17 @@ const REQUIRED_IN_PRODUCTION = [
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
// 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts)
'JWT_SECRET',
] as const;
/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */
const JWT_SECRET_MIN_LENGTH = 32;
/** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */
const DURATION_KEYS = [
'JWT_ACCESS_EXPIRES_IN',
'JWT_REFRESH_EXPIRES_IN',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
@@ -95,6 +108,16 @@ export function validateEnv(
}
}
// 기간 형식 — 틀리면 토큰 만료가 조용히 기본값이 되므로 기동 시점에 막는다
for (const key of DURATION_KEYS) {
const value = readString(config, key);
if (value !== undefined && parseDurationMs(value) === null) {
errors.push(
`${key} 는 15m · 1h · 14d 같은 기간 형식이어야 합니다. (현재 값: "${value}")`,
);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -113,6 +136,13 @@ export function validateEnv(
}
}
const jwtSecret = readString(config, 'JWT_SECRET');
if (jwtSecret !== undefined && jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
errors.push(
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (운영 환경)`,
);
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
@@ -0,0 +1,55 @@
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { snakeCase } from '@/shared/utils/case';
/**
* 엔티티 속성(camelCase)을 DB 컬럼(snake_case)으로 바꾸는 이름 규칙.
* Postgres 는 대문자가 섞인 이름을 따옴표로 감싸야 해서, 컬럼은 snake_case 로 통일한다.
* 응답(JSON)은 엔티티 속성 이름 그대로 camelCase 다 (.claude/rules/api-design.md).
*/
export class SnakeNamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface
{
override tableName(className: string, customName?: string): string {
return customName ?? snakeCase(className);
}
override columnName(
propertyName: string,
customName: string | undefined,
embeddedPrefixes: string[],
): string {
const prefix = embeddedPrefixes.map((p) => `${snakeCase(p)}_`).join('');
return prefix + (customName ?? snakeCase(propertyName));
}
override relationName(propertyName: string): string {
return snakeCase(propertyName);
}
override joinColumnName(
relationName: string,
referencedColumnName: string,
): string {
return snakeCase(`${relationName}_${referencedColumnName}`);
}
override joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
): string {
return snakeCase(
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
);
}
override joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return snakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { snakeCase } from './case';
describe('snakeCase', () => {
it('camelCase 를 snake_case 로 바꾼다', () => {
expect(snakeCase('photoFileId')).toBe('photo_file_id');
expect(snakeCase('createdAt')).toBe('created_at');
});
it('PascalCase 와 연속 대문자도 단어 단위로 나눈다', () => {
expect(snakeCase('RefreshToken')).toBe('refresh_token');
expect(snakeCase('HTTPServer')).toBe('http_server');
});
it('이미 snake_case 면 그대로 둔다', () => {
expect(snakeCase('user_id')).toBe('user_id');
});
it('점·하이픈은 밑줄로 바꾼다 (임베디드 접두어)', () => {
expect(snakeCase('archive.from')).toBe('archive_from');
});
});
+13
View File
@@ -0,0 +1,13 @@
// 문자열 표기 변환 — 부수효과 없는 순수 함수
/**
* camelCase · PascalCase 를 snake_case 로 바꾼다.
* DB 컬럼·테이블 이름에 쓴다 (예: `photoFileId` → `photo_file_id`, `HTTPServer` → `http_server`).
*/
export function snakeCase(value: string): string {
return value
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1_$2')
.replace(/[\s.-]+/g, '_')
.toLowerCase();
}
+52
View File
@@ -0,0 +1,52 @@
import { decodeCursor, encodeCursor, keysetPage } from './cursor';
describe('커서 인코딩', () => {
it('인코딩한 값을 그대로 되돌린다', () => {
const cursor = encodeCursor(['2026-10-11T00:00:00.000Z', 3, 'a1b2']);
expect(decodeCursor(cursor, 3)).toEqual([
'2026-10-11T00:00:00.000Z',
3,
'a1b2',
]);
});
it('커서는 URL 에 그대로 넣을 수 있는 문자만 쓴다', () => {
expect(encodeCursor(['한글 값', '?&='])).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('키 개수가 다르면 null 이다', () => {
expect(decodeCursor(encodeCursor(['a']), 2)).toBeNull();
});
it('해석할 수 없는 값이면 null 이다', () => {
expect(decodeCursor('not-a-cursor', 1)).toBeNull();
expect(decodeCursor(encodeCursor([]), 0)).toEqual([]);
expect(
decodeCursor(Buffer.from('{"a":1}').toString('base64url'), 1),
).toBeNull();
expect(
decodeCursor(Buffer.from('[{"a":1}]').toString('base64url'), 1),
).toBeNull();
});
});
describe('keysetPage', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
it('limit 보다 하나 더 읽혔으면 다음 페이지가 있다', () => {
const page = keysetPage(rows, 2, (row) => [row.id]);
expect(page.items).toEqual([{ id: 'a' }, { id: 'b' }]);
expect(page.hasNext).toBe(true);
expect(decodeCursor(page.nextCursor ?? '', 1)).toEqual(['b']);
});
it('마지막 페이지면 nextCursor 가 null 이다', () => {
const page = keysetPage(rows, 3, (row) => [row.id]);
expect(page.items).toHaveLength(3);
expect(page.hasNext).toBe(false);
expect(page.nextCursor).toBeNull();
});
});
+55
View File
@@ -0,0 +1,55 @@
// 커서 페이지네이션 — 부수효과 없는 순수 함수
/** 커서에 담는 정렬 키 하나 (예: 생성 시각 ISO 문자열, 정렬 순서, id) */
export type CursorValue = string | number;
/**
* 정렬 키 목록을 불투명한 커서 문자열로 만든다.
* 클라이언트는 값을 해석하지 않고 다음 요청에 그대로 돌려준다.
*/
export function encodeCursor(values: readonly CursorValue[]): string {
return Buffer.from(JSON.stringify(values), 'utf8').toString('base64url');
}
/**
* 커서를 정렬 키 목록으로 되돌린다.
* 형식이 틀렸거나 키 개수가 [arity] 와 다르면 null — 호출한 쪽이 VAL_001 로 응답한다.
*/
export function decodeCursor(
cursor: string,
arity: number,
): CursorValue[] | null {
try {
const parsed: unknown = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
);
if (!Array.isArray(parsed) || parsed.length !== arity) return null;
const values: CursorValue[] = [];
for (const value of parsed as unknown[]) {
if (typeof value !== 'string' && typeof value !== 'number') return null;
values.push(value);
}
return values;
} catch {
return null;
}
}
/** [limit] + 1 개를 읽어 온 결과를 한 페이지와 다음 커서로 나눈다 */
export function keysetPage<T>(
rows: readonly T[],
limit: number,
keyOf: (row: T) => readonly CursorValue[],
): { items: T[]; hasNext: boolean; nextCursor: string | null } {
const hasNext = rows.length > limit;
const items = rows.slice(0, limit);
const last = items.at(-1);
return {
items,
hasNext,
nextCursor:
hasNext && last !== undefined ? encodeCursor(keyOf(last)) : null,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { QueryFailedError } from 'typeorm';
/** Postgres 오류 코드 — https://www.postgresql.org/docs/current/errcodes-appendix.html */
const PG_UNIQUE_VIOLATION = '23505';
const PG_FOREIGN_KEY_VIOLATION = '23503';
function pgCodeOf(error: unknown): string | null {
if (!(error instanceof QueryFailedError)) return null;
const driverError: unknown = error.driverError;
if (
typeof driverError === 'object' &&
driverError !== null &&
'code' in driverError &&
typeof driverError.code === 'string'
) {
return driverError.code;
}
return null;
}
/** 유니크 제약 위반 — 같은 값이 동시에 들어온 경우 */
export function isUniqueViolation(error: unknown): boolean {
return pgCodeOf(error) === PG_UNIQUE_VIOLATION;
}
/** 외래 키 위반 — 아직 다른 행이 참조하고 있는 행을 지우려 한 경우 등 */
export function isForeignKeyViolation(error: unknown): boolean {
return pgCodeOf(error) === PG_FOREIGN_KEY_VIOLATION;
}
+24
View File
@@ -0,0 +1,24 @@
import { parseDurationMs } from './duration';
describe('parseDurationMs', () => {
it('단위별로 밀리초를 계산한다', () => {
expect(parseDurationMs('500ms')).toBe(500);
expect(parseDurationMs('30s')).toBe(30_000);
expect(parseDurationMs('15m')).toBe(900_000);
expect(parseDurationMs('1h')).toBe(3_600_000);
expect(parseDurationMs('14d')).toBe(1_209_600_000);
});
it('앞뒤 공백은 무시한다', () => {
expect(parseDurationMs(' 1h ')).toBe(3_600_000);
});
it('형식이 틀리거나 0 이면 null 이다', () => {
expect(parseDurationMs(undefined)).toBeNull();
expect(parseDurationMs('')).toBeNull();
expect(parseDurationMs('1 hour')).toBeNull();
expect(parseDurationMs('h')).toBeNull();
expect(parseDurationMs('0d')).toBeNull();
expect(parseDurationMs('-1h')).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
// 기간 문자열 파싱 — 부수효과 없는 순수 함수
const UNIT_TO_MS: Readonly<Record<string, number>> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const DURATION_PATTERN = /^(\d+)(ms|s|m|h|d)$/;
/**
* `15m` · `1h` · `14d` 같은 기간 문자열을 밀리초로 바꾼다.
* 형식이 틀리거나 0 이하면 null 을 돌려준다 (호출한 쪽이 기본값·검증 실패를 정한다).
*/
export function parseDurationMs(value: string | undefined): number | null {
if (value === undefined) return null;
const match = DURATION_PATTERN.exec(value.trim());
if (!match) return null;
const amount = Number(match[1]);
if (amount <= 0) return null;
return amount * UNIT_TO_MS[match[2]];
}
+11
View File
@@ -0,0 +1,11 @@
import { createHash, randomBytes } from 'crypto';
/** SHA-256 해시 (hex) — 토큰처럼 원문을 저장하면 안 되는 값을 찾을 때 쓴다 */
export function sha256Hex(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex');
}
/** 추측할 수 없는 무작위 문자열 (base64url) */
export function randomToken(bytes: number): string {
return randomBytes(bytes).toString('base64url');
}