generated from ttipo/project-base
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:
@@ -9,7 +9,27 @@
|
||||
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
|
||||
# ==========================================
|
||||
|
||||
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
# JWT 서명 키 — 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. 운영은 32자 이상 필수.
|
||||
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
# (개발에서 비워 두면 개발용 고정 키로 서명한다 — modules/auth/auth.config.ts)
|
||||
JWT_SECRET=change-me-generate-a-long-random-string
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# ==========================================
|
||||
# 소셜 로그인 (앱이 SDK 로 받은 토큰을 서버가 제공자에게 확인한다)
|
||||
#
|
||||
# 비어 있는 제공자는 로그인을 거절한다(AUTH_001). 값은 각 개발자 콘솔에서 확인한다.
|
||||
# ==========================================
|
||||
# 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다
|
||||
KAKAO_APP_ID=
|
||||
# 네이버 클라이언트 ID — 설정해야 네이버 로그인이 켜진다
|
||||
NAVER_CLIENT_ID=
|
||||
# 구글 OAuth 클라이언트 ID (iOS·Android 등 여러 개면 콤마로 구분) — id_token 의 aud
|
||||
GOOGLE_CLIENT_IDS=
|
||||
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
|
||||
APPLE_CLIENT_IDS=
|
||||
|
||||
# 개발용 로그인(POST /api/auth/dev-sessions) — 소셜 앱 키 없이 기기 키로 가입한다.
|
||||
# 운영에서는 이 값과 무관하게 항상 꺼진다. 개발에서도 끄려면 false.
|
||||
AUTH_DEV_LOGIN=true
|
||||
|
||||
@@ -9,7 +9,23 @@
|
||||
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
|
||||
# ==========================================
|
||||
|
||||
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
# JWT 서명 키 — 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. 운영은 32자 이상 필수.
|
||||
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
# (개발에서 비워 두면 개발용 고정 키로 서명한다 — modules/auth/auth.config.ts)
|
||||
JWT_SECRET=change-me-generate-a-long-random-string
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# ==========================================
|
||||
# 소셜 로그인 (앱이 SDK 로 받은 토큰을 서버가 제공자에게 확인한다)
|
||||
#
|
||||
# 비어 있는 제공자는 로그인을 거절한다(AUTH_001). 값은 각 개발자 콘솔에서 확인한다.
|
||||
# ==========================================
|
||||
# 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다
|
||||
KAKAO_APP_ID=
|
||||
# 네이버 클라이언트 ID — 설정해야 네이버 로그인이 켜진다
|
||||
NAVER_CLIENT_ID=
|
||||
# 구글 OAuth 클라이언트 ID (iOS·Android 등 여러 개면 콤마로 구분) — id_token 의 aud
|
||||
GOOGLE_CLIENT_IDS=
|
||||
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
|
||||
APPLE_CLIENT_IDS=
|
||||
|
||||
Generated
+229
-120
@@ -13,6 +13,8 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
@@ -24,6 +26,8 @@
|
||||
"class-validator": "^0.14.4",
|
||||
"helmet": "^8.1.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -42,6 +46,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -177,24 +182,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -202,22 +189,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -283,24 +254,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -308,22 +261,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -2951,6 +2888,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/jwt": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
||||
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"jsonwebtoken": "9.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
||||
@@ -2971,6 +2921,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express": {
|
||||
"version": "11.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.5.tgz",
|
||||
@@ -3092,24 +3052,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -3117,22 +3059,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -3534,7 +3460,7 @@
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
|
||||
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -3578,7 +3504,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3595,7 +3520,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3612,7 +3536,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3629,7 +3552,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3646,7 +3568,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3663,7 +3584,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3680,7 +3600,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3697,7 +3616,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3714,7 +3632,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3731,7 +3648,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3748,7 +3664,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3765,7 +3680,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3779,14 +3693,14 @@
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
|
||||
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3"
|
||||
@@ -4040,6 +3954,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||
@@ -4053,6 +3977,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
|
||||
@@ -4067,12 +3997,43 @@
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -5938,6 +5899,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -6823,6 +6790,15 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -9573,6 +9549,49 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
@@ -9690,6 +9709,42 @@
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
@@ -9704,6 +9759,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||
@@ -10525,6 +10586,42 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/passport": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
"utils-merge": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -10601,6 +10698,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pause": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -11294,7 +11396,6 @@
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -12923,7 +13024,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
@@ -13043,6 +13143,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
@@ -43,6 +45,8 @@
|
||||
"class-validator": "^0.14.4",
|
||||
"helmet": "^8.1.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -61,6 +65,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -34,8 +34,13 @@ interface ContractBody {
|
||||
|
||||
const body = (res: Response): ContractBody => res.body as ContractBody;
|
||||
|
||||
/** 매 실행마다 새 회원으로 시작한다 */
|
||||
const E2E_DEVICE_KEY = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
describe('응답 계약 (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let accessToken = '';
|
||||
const auth = () => ({ Authorization: `Bearer ${accessToken}` });
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
@@ -58,42 +63,46 @@ describe('응답 계약 (e2e)', () => {
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
|
||||
await app.init();
|
||||
|
||||
// 개발용 로그인으로 테스트 회원을 만든다 (NODE_ENV=test 에서는 열려 있다)
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/auth/dev-sessions')
|
||||
.send({ provider: 'kakao', deviceKey: E2E_DEVICE_KEY })
|
||||
.expect(201);
|
||||
accessToken = (login.body as { data: { accessToken: string } }).data
|
||||
.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// 테스트 회원과 기록을 지운다 (같은 DB 를 개발에 함께 쓰는 경우를 위해)
|
||||
await request(app.getHttpServer()).delete('/api/users/me').set(auth());
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('성공 응답은 { success, data } 로 감싸진다', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/users')
|
||||
.get('/api/users/me')
|
||||
.set(auth())
|
||||
.expect(200);
|
||||
|
||||
expect(body(res).success).toBe(true);
|
||||
expect(body(res).data).toHaveProperty('items');
|
||||
expect(body(res).data).toHaveProperty('pageInfo');
|
||||
expect(body(res).data).toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => {
|
||||
it('로그인하지 않으면 AUTH_001 과 traceId 를 반환한다', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/users')
|
||||
.expect(200);
|
||||
.get('/api/users/me')
|
||||
.expect(401);
|
||||
|
||||
const pageInfo = body(res).data?.pageInfo;
|
||||
|
||||
expect(Object.keys(pageInfo ?? {}).sort()).toEqual([
|
||||
'hasNext',
|
||||
'nextCursor',
|
||||
'total',
|
||||
]);
|
||||
expect(typeof pageInfo?.hasNext).toBe('boolean');
|
||||
expect(pageInfo?.nextCursor).toBeNull();
|
||||
expect(typeof pageInfo?.total).toBe('number');
|
||||
expect(body(res).success).toBe(false);
|
||||
expect(body(res).error?.code).toBe('AUTH_001');
|
||||
expect(typeof body(res).error?.traceId).toBe('string');
|
||||
});
|
||||
|
||||
it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/users/999999')
|
||||
.get('/api/files/00000000-0000-4000-8000-000000000000')
|
||||
.set(auth())
|
||||
.expect(404);
|
||||
|
||||
expect(body(res).success).toBe(false);
|
||||
@@ -103,23 +112,36 @@ describe('응답 계약 (e2e)', () => {
|
||||
|
||||
it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/users')
|
||||
.send({ email: '잘못된형식', password: '1' })
|
||||
.post('/api/auth/dev-sessions')
|
||||
.send({ provider: '없는제공자', deviceKey: '짧음' })
|
||||
.expect(400);
|
||||
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
// class-validator 의 개별 메시지가 그대로 새어나가면 안 된다
|
||||
expect(JSON.stringify(body(res))).not.toContain('must be an email');
|
||||
expect(JSON.stringify(body(res))).not.toContain(
|
||||
'지원하지 않는 로그인 방식',
|
||||
);
|
||||
});
|
||||
|
||||
it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/users?정의되지않은파라미터=1')
|
||||
.patch('/api/users/me')
|
||||
.set(auth())
|
||||
.send({ nickname: '집사', 정의되지않은필드: 1 })
|
||||
.expect(400);
|
||||
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('204 응답은 본문 없이 내려간다 (@SkipTransform)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/auth/token-revocation')
|
||||
.send({ refreshToken: 'already-gone-token' })
|
||||
.expect(204);
|
||||
|
||||
expect(res.text).toBe('');
|
||||
});
|
||||
|
||||
it('헬스체크는 표준 래퍼 없이 terminus 원본 형식을 반환한다', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/health');
|
||||
|
||||
@@ -129,7 +151,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
|
||||
it('모든 응답에 x-request-id 헤더가 붙는다', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/users');
|
||||
const res = await request(app.getHttpServer()).get('/api/health');
|
||||
|
||||
expect(typeof res.headers['x-request-id']).toBe('string');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user