From 93f4da6c8787fd6385679eba97211f99eb6a7435 Mon Sep 17 00:00:00 2001 From: Seojin <1020seojin@naver.com> Date: Thu, 28 May 2026 00:47:14 +0900 Subject: [PATCH 1/6] =?UTF-8?q?mission:=209=EC=A3=BC=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=EC=8A=B5=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 293 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 8 ++ src/auth.config.ts | 108 +++++++++++++++++ src/index.ts | 23 ++++ 4 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 src/auth.config.ts diff --git a/package-lock.json b/package-lock.json index b962d43..7cab1f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,11 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "swagger-autogen": "^2.23.7", "swagger-ui-express": "^5.0.1", "tsoa": "^7.0.0-alpha.0" @@ -31,8 +35,12 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^6.1.1", "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^25.6.2", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", + "@types/passport-jwt": "^4.0.1", "@types/swagger-ui-express": "^4.1.8", "nodemon": "^3.1.14", "prisma": "^7.8.0", @@ -1817,6 +1825,17 @@ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -1858,6 +1877,13 @@ "@types/node": "*" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/multer": { "version": "1.4.13", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", @@ -1876,6 +1902,72 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "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-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, + "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.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -2062,6 +2154,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -2164,6 +2265,12 @@ "node": ">=8" } }, + "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/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2659,6 +2766,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", @@ -3483,12 +3599,97 @@ "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/libphonenumber-js": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.2.tgz", "integrity": "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==", "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.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/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -3829,6 +4030,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3902,6 +4109,74 @@ "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-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "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-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "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-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3953,6 +4228,11 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -4111,9 +4391,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4307,7 +4587,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4895,6 +5174,12 @@ "node": ">=0.8.0" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 5af6317..c8ed47f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,11 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "swagger-autogen": "^2.23.7", "swagger-ui-express": "^5.0.1", "tsoa": "^7.0.0-alpha.0" @@ -34,8 +38,12 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^6.1.1", "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^25.6.2", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", + "@types/passport-jwt": "^4.0.1", "@types/swagger-ui-express": "^4.1.8", "nodemon": "^3.1.14", "prisma": "^7.8.0", diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 0000000..8a35114 --- /dev/null +++ b/src/auth.config.ts @@ -0,0 +1,108 @@ +import dotenv from "dotenv"; +import { Strategy as GoogleStrategy, Profile } from "passport-google-oauth20"; +import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; +import jwt from "jsonwebtoken"; +import { prisma } from "./db.config.js"; // Prisma 설정 파일 경로 확인 필요 + +dotenv.config(); + +// 1. JWT 토큰 생성 함수 (타입 지정) +export const generateAccessToken = (user: { + userId: number; + email: string; +}) => { + return jwt.sign( + { + userId: user.userId, + email: user.email, + }, + process.env.JWT_SECRET!, + { expiresIn: "1h" } + ); +}; + +export const generateRefreshToken = (user: { + userId: number; +}) => { + return jwt.sign( + { + userId: user.userId, + }, + process.env.JWT_SECRET!, + { expiresIn: "14d" } + ); +}; + +// 2. Google Verify 로직 +const googleVerify = async (profile: Profile) => { + const email = profile.emails?.[0]?.value; + if (!email) throw new Error("Google 프로필에 이메일이 없습니다."); + + let user = await prisma.user.findFirst({ where: { email } }); + + if (!user) { + user = await prisma.user.create({ + data: { + email, + password: "GOOGLE_LOGIN_USER", + name: profile.displayName, + + gender: "추후 수정", + birth: new Date("1970-01-01"), + + phoneNumber: "추후 수정", + + address: "추후 수정", + city: "추후 수정", + district: "추후 수정", + neighborhood: "추후 수정", + detail: "추후 수정", + }, + }); + } + + return { + userId: user.userId, + email: user.email, + name: user.name, + role: user.role, + }; +}; + +// 3. Google Strategy +export const googleStrategy = new GoogleStrategy( + { + clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID!, + clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET!, + callbackURL: "/oauth2/callback/google", + scope: ["email", "profile"], + }, + async (_accessToken, _refreshToken, profile, cb) => { + try { + const user = await googleVerify(profile); + const tokens = { + accessToken: generateAccessToken(user), + refreshToken: generateRefreshToken(user), + }; + return cb(null, tokens); + } catch (err) { + return cb(err as Error); + } + } +); + +// JWT 검증 미들웨어 +export const jwtStrategy = new JwtStrategy( + { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET!, + }, + async (payload, done) => { + try { + const user = await prisma.user.findFirst({ where: { userId: payload.id } }); + return user ? done(null, user) : done(null, false); + } catch (err) { + return done(err, false); + } + } +); diff --git a/src/index.ts b/src/index.ts index cd0c6ed..2b9b71a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,10 +7,16 @@ import path from "path"; import fs from "fs"; import { RegisterRoutes } from "./generated/routes.js"; +import passport from "passport"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import { ApiResponse } from "./common/responses/api.response.js"; // 환경 변수 설정 dotenv.config(); +passport.use(googleStrategy); +passport.use(jwtStrategy); + const app: Express = express(); const port = process.env.PORT || 3000; @@ -19,6 +25,7 @@ app.use(cors()); // cors 방식 허용 app.use(express.static("public")); // 정적 파일 접근 app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함(JSON 형태의 요청 body를 파싱하기 위함) app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 +app.use(passport.initialize()); // Swagger 설정 const swaggerFile = JSON.parse( @@ -39,6 +46,22 @@ const router = express.Router(); RegisterRoutes(router); app.use("/api/v1", router); +const isLogin = passport.authenticate("jwt", { + session: false, +}); + +app.get("/mypage", isLogin, (req, res) => { + return res.status(200).json( + ApiResponse.success( + 200, + `인증 성공! ${(req.user as any).name}님의 마이페이지입니다.`, + { + user: req.user, + } + ) + ); +}); + // 서버 시작 app.listen(port, () => { console.log(`[server]: Server is running at :${port}`); From 73dbebdea794c93c609491b0c2e7708df9657df9 Mon Sep 17 00:00:00 2001 From: Seojin <1020seojin@naver.com> Date: Thu, 28 May 2026 01:27:28 +0900 Subject: [PATCH 2/6] =?UTF-8?q?mission:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B0=B1=EC=8B=A0=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 6 +- src/generated/routes.ts | 51 +++++++++++- .../users/controllers/user.controller.ts | 30 ++++++- src/modules/users/dtos/user.request.dto.ts | 79 ++++++++++++++++++- src/modules/users/dtos/user.response.dto.ts | 2 +- .../users/repositories/user.repository.ts | 32 ++++++-- src/modules/users/services/user.service.ts | 66 +++++++++++++++- 7 files changed, 247 insertions(+), 19 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 682c2ac..52df773 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,12 +31,12 @@ model User { userId Int @id @default(autoincrement()) email String @unique password String - phoneNumber String + phoneNumber String? role String @default("USER") name String - gender String - birth DateTime + gender String? + birth DateTime? address String? city String? diff --git a/src/generated/routes.ts b/src/generated/routes.ts index e23e8fd..02b55a4 100644 --- a/src/generated/routes.ts +++ b/src/generated/routes.ts @@ -32,7 +32,7 @@ const models: TsoaRoute.Models = { "role": {"dataType":"string","required":true}, "name": {"dataType":"string","required":true}, "gender": {"dataType":"string","required":true}, - "birth": {"dataType":"datetime","required":true}, + "birth": {"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true}, "address": {"dataType":"string","required":true}, "city": {"dataType":"string","required":true}, "district": {"dataType":"string","required":true}, @@ -77,7 +77,7 @@ const models: TsoaRoute.Models = { "password": {"dataType":"string","required":true}, "name": {"dataType":"string","required":true}, "gender": {"dataType":"string","required":true}, - "birth": {"dataType":"string","required":true}, + "birth": {"dataType":"string"}, "address": {"dataType":"string"}, "city": {"dataType":"string"}, "district": {"dataType":"string"}, @@ -89,6 +89,22 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "UpdateMyInfoRequest": { + "dataType": "refObject", + "properties": { + "gender": {"dataType":"string"}, + "birth": {"dataType":"string"}, + "address": {"dataType":"string"}, + "city": {"dataType":"string"}, + "district": {"dataType":"string"}, + "neighborhood": {"dataType":"string"}, + "detail": {"dataType":"string"}, + "phoneNumber": {"dataType":"string"}, + "preferences": {"dataType":"array","array":{"dataType":"refAlias","ref":"FoodType"}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "ChallengeMissionResponse": { "dataType": "refObject", "properties": { @@ -325,6 +341,37 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsUserController_handleUpdateMyInfo: Record = { + userId: {"in":"path","name":"userId","required":true,"dataType":"double"}, + body: {"in":"body","name":"body","required":true,"ref":"UpdateMyInfoRequest"}, + }; + app.put('/users/:userId', + ...(fetchMiddlewares(UserController)), + ...(fetchMiddlewares(UserController.prototype.handleUpdateMyInfo)), + + async function UserController_handleUpdateMyInfo(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsUserController_handleUpdateMyInfo, request, response }); + + const controller = new UserController(); + + await templateService.apiHandler({ + methodName: 'handleUpdateMyInfo', + controller, + response, + next, + validatedArgs, + successStatus: 200, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsUserMissionController_challengeMission: Record = { missionId: {"in":"path","name":"missionId","required":true,"dataType":"double"}, body: {"in":"body","name":"body","required":true,"ref":"ChallengeMissionRequest"}, diff --git a/src/modules/users/controllers/user.controller.ts b/src/modules/users/controllers/user.controller.ts index 2ba2aa4..a996c5b 100644 --- a/src/modules/users/controllers/user.controller.ts +++ b/src/modules/users/controllers/user.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Post, Route, Tags, SuccessResponse, Response } from "tsoa"; +import { Body, Controller, Post, Route, Tags, SuccessResponse, Response, Put, Path } from "tsoa"; import { StatusCodes } from "http-status-codes"; -import { UserSignUpRequest } from "../dtos/user.request.dto.js"; -import { userSignUp } from "../services/user.service.js"; +import { UpdateMyInfoRequest, UserSignUpRequest } from "../dtos/user.request.dto.js"; +import { updateMyInfo, userSignUp } from "../services/user.service.js"; import { ApiResponse } from "../../../common/responses/api.response.js"; import { UserSignUpResponse } from "../dtos/user.response.dto.js"; @@ -48,4 +48,28 @@ export class UserController extends Controller { user, ); } + + /** + * 내 정보 수정 API + */ + @SuccessResponse(StatusCodes.OK, "회원 정보 수정 성공") + @Response>(400, "잘못된 요청") + @Response>(404, "유저 없음") + @Response>(500, "서버 내부 오류") + @Put("{userId}") + public async handleUpdateMyInfo( + @Path() userId: number, + @Body() body: UpdateMyInfoRequest, + ): Promise> { + + const user = await updateMyInfo(userId, body); + + this.setStatus(StatusCodes.OK); + + return ApiResponse.success( + StatusCodes.OK, + "회원 정보 수정 성공", + user, + ); + } } \ No newline at end of file diff --git a/src/modules/users/dtos/user.request.dto.ts b/src/modules/users/dtos/user.request.dto.ts index 8bc4cec..1c5d66b 100644 --- a/src/modules/users/dtos/user.request.dto.ts +++ b/src/modules/users/dtos/user.request.dto.ts @@ -35,8 +35,9 @@ export class UserSignUpRequest { * 사용자 생년월일 * @example "2000-01-01" */ + @IsOptional() @IsDateString() - birth!: string; + birth?: string; /** * 주소 @@ -93,4 +94,80 @@ export class UserSignUpRequest { @ArrayNotEmpty() @IsEnum(FoodType, { each: true }) preferences!: FoodType[]; +} + +export class UpdateMyInfoRequest { + + /** + * 사용자 성별 + * @example "male" + */ + @IsOptional() + @IsString() + gender?: string; + + /** + * 사용자 생년월일 + * @example "2000-01-01" + */ + @IsOptional() + @IsDateString() + birth?: string; + + /** + * 주소 + * @example "서울특별시 강남구" + */ + @IsOptional() + @IsString() + address?: string; + + /** + * 시/도 + * @example "서울특별시" + */ + @IsOptional() + @IsString() + city?: string; + + /** + * 구 + * @example "강남구" + */ + @IsOptional() + @IsString() + district?: string; + + /** + * 동 + * @example "역삼동" + */ + @IsOptional() + @IsString() + neighborhood?: string; + + /** + * 상세 주소 + * @example "101동 202호" + */ + @IsOptional() + @IsString() + detail?: string; + + /** + * 전화번호 + * @example "01012345678" + */ + @IsOptional() + @IsString() + phoneNumber?: string; + + /** + * 선호 음식 카테고리 목록 + * @example ["한식", "일식"] + */ + @IsOptional() + @IsArray() + @IsEnum(FoodType, { each: true }) + preferences?: FoodType[]; } \ No newline at end of file diff --git a/src/modules/users/dtos/user.response.dto.ts b/src/modules/users/dtos/user.response.dto.ts index 782c3b5..717f25d 100644 --- a/src/modules/users/dtos/user.response.dto.ts +++ b/src/modules/users/dtos/user.response.dto.ts @@ -29,7 +29,7 @@ export interface UserSignUpResponse { /** * 생년월일 */ - birth: Date; + birth: Date | null; /** * 주소 diff --git a/src/modules/users/repositories/user.repository.ts b/src/modules/users/repositories/user.repository.ts index dba1ccd..1f31d3c 100644 --- a/src/modules/users/repositories/user.repository.ts +++ b/src/modules/users/repositories/user.repository.ts @@ -5,11 +5,11 @@ interface AddUserParams { email: string; password: string; name: string; - gender: string; - birth: Date; - address: string; - detailAddress: string; - phoneNumber: string; + gender?: string; + birth?: Date; + address?: string; + detailAddress?: string; + phoneNumber?: string; } // 이메일로 사용자 조회 @@ -63,4 +63,26 @@ export const getUserPreferencesByUserId = async ( where: { userId }, orderBy: { foodType: "asc" }, }); +}; + +interface UpdateUserParams { + gender?: string; + birth?: Date; + address?: string; + city?: string; + district?: string; + neighborhood?: string; + detail?: string; + phoneNumber?: string; +} + +// 사용자 정보 수정 +export const updateUser = async ( + userId: number, + data: UpdateUserParams, +) => { + return await prisma.user.update({ + where: { userId }, + data, + }); }; \ No newline at end of file diff --git a/src/modules/users/services/user.service.ts b/src/modules/users/services/user.service.ts index 0407671..59ba5aa 100644 --- a/src/modules/users/services/user.service.ts +++ b/src/modules/users/services/user.service.ts @@ -7,9 +7,10 @@ import { getUserByEmail, getUserPreferencesByUserId, setPreference, + updateUser, } from "../repositories/user.repository.js"; -import { UserSignUpRequest } from "../dtos/user.request.dto.js"; +import { UpdateMyInfoRequest, UserSignUpRequest } from "../dtos/user.request.dto.js"; import { UserSignUpResponse } from "../dtos/user.response.dto.js"; export const userSignUp = async ( @@ -37,7 +38,9 @@ export const userSignUp = async ( password: hashedPassword, name: data.name, gender: data.gender, - birth: new Date(data.birth), + birth: data.birth + ? new Date(data.birth) + : undefined, address: data.address ?? "", detailAddress: data.detail ?? "", phoneNumber: data.phoneNumber, @@ -67,7 +70,7 @@ export const userSignUp = async ( userId: user.userId, role: user.role, name: user.name, - gender: user.gender, + gender: user.gender ?? "", birth: user.birth, address: user.address ?? "", @@ -76,10 +79,65 @@ export const userSignUp = async ( neighborhood: user.neighborhood ?? "", detail: user.detail ?? "", - phoneNumber: user.phoneNumber, + phoneNumber: user.phoneNumber ?? "", point: user.point, createdAt: user.createdAt, preferences: preferences.map((p) => p.foodType), }; +}; + +// 정보 수정 +export const updateMyInfo = async ( + userId: number, + data: UpdateMyInfoRequest, +): Promise => { + + await updateUser(userId, { + gender: data.gender, + + birth: data.birth + ? new Date(data.birth) + : undefined, + + address: data.address, + city: data.city, + district: data.district, + neighborhood: data.neighborhood, + detail: data.detail, + phoneNumber: data.phoneNumber, + }); + + const user = await getUser(userId); + + if (!user) { + throw new CustomError(404, "유저 없음"); + } + + const preferences = + await getUserPreferencesByUserId(userId); + + return { + userId: user.userId, + role: user.role, + name: user.name, + + gender: user.gender ?? "", + birth: user.birth, + + address: user.address ?? "", + city: user.city ?? "", + district: user.district ?? "", + neighborhood: user.neighborhood ?? "", + detail: user.detail ?? "", + + phoneNumber: user.phoneNumber ?? "", + + point: user.point, + createdAt: user.createdAt, + + preferences: preferences.map( + (p) => p.foodType, + ), + }; }; \ No newline at end of file From bb6a25ea70c9353cee4f1b49b1b356ca7e0da1f8 Mon Sep 17 00:00:00 2001 From: Seojin <1020seojin@naver.com> Date: Thu, 28 May 2026 01:55:13 +0900 Subject: [PATCH 3/6] =?UTF-8?q?mission:=20isLogin=20=EB=AF=B8=EB=93=A4?= =?UTF-8?q?=EC=9B=A8=EC=96=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/authHandler.ts | 28 +++++ src/common/middlewares/isLogin.middleware.ts | 40 +++++++ src/generated/routes.ts | 103 +++++++++++++++--- .../controllers/mission.controller.ts | 17 +-- .../reviews/controllers/review.controller.ts | 33 +++--- .../stores/controllers/store.controller.ts | 3 +- .../controllers/userMission.controller.ts | 90 +++++---------- .../users/controllers/user.controller.ts | 42 +++---- tsoa.json | 5 + 9 files changed, 232 insertions(+), 129 deletions(-) create mode 100644 src/auth/authHandler.ts create mode 100644 src/common/middlewares/isLogin.middleware.ts diff --git a/src/auth/authHandler.ts b/src/auth/authHandler.ts new file mode 100644 index 0000000..d9d20fb --- /dev/null +++ b/src/auth/authHandler.ts @@ -0,0 +1,28 @@ +import jwt from "jsonwebtoken"; +import { CustomError } from "../common/errors/custom.error.js"; + +export const authHandler = (request: any) => { + const authHeader = request.headers.authorization; + + if (!authHeader) { + throw new CustomError(401, "토큰 없음"); + } + + const token = authHeader.split(" ")[1]; + + if (!token) { + throw new CustomError(401, "토큰 형식 오류"); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { + userId: number; + }; + + return { + userId: decoded.userId, + }; + } catch { + throw new CustomError(401, "토큰 검증 실패"); + } +}; \ No newline at end of file diff --git a/src/common/middlewares/isLogin.middleware.ts b/src/common/middlewares/isLogin.middleware.ts new file mode 100644 index 0000000..50a2de0 --- /dev/null +++ b/src/common/middlewares/isLogin.middleware.ts @@ -0,0 +1,40 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { CustomError } from "../errors/custom.error.js"; + +interface JwtPayload { + userId: number; +} + +export const isLogin = ( + req: Request, + _res: Response, + next: NextFunction, +) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new CustomError(401, "토큰 없음"); + } + + const token = authHeader.split(" ")[1]; + + if (!token) { + throw new CustomError(401, "토큰 형식 오류"); + } + + const decoded = jwt.verify( + token, + process.env.JWT_SECRET!, + ) as JwtPayload; + + (req as any).user = { + userId: decoded.userId, + }; + + next(); + } catch (err) { + next(err); + } +}; \ No newline at end of file diff --git a/src/generated/routes.ts b/src/generated/routes.ts index 02b55a4..585c288 100644 --- a/src/generated/routes.ts +++ b/src/generated/routes.ts @@ -130,14 +130,6 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "ChallengeMissionRequest": { - "dataType": "refObject", - "properties": { - "userId": {"dataType":"double","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "ApiResponse_ChallengeMissionResponse-Array_": { "dataType": "refObject", "properties": { @@ -342,10 +334,11 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsUserController_handleUpdateMyInfo: Record = { - userId: {"in":"path","name":"userId","required":true,"dataType":"double"}, + req: {"in":"request","name":"req","required":true,"dataType":"object"}, body: {"in":"body","name":"body","required":true,"ref":"UpdateMyInfoRequest"}, }; - app.put('/users/:userId', + app.put('/users/me', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(UserController)), ...(fetchMiddlewares(UserController.prototype.handleUpdateMyInfo)), @@ -373,10 +366,11 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsUserMissionController_challengeMission: Record = { + req: {"in":"request","name":"req","required":true,"dataType":"object"}, missionId: {"in":"path","name":"missionId","required":true,"dataType":"double"}, - body: {"in":"body","name":"body","required":true,"ref":"ChallengeMissionRequest"}, }; app.post('/user-missions/:missionId', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(UserMissionController)), ...(fetchMiddlewares(UserMissionController.prototype.challengeMission)), @@ -404,9 +398,10 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsUserMissionController_getReceivedMissions: Record = { - userId: {"in":"path","name":"userId","required":true,"dataType":"double"}, + req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; - app.get('/user-missions/:userId', + app.get('/user-missions/me', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(UserMissionController)), ...(fetchMiddlewares(UserMissionController.prototype.getReceivedMissions)), @@ -434,10 +429,11 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsUserMissionController_completeUserMission: Record = { + req: {"in":"request","name":"req","required":true,"dataType":"object"}, userMissionId: {"in":"path","name":"userMissionId","required":true,"dataType":"double"}, - body: {"in":"body","name":"body","required":true,"ref":"ChallengeMissionRequest"}, }; app.patch('/user-missions/:userMissionId', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(UserMissionController)), ...(fetchMiddlewares(UserMissionController.prototype.completeUserMission)), @@ -468,6 +464,7 @@ export function RegisterRoutes(app: Router) { body: {"in":"body","name":"body","required":true,"ref":"CreateStoreRequest"}, }; app.post('/stores', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(StoreController)), ...(fetchMiddlewares(StoreController.prototype.createStore)), @@ -495,9 +492,11 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsReviewController_createReview: Record = { + req: {"in":"request","name":"req","required":true,"dataType":"object"}, body: {"in":"body","name":"body","required":true,"ref":"CreateReviewRequest"}, }; app.post('/reviews', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(ReviewController)), ...(fetchMiddlewares(ReviewController.prototype.createReview)), @@ -525,9 +524,10 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsReviewController_getMyReviews: Record = { - userId: {"in":"path","name":"userId","required":true,"dataType":"double"}, + req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; - app.get('/reviews/:userId', + app.get('/reviews/me', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(ReviewController)), ...(fetchMiddlewares(ReviewController.prototype.getMyReviews)), @@ -559,6 +559,7 @@ export function RegisterRoutes(app: Router) { body: {"in":"body","name":"body","required":true,"ref":"CreateMissionRequest"}, }; app.post('/missions/:storeId', + authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(MissionController)), ...(fetchMiddlewares(MissionController.prototype.createMission)), @@ -619,6 +620,76 @@ export function RegisterRoutes(app: Router) { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + function authenticateMiddleware(security: TsoaRoute.Security[] = []) { + return async function runAuthenticationMiddleware(request: any, response: any, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + // keep track of failed auth attempts so we can hand back the most + // recent one. This behavior was previously existing so preserving it + // here + const failedAttempts: any[] = []; + const pushAndRethrow = (error: any) => { + failedAttempts.push(error); + throw error; + }; + + const secMethodOrPromises: Promise[] = []; + for (const secMethod of security) { + if (Object.keys(secMethod).length > 1) { + const secMethodAndPromises: Promise[] = []; + + for (const name in secMethod) { + secMethodAndPromises.push( + expressAuthenticationRecasted(request, name, secMethod[name], response) + .catch(pushAndRethrow) + ); + } + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + secMethodOrPromises.push(Promise.all(secMethodAndPromises) + .then(users => { return users[0]; })); + } else { + for (const name in secMethod) { + secMethodOrPromises.push( + expressAuthenticationRecasted(request, name, secMethod[name], response) + .catch(pushAndRethrow) + ); + } + } + } + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + try { + request['user'] = await Promise.any(secMethodOrPromises); + + // Response was sent in middleware, abort + if (response.writableEnded) { + return; + } + + next(); + } + catch(err) { + // Show most recent error as response + const error = failedAttempts.pop(); + error.status = error.status || 401; + + // Response was sent in middleware, abort + if (response.writableEnded) { + return; + } + next(error); + } + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + } + } + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa } diff --git a/src/modules/missions/controllers/mission.controller.ts b/src/modules/missions/controllers/mission.controller.ts index 22dbc08..701ac03 100644 --- a/src/modules/missions/controllers/mission.controller.ts +++ b/src/modules/missions/controllers/mission.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Path, Post, Route, Tags, SuccessResponse, Response, Get } from "tsoa"; +import { Body, Controller, Path, Post, Route, Tags, SuccessResponse, Response, Get, Security } from "tsoa"; import { createMissionService, @@ -18,20 +18,21 @@ export class MissionController extends Controller { * * 특정 가게에 새로운 미션을 생성합니다. */ + @Security("jwt") @SuccessResponse( - 201, + 201, "미션 생성 성공", ) @Response>( - 400, + 400, "잘못된 요청", ) @Response>( - 404, + 404, "가게를 찾을 수 없음", ) @Response>( - 500, + 500, "서버 내부 오류", ) @Post("{storeId}") @@ -65,15 +66,15 @@ export class MissionController extends Controller { * 특정 가게의 미션 목록을 조회합니다. */ @SuccessResponse( - 200, + 200, "가게 미션 조회 성공", ) @Response>( - 404, + 404, "가게를 찾을 수 없음", ) @Response>( - 500, + 500, "서버 내부 오류", ) @Get("{storeId}") diff --git a/src/modules/reviews/controllers/review.controller.ts b/src/modules/reviews/controllers/review.controller.ts index f9e6f0f..7eb85f4 100644 --- a/src/modules/reviews/controllers/review.controller.ts +++ b/src/modules/reviews/controllers/review.controller.ts @@ -1,5 +1,4 @@ -import { Body, Controller, Path, Post, Route, Tags, SuccessResponse, Response, Get } from "tsoa"; -import { StatusCodes } from "http-status-codes"; +import { Body, Controller, Post, Route, Tags, SuccessResponse, Response, Get, Security, Request } from "tsoa"; import { createReviewService, @@ -19,8 +18,9 @@ export class ReviewController extends Controller { * * 사용자가 수행한 미션에 대한 리뷰를 작성합니다. */ + @Security("jwt") @SuccessResponse( - StatusCodes.CREATED, + 201, "리뷰 작성 성공", ) @Response>( @@ -41,12 +41,18 @@ export class ReviewController extends Controller { ) @Post() public async createReview( + @Request() req: any, @Body() body: CreateReviewRequest, ): Promise> { - const result = await createReviewService(body); + const userId = req.user.userId; - this.setStatus(StatusCodes.CREATED); + const result = await createReviewService({ + ...body, + userId, + }); + + this.setStatus(201); return ApiResponse.success( 201, @@ -60,8 +66,9 @@ export class ReviewController extends Controller { * * 특정 사용자가 작성한 리뷰 목록을 조회합니다. */ + @Security("jwt") @SuccessResponse( - StatusCodes.OK, + 200, "내 리뷰 조회 성공", ) @Response>( @@ -72,20 +79,16 @@ export class ReviewController extends Controller { 500, "서버 내부 오류", ) - @Get("{userId}") + @Get("me") public async getMyReviews( - - /** - * 사용자 ID - * @example 1 - */ - @Path() userId: number, - + @Request() req: any, ): Promise> { + const userId = req.user.userId; + const result = await getMyReviewsService(userId); - this.setStatus(StatusCodes.OK); + this.setStatus(200); return ApiResponse.success( 200, diff --git a/src/modules/stores/controllers/store.controller.ts b/src/modules/stores/controllers/store.controller.ts index 0e5ebc3..6b31d75 100644 --- a/src/modules/stores/controllers/store.controller.ts +++ b/src/modules/stores/controllers/store.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, Route, Tags, SuccessResponse, Response } from "tsoa"; +import { Body, Controller, Post, Route, Tags, SuccessResponse, Response, Security } from "tsoa"; import { StatusCodes } from "http-status-codes"; import { createStoreService } from "../services/store.service.js"; @@ -16,6 +16,7 @@ export class StoreController extends Controller { * * 새로운 가게 정보를 등록합니다. */ + @Security("jwt") @SuccessResponse( StatusCodes.CREATED, "가게 생성 성공", diff --git a/src/modules/userMissions/controllers/userMission.controller.ts b/src/modules/userMissions/controllers/userMission.controller.ts index 84099f5..673909a 100644 --- a/src/modules/userMissions/controllers/userMission.controller.ts +++ b/src/modules/userMissions/controllers/userMission.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Path, Post, Route, Tags, SuccessResponse, Response, Get, Patch } from "tsoa"; +import { Controller, Path, Post, Route, Tags, SuccessResponse, Response, Get, Patch, Security, Request } from "tsoa"; import { challengeMissionService, @@ -7,7 +7,6 @@ import { } from "../services/userMission.service.js"; import { ApiResponse } from "../../../common/responses/api.response.js"; -import { ChallengeMissionRequest } from "../dtos/userMission.request.dto.js"; import { ChallengeMissionResponse } from "../dtos/userMission.response.dto.js"; @Route("user-missions") @@ -19,39 +18,28 @@ export class UserMissionController extends Controller { * * 사용자가 특정 미션에 도전합니다. */ + @Security("jwt") @SuccessResponse(201, "미션 도전 성공") - @Response>( - 400, - "잘못된 요청", - ) - @Response>( - 404, - "유저 또는 미션 없음", - ) - @Response>( - 409, - "이미 도전한 미션", - ) - @Response>( - 500, - "서버 내부 오류", - ) + @Response>(400, "잘못된 요청") + @Response>(404, "유저 또는 미션 없음") + @Response>(409, "이미 도전한 미션") + @Response>(500, "서버 내부 오류") @Post("{missionId}") public async challengeMission( + @Request() req: any, /** * 미션 ID * @example 1 */ @Path() missionId: number, - - @Body() body: ChallengeMissionRequest, - ): Promise> { + const userId = req.user.userId; + const result = await challengeMissionService( missionId, - body.userId, + userId, ); this.setStatus(201); @@ -68,31 +56,18 @@ export class UserMissionController extends Controller { * * 사용자가 진행중인 미션 목록을 조회합니다. */ + @Security("jwt") @SuccessResponse(200, "진행중 미션 조회 성공") - @Response>( - 404, - "유저 없음", - ) - @Response>( - 500, - "서버 내부 오류", - ) - @Get("{userId}") + @Response>(404, "유저 없음") + @Response>(500, "서버 내부 오류") + @Get("me") public async getReceivedMissions( - - /** - * 사용자 ID - * @example 1 - */ - @Path() userId: number, - + @Request() req: any, ): Promise> { - const result = await getReceivedMissionsService( - userId, - ); + const userId = req.user.userId; - this.setStatus(200); + const result = await getReceivedMissionsService(userId); return ApiResponse.success( 200, @@ -106,43 +81,30 @@ export class UserMissionController extends Controller { * * 사용자가 진행중인 미션을 완료 처리합니다. */ + @Security("jwt") @SuccessResponse(200, "미션 완료 처리 성공") - @Response>( - 400, - "잘못된 요청", - ) - @Response>( - 404, - "유저 미션 없음", - ) - @Response>( - 409, - "이미 완료된 미션", - ) - @Response>( - 500, - "서버 내부 오류", - ) + @Response>(400, "잘못된 요청") + @Response>(404, "유저 미션 없음") + @Response>(409, "이미 완료된 미션") + @Response>(500, "서버 내부 오류") @Patch("{userMissionId}") public async completeUserMission( + @Request() req: any, /** * 사용자 미션 ID * @example 1 */ @Path() userMissionId: number, - - @Body() body: ChallengeMissionRequest, - ): Promise> { + const userId = req.user.userId; + const result = await completeUserMissionService( userMissionId, - body.userId, + userId, ); - this.setStatus(200); - return ApiResponse.success( 200, "미션 완료 처리 성공", diff --git a/src/modules/users/controllers/user.controller.ts b/src/modules/users/controllers/user.controller.ts index a996c5b..a593118 100644 --- a/src/modules/users/controllers/user.controller.ts +++ b/src/modules/users/controllers/user.controller.ts @@ -1,5 +1,4 @@ -import { Body, Controller, Post, Route, Tags, SuccessResponse, Response, Put, Path } from "tsoa"; -import { StatusCodes } from "http-status-codes"; +import { Body, Controller, Post, Route, Tags, SuccessResponse, Response, Put, Request, Security } from "tsoa"; import { UpdateMyInfoRequest, UserSignUpRequest } from "../dtos/user.request.dto.js"; import { updateMyInfo, userSignUp } from "../services/user.service.js"; @@ -13,37 +12,27 @@ export class UserController extends Controller { /** * 회원가입 API - * + * * 사용자를 생성하고 선호 카테고리를 저장합니다. */ @SuccessResponse( - StatusCodes.CREATED, + 201, "회원가입 성공", ) - @Response>( - 400, - "잘못된 요청", - ) - @Response>( - 409, - "이미 존재하는 이메일", - ) - @Response>( - 500, - "서버 내부 오류", - ) + @Response>(400, "잘못된 요청") + @Response>(409, "이미 존재하는 이메일") + @Response>(500, "서버 내부 오류") @Post("signup") - public async handleUserSignUp( @Body() body: UserSignUpRequest, ): Promise> { const user = await userSignUp(body); - this.setStatus(StatusCodes.CREATED); + this.setStatus(201); return ApiResponse.success( - StatusCodes.CREATED, + 201, "회원가입 성공", user, ); @@ -51,23 +40,26 @@ export class UserController extends Controller { /** * 내 정보 수정 API + * + * 사용자의 정보를 수정합니다. */ - @SuccessResponse(StatusCodes.OK, "회원 정보 수정 성공") + @Security("jwt") + @SuccessResponse(200, "회원 정보 수정 성공") @Response>(400, "잘못된 요청") @Response>(404, "유저 없음") @Response>(500, "서버 내부 오류") - @Put("{userId}") + @Put("me") public async handleUpdateMyInfo( - @Path() userId: number, + @Request() req: any, @Body() body: UpdateMyInfoRequest, ): Promise> { - const user = await updateMyInfo(userId, body); + const userId = req.user.userId; - this.setStatus(StatusCodes.OK); + const user = await updateMyInfo(userId, body); return ApiResponse.success( - StatusCodes.OK, + 200, "회원 정보 수정 성공", user, ); diff --git a/tsoa.json b/tsoa.json index 7942794..81355ac 100644 --- a/tsoa.json +++ b/tsoa.json @@ -8,5 +8,10 @@ }, "routes": { "routesDir": "src/generated" + }, + "auth": { + "jwt": { + "authHeader": "authorization" + } } } From 749860113a0ad041898e1edc1866113314208e22 Mon Sep 17 00:00:00 2001 From: Seojin <1020seojin@naver.com> Date: Thu, 28 May 2026 02:36:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?mission:=20Github=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 25 +++++++++++++++++ package.json | 2 ++ src/auth.config.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 3 +- 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7cab1f6..901de78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", "passport": "^0.7.0", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "swagger-autogen": "^2.23.7", @@ -39,6 +40,7 @@ "@types/morgan": "^1.9.10", "@types/node": "^25.6.2", "@types/passport": "^1.0.17", + "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/swagger-ui-express": "^4.1.8", @@ -1922,6 +1924,18 @@ "@types/express": "*" } }, + "node_modules/@types/passport-github2": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@types/passport-github2/-/passport-github2-1.2.9.tgz", + "integrity": "sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-google-oauth20": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", @@ -4127,6 +4141,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", diff --git a/package.json b/package.json index c8ed47f..2595473 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", "passport": "^0.7.0", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "swagger-autogen": "^2.23.7", @@ -42,6 +43,7 @@ "@types/morgan": "^1.9.10", "@types/node": "^25.6.2", "@types/passport": "^1.0.17", + "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/swagger-ui-express": "^4.1.8", diff --git a/src/auth.config.ts b/src/auth.config.ts index 8a35114..e3504ce 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -1,6 +1,12 @@ import dotenv from "dotenv"; -import { Strategy as GoogleStrategy, Profile } from "passport-google-oauth20"; +import { + Strategy as GoogleStrategy, + Profile, +} from "passport-google-oauth20"; + +import { Strategy as GitHubStrategy } from "passport-github2"; import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; + import jwt from "jsonwebtoken"; import { prisma } from "./db.config.js"; // Prisma 설정 파일 경로 확인 필요 @@ -91,6 +97,68 @@ export const googleStrategy = new GoogleStrategy( } ); +// Github Verify 로직 +const githubVerify = async (profile: any) => { + const email = + profile.emails?.[0]?.value || + `${profile.username}@github.com`; + + let user = await prisma.user.findFirst({ + where: { email }, + }); + + if (!user) { + user = await prisma.user.create({ + data: { + email, + password: "GITHUB_LOGIN_USER", + name: profile.displayName || profile.username, + + gender: "추후 수정", + birth: new Date("1970-01-01"), + phoneNumber: "추후 수정", + + address: "추후 수정", + city: "추후 수정", + district: "추후 수정", + neighborhood: "추후 수정", + detail: "추후 수정", + }, + }); + } + + return { + userId: user.userId, + email: user.email, + name: user.name, + role: user.role, + }; +}; + +// Github Strategy +export const githubStrategy = new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + callbackURL: "/oauth2/callback/github", + scope: ["user:email"], + }, + async (_accessToken: string, _refreshToken: string, profile: any, cb: any) => { + try { + const user = await githubVerify(profile); + + const tokens = { + accessToken: generateAccessToken(user), + refreshToken: generateRefreshToken(user), + }; + + return cb(null, tokens); + } catch (err) { + return cb(err as Error); + } + }, +); + // JWT 검증 미들웨어 export const jwtStrategy = new JwtStrategy( { diff --git a/src/index.ts b/src/index.ts index 2b9b71a..ee7eabe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,13 +8,14 @@ import fs from "fs"; import { RegisterRoutes } from "./generated/routes.js"; import passport from "passport"; -import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import { googleStrategy, githubStrategy, jwtStrategy } from "./auth.config.js"; import { ApiResponse } from "./common/responses/api.response.js"; // 환경 변수 설정 dotenv.config(); passport.use(googleStrategy); +passport.use(githubStrategy); passport.use(jwtStrategy); const app: Express = express(); From abc46ce393538c494a7ae9e4748d9ac4525fe892 Mon Sep 17 00:00:00 2001 From: Seojin <1020seojin@naver.com> Date: Thu, 28 May 2026 03:05:08 +0900 Subject: [PATCH 5/6] =?UTF-8?q?mission:=20email-pw=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 25 +++++++ package.json | 2 + prisma/schema.prisma | 2 + src/auth.config.ts | 31 +++++++++ src/auth/auth.controller.ts | 69 +++++++++++++++++++ src/index.ts | 3 +- .../users/repositories/user.repository.ts | 4 +- src/modules/users/services/user.service.ts | 1 + 8 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/auth/auth.controller.ts diff --git a/package-lock.json b/package-lock.json index 901de78..848aaf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "swagger-autogen": "^2.23.7", "swagger-ui-express": "^5.0.1", "tsoa": "^7.0.0-alpha.0" @@ -43,6 +44,7 @@ "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", "@types/swagger-ui-express": "^4.1.8", "nodemon": "^3.1.14", "prisma": "^7.8.0", @@ -1959,6 +1961,18 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, "node_modules/@types/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -4174,6 +4188,17 @@ "passport-strategy": "^1.0.0" } }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", diff --git a/package.json b/package.json index 2595473..d46de65 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "swagger-autogen": "^2.23.7", "swagger-ui-express": "^5.0.1", "tsoa": "^7.0.0-alpha.0" @@ -46,6 +47,7 @@ "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", "@types/swagger-ui-express": "^4.1.8", "nodemon": "^3.1.14", "prisma": "^7.8.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52df773..3b6bfcb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,8 @@ model User { password String phoneNumber String? + provider String @default("local") + role String @default("USER") name String gender String? diff --git a/src/auth.config.ts b/src/auth.config.ts index e3504ce..898aa87 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -6,6 +6,8 @@ import { import { Strategy as GitHubStrategy } from "passport-github2"; import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; +import { Strategy as LocalStrategy } from "passport-local"; +import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { prisma } from "./db.config.js"; // Prisma 설정 파일 경로 확인 필요 @@ -174,3 +176,32 @@ export const jwtStrategy = new JwtStrategy( } } ); + +// 로컬 strategy +export const localStrategy = new LocalStrategy( + { + usernameField: "email", + passwordField: "password", + }, + async (email, password, done) => { + try { + const user = await prisma.user.findFirst({ + where: { email }, + }); + + if (!user) { + return done(null, false, { message: "존재하지 않는 유저" }); + } + + const isMatch = await bcrypt.compare(password, user.password); + + if (!isMatch) { + return done(null, false, { message: "비밀번호 불일치" }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + }, +); \ No newline at end of file diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..033ae2a --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Post, Route, SuccessResponse, Tags, Response } from "tsoa"; +import { UserSignUpRequest } from "../modules/users/dtos/user.request.dto.js"; +import { ApiResponse } from "../common/responses/api.response.js"; +import { userSignUp } from "../modules/users/services/user.service.js"; +import passport from "passport"; +import jwt from "jsonwebtoken"; + +@Route("auth") +@Tags("Auth") +export class AuthController extends Controller { + + /** + * 회원가입 API + */ + @SuccessResponse(201, "회원가입 성공") + @Response>(400, "잘못된 요청") + @Response>(409, "이미 존재하는 이메일") + @Post("signup") + public async signup( + @Body() body: UserSignUpRequest, + ): Promise> { + + const result = await userSignUp(body); + + this.setStatus(201); + + return ApiResponse.success( + 201, + "회원가입 성공", + result, + ); + } + + /** + * 이메일 + 비밀번호 로그인 + */ + @Post("login") + public async login( + @Body() body: { email: string; password: string }, + ): Promise { + + return new Promise((resolve, reject) => { + passport.authenticate("local", (err: any, user: any) => { + + if (err || !user) { + return reject(new Error("로그인 실패")); + } + + const token = jwt.sign( + { userId: user.userId }, + process.env.JWT_SECRET!, + { expiresIn: "1h" }, + ); + + resolve( + ApiResponse.success( + 200, + "로그인 성공", + { + token, + user, + }, + ), + ); + + })({ body } as any); + }); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index ee7eabe..bb1df09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import fs from "fs"; import { RegisterRoutes } from "./generated/routes.js"; import passport from "passport"; -import { googleStrategy, githubStrategy, jwtStrategy } from "./auth.config.js"; +import { googleStrategy, githubStrategy, jwtStrategy, localStrategy } from "./auth.config.js"; import { ApiResponse } from "./common/responses/api.response.js"; // 환경 변수 설정 @@ -17,6 +17,7 @@ dotenv.config(); passport.use(googleStrategy); passport.use(githubStrategy); passport.use(jwtStrategy); +passport.use(localStrategy); const app: Express = express(); const port = process.env.PORT || 3000; diff --git a/src/modules/users/repositories/user.repository.ts b/src/modules/users/repositories/user.repository.ts index 1f31d3c..2924850 100644 --- a/src/modules/users/repositories/user.repository.ts +++ b/src/modules/users/repositories/user.repository.ts @@ -10,6 +10,7 @@ interface AddUserParams { address?: string; detailAddress?: string; phoneNumber?: string; + provider?: string; } // 이메일로 사용자 조회 @@ -31,6 +32,7 @@ export const addUser = async (data: AddUserParams) => { address: data.address, detail: data.detailAddress, phoneNumber: data.phoneNumber, + provider: data.provider ?? "local", }, }); }; @@ -65,6 +67,7 @@ export const getUserPreferencesByUserId = async ( }); }; +// 사용자 정보 수정 interface UpdateUserParams { gender?: string; birth?: Date; @@ -76,7 +79,6 @@ interface UpdateUserParams { phoneNumber?: string; } -// 사용자 정보 수정 export const updateUser = async ( userId: number, data: UpdateUserParams, diff --git a/src/modules/users/services/user.service.ts b/src/modules/users/services/user.service.ts index 59ba5aa..534d468 100644 --- a/src/modules/users/services/user.service.ts +++ b/src/modules/users/services/user.service.ts @@ -44,6 +44,7 @@ export const userSignUp = async ( address: data.address ?? "", detailAddress: data.detail ?? "", phoneNumber: data.phoneNumber, + provider: "local", }); // 2. 선호 저장 From 332c93af59cb0085e59c0525dcc2b84cb4925577 Mon Sep 17 00:00:00 2001 From: Seojin <1020seojin@naver.com> Date: Thu, 28 May 2026 03:29:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?mission:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 2 +- src/auth.config.ts | 118 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 97 insertions(+), 23 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b6bfcb..e00601d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,7 +33,7 @@ model User { password String phoneNumber String? - provider String @default("local") + provider String role String @default("USER") name String diff --git a/src/auth.config.ts b/src/auth.config.ts index 898aa87..3d4cbb3 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -11,6 +11,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { prisma } from "./db.config.js"; // Prisma 설정 파일 경로 확인 필요 +import { CustomError } from "./common/errors/custom.error.js"; dotenv.config(); @@ -44,13 +45,33 @@ export const generateRefreshToken = (user: { // 2. Google Verify 로직 const googleVerify = async (profile: Profile) => { const email = profile.emails?.[0]?.value; - if (!email) throw new Error("Google 프로필에 이메일이 없습니다."); + + if (!email) { + throw new CustomError( + 400, + "Google 프로필에 이메일이 없습니다.", + "GOOGLE_EMAIL_REQUIRED", + ); + } let user = await prisma.user.findFirst({ where: { email } }); - if (!user) { + if ( + user && + user.provider !== "google" + ) { + + throw new CustomError( + 403, + `${user.provider} 로그인만 가능합니다.`, + "INVALID_LOGIN_PROVIDER", + ); + } + + if (!user) { user = await prisma.user.create({ data: { + provider: "google", email, password: "GOOGLE_LOGIN_USER", name: profile.displayName, @@ -82,7 +103,7 @@ export const googleStrategy = new GoogleStrategy( { clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID!, clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET!, - callbackURL: "/oauth2/callback/google", + callbackURL: "auth/google/callback", scope: ["email", "profile"], }, async (_accessToken, _refreshToken, profile, cb) => { @@ -105,13 +126,24 @@ const githubVerify = async (profile: any) => { profile.emails?.[0]?.value || `${profile.username}@github.com`; - let user = await prisma.user.findFirst({ - where: { email }, - }); + let user = await prisma.user.findFirst({where: { email }}); + + if ( + user && + user.provider !== "github" + ) { + + throw new CustomError( + 403, + `${user.provider} 로그인만 가능합니다.`, + "INVALID_LOGIN_PROVIDER", + ); + } if (!user) { user = await prisma.user.create({ data: { + provider: "github", email, password: "GITHUB_LOGIN_USER", name: profile.displayName || profile.username, @@ -142,7 +174,7 @@ export const githubStrategy = new GitHubStrategy( { clientID: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, - callbackURL: "/oauth2/callback/github", + callbackURL: "auth/github/callback", scope: ["user:email"], }, async (_accessToken: string, _refreshToken: string, profile: any, cb: any) => { @@ -177,7 +209,53 @@ export const jwtStrategy = new JwtStrategy( } ); -// 로컬 strategy +// Local Verify 로직 +const localVerify = async ( + email: string, + password: string +) => { + const user = await prisma.user.findFirst({ + where: { email }, + }); + + if (!user) { + throw new CustomError( + 404, + "존재하지 않는 유저입니다.", + "USER_NOT_FOUND", + ); + } + + if (user.provider !== "local") { + throw new CustomError( + 403, + `${user.provider} 로그인만 가능합니다.`, + "INVALID_LOGIN_PROVIDER", + ); + } + + const isMatch = await bcrypt.compare( + password, + user.password + ); + + if (!isMatch) { + throw new CustomError( + 401, + "비밀번호가 일치하지 않습니다.", + "INVALID_PASSWORD", + ); + } + + return { + userId: user.userId, + email: user.email, + name: user.name, + role: user.role, + }; +}; + +// local strategy export const localStrategy = new LocalStrategy( { usernameField: "email", @@ -185,23 +263,19 @@ export const localStrategy = new LocalStrategy( }, async (email, password, done) => { try { - const user = await prisma.user.findFirst({ - where: { email }, - }); - - if (!user) { - return done(null, false, { message: "존재하지 않는 유저" }); - } - - const isMatch = await bcrypt.compare(password, user.password); + const user = await localVerify( + email, + password + ); - if (!isMatch) { - return done(null, false, { message: "비밀번호 불일치" }); - } + const tokens = { + accessToken: generateAccessToken(user), + refreshToken: generateRefreshToken(user), + }; - return done(null, user); + return done(null, tokens); } catch (err) { - return done(err); + return done(err as Error); } }, ); \ No newline at end of file