diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 0de2d10..31d3f41 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import AuthService from "../services/auth.service"; +import AuthService, { assertAllowedRedirectUri } from "../services/auth.service"; import { AppError } from "../../errors/AppError"; import { CompleteSignupDto } from "../dtos/complete-signup.dto"; import { validate } from "class-validator"; @@ -318,7 +318,7 @@ class AuthController { async kakaoToken(req: Request, res: Response) { try { - const { code } = req.body; + const { code, redirect_uri } = req.body; if (!code) { return res.status(400).json({ @@ -328,7 +328,8 @@ class AuthController { }); } - const result = await AuthService.exchangeKakaoToken(code); + const validatedRedirectUri = assertAllowedRedirectUri(redirect_uri); + const result = await AuthService.exchangeKakaoToken(code, validatedRedirectUri); res.status(200).json({ message: "카카오 로그인이 완료되었습니다.", @@ -354,7 +355,7 @@ class AuthController { async googleToken(req: Request, res: Response) { try { - const { code } = req.body; + const { code, redirect_uri } = req.body; if (!code) { return res.status(400).json({ @@ -364,7 +365,8 @@ class AuthController { }); } - const result = await AuthService.exchangeGoogleToken(code); + const validatedRedirectUri = assertAllowedRedirectUri(redirect_uri); + const result = await AuthService.exchangeGoogleToken(code, validatedRedirectUri); res.status(200).json({ message: "구글 로그인이 완료되었습니다.", @@ -390,7 +392,7 @@ class AuthController { async naverToken(req: Request, res: Response) { try { - const { code } = req.body; + const { code, redirect_uri } = req.body; if (!code) { return res.status(400).json({ @@ -400,7 +402,8 @@ class AuthController { }); } - const result = await AuthService.exchangeNaverToken(code); + const validatedRedirectUri = assertAllowedRedirectUri(redirect_uri); + const result = await AuthService.exchangeNaverToken(code, validatedRedirectUri); res.status(200).json({ message: "네이버 로그인이 완료되었습니다.", diff --git a/src/auth/routes/auth.route.ts b/src/auth/routes/auth.route.ts index 096ff65..b225cd5 100644 --- a/src/auth/routes/auth.route.ts +++ b/src/auth/routes/auth.route.ts @@ -290,11 +290,16 @@ router.post("/complete-signup", AuthController.completeSignup); * type: object * required: * - code + * - redirect_uri * properties: * code: * type: string * description: 카카오 OAuth 인증코드 * example: "abc123def456ghi789" + * redirect_uri: + * type: string + * description: OAuth 인증 시작 시 사용한 redirect_uri (서버 화이트리스트에 등록되어 있어야 함) + * example: "https://promptplace-develop.vercel.app/auth/callback" * responses: * 200: * description: 카카오 로그인 성공 @@ -425,11 +430,16 @@ router.post("/kakao/token", AuthController.kakaoToken); * type: object * required: * - code + * - redirect_uri * properties: * code: * type: string * description: 구글 OAuth 인증코드 * example: "4/0AfJohXn..." + * redirect_uri: + * type: string + * description: OAuth 인증 시작 시 사용한 redirect_uri (서버 화이트리스트에 등록되어 있어야 함) + * example: "https://promptplace-develop.vercel.app/auth/callback" * responses: * 200: * description: 구글 로그인 성공 @@ -560,11 +570,16 @@ router.post("/google/token", AuthController.googleToken); * type: object * required: * - code + * - redirect_uri * properties: * code: * type: string * description: 네이버 OAuth 인증코드 * example: "abc123def456ghi789" + * redirect_uri: + * type: string + * description: OAuth 인증 시작 시 사용한 redirect_uri (서버 화이트리스트에 등록되어 있어야 함) + * example: "https://promptplace-develop.vercel.app/auth/callback" * responses: * 200: * description: 네이버 로그인 성공 diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index dece094..d168de2 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -14,25 +14,25 @@ interface Tokens { refreshToken: string; } -const getCallbackUrl = (provider: 'KAKAO' | 'GOOGLE' | 'NAVER') => { - const isDev = process.env.NODE_ENV !== 'production'; - - switch (provider) { - case 'KAKAO': - return isDev - ? process.env.KAKAO_CALLBACK_URL_DEV - : process.env.KAKAO_CALLBACK_URL; - case 'GOOGLE': - return isDev - ? process.env.GOOGLE_CALLBACK_URL_DEV - : process.env.GOOGLE_CALLBACK_URL; - case 'NAVER': - return isDev - ? process.env.NAVER_CALLBACK_URL_DEV - : process.env.NAVER_CALLBACK_URL; - default: - throw new Error('Unknown provider'); +const parseAllowedRedirectUris = (): Set => { + const raw = process.env.OAUTH_ALLOWED_REDIRECT_URIS ?? ""; + return new Set( + raw + .split(",") + .map((uri) => uri.trim()) + .filter(Boolean) + ); +}; + +export const assertAllowedRedirectUri = (redirectUri: string | undefined): string => { + if (!redirectUri) { + throw new AppError("redirect_uri가 필요합니다.", 400, "BadRequest"); + } + const allowed = parseAllowedRedirectUris(); + if (!allowed.has(redirectUri)) { + throw new AppError("허용되지 않은 redirect_uri입니다.", 400, "BadRequest"); } + return redirectUri; }; class AuthService { @@ -109,10 +109,10 @@ class AuthService { }; } - async exchangeKakaoToken(code: string) { + async exchangeKakaoToken(code: string, redirectUri: string) { try { // 기존 Passport 카카오 전략을 활용하여 사용자 정보 처리 - const user = await this.handleKakaoUserFromCode(code); + const user = await this.handleKakaoUserFromCode(code, redirectUri); const { accessToken, refreshToken } = await this.generateTokens(user); @@ -137,10 +137,10 @@ class AuthService { } } - async exchangeGoogleToken(code: string) { + async exchangeGoogleToken(code: string, redirectUri: string) { try { // 기존 Passport 구글 전략을 활용하여 사용자 정보 처리 - let user = await this.handleGoogleUserFromCode(code); + let user = await this.handleGoogleUserFromCode(code, redirectUri); if (!isActive(user.status)) { const inactive = user.inactive_date @@ -191,10 +191,10 @@ class AuthService { } } - async exchangeNaverToken(code: string) { + async exchangeNaverToken(code: string, redirectUri: string) { try { // 기존 Passport 네이버 전략을 활용하여 사용자 정보 처리 - let user = await this.handleNaverUserFromCode(code); + let user = await this.handleNaverUserFromCode(code, redirectUri); if (!isActive(user.status)) { const inactive = user.inactive_date @@ -247,7 +247,7 @@ class AuthService { } // 카카오 인증코드로 사용자 처리 (기존 로직 재사용) - private async handleKakaoUserFromCode(code: string): Promise { + private async handleKakaoUserFromCode(code: string, redirectUri: string): Promise { try { // 카카오에서 액세스 토큰 받기 const tokenResponse = await fetch("https://kauth.kakao.com/oauth/token", { @@ -258,7 +258,7 @@ class AuthService { client_id: process.env.KAKAO_CLIENT_ID!, client_secret: process.env.KAKAO_CLIENT_SECRET!, code: code, - redirect_uri: getCallbackUrl('KAKAO')!, + redirect_uri: redirectUri, }), }); @@ -312,7 +312,7 @@ class AuthService { } // 구글 인증코드로 사용자 처리 (기존 로직 재사용) - private async handleGoogleUserFromCode(code: string): Promise { + private async handleGoogleUserFromCode(code: string, redirectUri: string): Promise { try { // 구글에서 액세스 토큰 받기 const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { @@ -323,7 +323,7 @@ class AuthService { client_id: process.env.GOOGLE_CLIENT_ID!, client_secret: process.env.GOOGLE_CLIENT_SECRET!, code: code, - redirect_uri: getCallbackUrl('GOOGLE')!, + redirect_uri: redirectUri, }), }); @@ -380,7 +380,7 @@ class AuthService { } // 네이버 인증코드로 사용자 처리 (기존 로직 재사용) - private async handleNaverUserFromCode(code: string): Promise { + private async handleNaverUserFromCode(code: string, redirectUri: string): Promise { try { // 네이버에서 액세스 토큰 받기 const tokenResponse = await fetch( @@ -393,7 +393,7 @@ class AuthService { client_id: process.env.NAVER_CLIENT_ID!, client_secret: process.env.NAVER_CLIENT_SECRET!, code: code, - redirect_uri: getCallbackUrl('NAVER')!, + redirect_uri: redirectUri, }), } ); diff --git a/swagger.json b/swagger.json index 17b1776..aeb7ebd 100644 --- a/swagger.json +++ b/swagger.json @@ -384,13 +384,19 @@ "schema": { "type": "object", "required": [ - "code" + "code", + "redirect_uri" ], "properties": { "code": { "type": "string", "description": "카카오 OAuth 인증코드", "example": "abc123def456ghi789" + }, + "redirect_uri": { + "type": "string", + "description": "OAuth 인증 시작 시 사용한 redirect_uri (서버 화이트리스트에 등록되어 있어야 함)", + "example": "https://promptplace-develop.vercel.app/auth/callback" } } } @@ -572,13 +578,19 @@ "schema": { "type": "object", "required": [ - "code" + "code", + "redirect_uri" ], "properties": { "code": { "type": "string", "description": "구글 OAuth 인증코드", "example": "4/0AfJohXn..." + }, + "redirect_uri": { + "type": "string", + "description": "OAuth 인증 시작 시 사용한 redirect_uri (서버 화이트리스트에 등록되어 있어야 함)", + "example": "https://promptplace-develop.vercel.app/auth/callback" } } } @@ -760,13 +772,19 @@ "schema": { "type": "object", "required": [ - "code" + "code", + "redirect_uri" ], "properties": { "code": { "type": "string", "description": "네이버 OAuth 인증코드", "example": "abc123def456ghi789" + }, + "redirect_uri": { + "type": "string", + "description": "OAuth 인증 시작 시 사용한 redirect_uri (서버 화이트리스트에 등록되어 있어야 함)", + "example": "https://promptplace-develop.vercel.app/auth/callback" } } }