diff --git a/backend/.env.development.example b/backend/.env.development.example index 2f36cf9..e368433 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example index 71068cd..fb460d4 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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= diff --git a/backend/package-lock.json b/backend/package-lock.json index f62dd97..7e2ca00 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index ccc5784..e38eaaf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d84c929..033990a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 { diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..3f8497e --- /dev/null +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -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(); + if (!request.user) throw new BusinessException('AUTH_001'); + return request.user; + }, +); diff --git a/backend/src/common/decorators/public.decorator.ts b/backend/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..dc741bf --- /dev/null +++ b/backend/src/common/decorators/public.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +/** + * 로그인 없이 부를 수 있는 엔드포인트에 붙인다. + * 전역 JwtAuthGuard 가 기본으로 모든 라우트를 보호하므로, 예외만 이 데코레이터로 연다 + * (로그인·토큰 갱신·헬스체크 등). 빠뜨려서 열리는 일은 없고, 빠뜨리면 닫힌다. + */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/common/guards/jwt-auth.guard.ts b/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..b8fdccf --- /dev/null +++ b/backend/src/common/guards/jwt-auth.guard.ts @@ -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(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } + + override handleRequest(err: unknown, user: TUser | false): TUser { + if (err || !user) { + // passport 가 던진 예외(영문 메시지)를 그대로 내보내지 않고 에러 코드로 바꾼다 + throw new BusinessException('AUTH_001'); + } + return user; + } +} diff --git a/backend/src/migrations/1791705189542-AddAuthUsersFiles.ts b/backend/src/migrations/1791705189542-AddAuthUsersFiles.ts new file mode 100644 index 0000000..44d6a1b --- /dev/null +++ b/backend/src/migrations/1791705189542-AddAuthUsersFiles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAuthUsersFiles1791705189542 implements MigrationInterface { + name = 'AddAuthUsersFiles1791705189542'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/auth/auth.config.spec.ts b/backend/src/modules/auth/auth.config.spec.ts new file mode 100644 index 0000000..8bb765e --- /dev/null +++ b/backend/src/modules/auth/auth.config.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/modules/auth/auth.config.ts b/backend/src/modules/auth/auth.config.ts new file mode 100644 index 0000000..110c953 --- /dev/null +++ b/backend/src/modules/auth/auth.config.ts @@ -0,0 +1,83 @@ +import { parseDurationMs } from '@/shared/utils/duration'; + +/** + * 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값). + * 값은 모듈을 불러올 때가 아니라 쓸 때 읽는다 (ConfigModule 이 env 파일을 읽은 뒤여야 한다). + * 형식 검증은 shared/config/env.validation.ts 가 기동 시점에 한다. + */ + +type Env = Readonly>; + +/** 액세스 토큰 기본 유효 시간 — 짧게 둔다 (.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'; diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..0982c73 --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -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 { + return this.authService.socialLogin(dto); + } + + @Post('dev-sessions') + @ApiOperation({ + summary: '개발용 로그인 (운영에서는 열리지 않음)', + description: + '소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 써서 가입·로그인한다.', + }) + @ApiStandardResponse(AuthResultDto, { + status: 201, + description: '로그인 성공', + }) + @ApiErrorResponse('RES_001', '운영 환경 — 엔드포인트 없음') + devLogin(@Body() dto: DevLoginDto): Promise { + return this.authService.devLogin(dto); + } + + @Post('tokens') + @ApiOperation({ + summary: '토큰 갱신', + description: + '리프레시 토큰은 한 번만 쓸 수 있다. 새 한 쌍으로 바꿔 보관한다.', + }) + @ApiStandardResponse(AuthTokensDto, { status: 201, description: '새 토큰' }) + @ApiErrorResponse('AUTH_001', '만료·폐기·없는 토큰') + refresh(@Body() dto: RefreshTokenDto): Promise { + 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 { + await this.authService.logout(dto.refreshToken); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..b1434b3 --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..fd038c0 --- /dev/null +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -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 { + 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): Promise { + 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) => 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, + ); + 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]]; + 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 }), + ); + }); +}); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..a22c2dd --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -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, + ) {} + + /** 소셜 로그인 — 제공자에게 토큰을 확인하고, 처음이면 가입시킨다 */ + async socialLogin(dto: SocialLoginDto): Promise { + 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 { + 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 { + 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 { + await this.refreshTokens.update( + { tokenHash: sha256Hex(refreshToken), revokedAt: IsNull() }, + { revokedAt: new Date() }, + ); + } + + private async signIn(account: VerifiedSocialAccount): Promise { + 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 { + 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 { + await this.refreshTokens.update( + { userId, revokedAt: IsNull() }, + { revokedAt: new Date() }, + ); + } +} diff --git a/backend/src/modules/auth/dto/auth-result.dto.ts b/backend/src/modules/auth/dto/auth-result.dto.ts new file mode 100644 index 0000000..e97681c --- /dev/null +++ b/backend/src/modules/auth/dto/auth-result.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..3b64c4a --- /dev/null +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/entities/refresh-token.entity.ts b/backend/src/modules/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..c62644c --- /dev/null +++ b/backend/src/modules/auth/entities/refresh-token.entity.ts @@ -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; +} diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..e44df6f --- /dev/null +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -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 { + 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 }; + } +} diff --git a/backend/src/modules/auth/refresh-token.cleanup.ts b/backend/src/modules/auth/refresh-token.cleanup.ts new file mode 100644 index 0000000..02cffaf --- /dev/null +++ b/backend/src/modules/auth/refresh-token.cleanup.ts @@ -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, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async removeExpired(): Promise { + const result = await this.refreshTokens.delete({ + expiresAt: LessThan(new Date()), + }); + if (result.affected) { + this.logger.log(`만료된 리프레시 토큰 ${result.affected}개 정리`); + } + } +} diff --git a/backend/src/modules/auth/social/id-token.verifier.ts b/backend/src/modules/auth/social/id-token.verifier.ts new file mode 100644 index 0000000..d1bab80 --- /dev/null +++ b/backend/src/modules/auth/social/id-token.verifier.ts @@ -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; + 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(); + + constructor(private readonly jwt: JwtService) {} + + async verify( + token: string, + rules: IdTokenRules, + ): Promise> { + 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>(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 { + 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> { + const body = await fetchProviderJson(jwksUrl); + const list = pick(body, 'keys'); + const keys = new Map(); + + 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; + } +} diff --git a/backend/src/modules/auth/social/social-http.ts b/backend/src/modules/auth/social/social-http.ts new file mode 100644 index 0000000..9fc2580 --- /dev/null +++ b/backend/src/modules/auth/social/social-http.ts @@ -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 } = {}, +): Promise { + 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)[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; +} diff --git a/backend/src/modules/auth/social/social-verifier.service.spec.ts b/backend/src/modules/auth/social/social-verifier.service.spec.ts new file mode 100644 index 0000000..e4db76b --- /dev/null +++ b/backend/src/modules/auth/social/social-verifier.service.spec.ts @@ -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, +): 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): Promise { + 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(); + }); +}); diff --git a/backend/src/modules/auth/social/social-verifier.service.ts b/backend/src/modules/auth/social/social-verifier.service.ts new file mode 100644 index 0000000..41d6d5b --- /dev/null +++ b/backend/src/modules/auth/social/social-verifier.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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}` }, + }); +} diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index 03c2e29..7003463 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -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') diff --git a/backend/src/modules/upload/dto/upload-response.dto.ts b/backend/src/modules/upload/dto/upload-response.dto.ts index 1c0a1f2..3cacf30 100644 --- a/backend/src/modules/upload/dto/upload-response.dto.ts +++ b/backend/src/modules/upload/dto/upload-response.dto.ts @@ -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, + }; + } } diff --git a/backend/src/modules/upload/entities/stored-file.entity.ts b/backend/src/modules/upload/entities/stored-file.entity.ts new file mode 100644 index 0000000..c79a9f7 --- /dev/null +++ b/backend/src/modules/upload/entities/stored-file.entity.ts @@ -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; +} diff --git a/backend/src/modules/upload/upload.config.spec.ts b/backend/src/modules/upload/upload.config.spec.ts index 768f47f..23a45dc 100644 --- a/backend/src/modules/upload/upload.config.spec.ts +++ b/backend/src/modules/upload/upload.config.spec.ts @@ -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('