diff --git a/src/main/java/ceos/ipx/IpxBeApplication.java b/src/main/java/ceos/ipx/IpxBeApplication.java index fb7c88c..70641c8 100644 --- a/src/main/java/ceos/ipx/IpxBeApplication.java +++ b/src/main/java/ceos/ipx/IpxBeApplication.java @@ -1,13 +1,19 @@ package ceos.ipx; +import ceos.ipx.global.config.FrontendOAuthProperties; +import ceos.ipx.global.config.GoogleOAuthProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +@EnableConfigurationProperties({ + GoogleOAuthProperties.class, + FrontendOAuthProperties.class +}) @SpringBootApplication public class IpxBeApplication { public static void main(String[] args) { SpringApplication.run(IpxBeApplication.class, args); } - -} +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java index fe2e544..fd5b261 100644 --- a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -23,6 +23,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import ceos.ipx.domain.auth.dto.PasswordResetRequest; +import ceos.ipx.domain.auth.service.GoogleOAuthService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.net.URI; + @Tag(name = "Auth API", description = "인증/인가 관련 API") @RestController @@ -31,6 +37,7 @@ public class AuthController { private final AuthService authService; + private final GoogleOAuthService googleOAuthService; @Operation(summary = "일반 회원가입", description = "이메일 인증이 완료된 사용자의 정보를 받아 계정을 생성합니다.") @ApiResponses({ @@ -121,4 +128,41 @@ public ResponseEntity> logout( return ResponseEntity.ok(ApiResponse.ok(null)); } + + @Operation(summary = "Google 로그인 시작", description = "Google OAuth 인증 페이지로 Redirect합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "Google OAuth 로그인 페이지로 이동"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "허용되지 않은 OAuth redirectUri"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "OAuth URL 생성 실패") + }) + @GetMapping("/oauth/google") + public ResponseEntity redirectToGoogleOAuth( + @RequestParam(value = "redirectUri", required = false) String redirectUri + ) { + String googleAuthUrl = googleOAuthService.generateGoogleAuthUrl(redirectUri); + + return ResponseEntity + .status(HttpStatus.FOUND) + .location(URI.create(googleAuthUrl)) + .build(); + } + + @Operation(summary = "Google OAuth Callback", description = "Google OAuth 인증 완료 후 code와 state를 받아 FE 콜백 화면으로 Redirect합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "FE OAuth Callback 화면으로 이동"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 오류 또는 유효하지 않은 OAuth state"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @GetMapping("/oauth/google/callback") + public ResponseEntity handleGoogleOAuthCallback( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state + ) { + String frontendCallbackUrl = googleOAuthService.buildFrontendCallbackUrl(code, state); + + return ResponseEntity + .status(HttpStatus.FOUND) + .location(URI.create(frontendCallbackUrl)) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthService.java b/src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthService.java new file mode 100644 index 0000000..34cb9f0 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthService.java @@ -0,0 +1,81 @@ +package ceos.ipx.domain.auth.service; + +import ceos.ipx.global.config.FrontendOAuthProperties; +import ceos.ipx.global.config.GoogleOAuthProperties; +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class GoogleOAuthService { + + private static final String RESPONSE_TYPE_CODE = "code"; + private static final String ACCESS_TYPE_OFFLINE = "offline"; + private static final String PROMPT_CONSENT = "consent"; + + private final GoogleOAuthProperties googleOAuthProperties; + private final FrontendOAuthProperties frontendOAuthProperties; + private final GoogleOAuthStateService googleOAuthStateService; + + public String generateGoogleAuthUrl(String redirectUri) { + String frontendRedirectUri = resolveFrontendRedirectUri(redirectUri); + String state = UUID.randomUUID().toString(); + + googleOAuthStateService.saveState(state, frontendRedirectUri); + + try { + return UriComponentsBuilder + .fromUriString(googleOAuthProperties.authUri()) + .queryParam("client_id", googleOAuthProperties.clientId()) + .queryParam("redirect_uri", googleOAuthProperties.redirectUri()) + .queryParam("response_type", RESPONSE_TYPE_CODE) + .queryParam("scope", String.join(" ", googleOAuthProperties.scope())) + .queryParam("state", state) + .queryParam("access_type", ACCESS_TYPE_OFFLINE) + .queryParam("prompt", PROMPT_CONSENT) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); + } catch (RuntimeException e) { + throw new BusinessException(ErrorCode.OAUTH_URL_GENERATION_FAILED); + } + } + + public String buildFrontendCallbackUrl(String code, String state) { + if (code == null || code.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + + String frontendRedirectUri = googleOAuthStateService.consumeState(state); + + return UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("code", code) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); + } + + private String resolveFrontendRedirectUri(String redirectUri) { + if (redirectUri == null || redirectUri.isBlank()) { + return frontendOAuthProperties.googleCallbackUri(); + } + + if (!isAllowedFrontendRedirectUri(redirectUri)) { + throw new BusinessException(ErrorCode.INVALID_OAUTH_REDIRECT_URI); + } + + return redirectUri; + } + + private boolean isAllowedFrontendRedirectUri(String redirectUri) { + return frontendOAuthProperties.allowedRedirectUris() != null + && frontendOAuthProperties.allowedRedirectUris().contains(redirectUri); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthStateService.java b/src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthStateService.java new file mode 100644 index 0000000..175e1d9 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthStateService.java @@ -0,0 +1,47 @@ +package ceos.ipx.domain.auth.service; + +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class GoogleOAuthStateService { + + private static final String GOOGLE_OAUTH_STATE_KEY_PREFIX = "oauth:google:state:"; + private static final Duration GOOGLE_OAUTH_STATE_TTL = Duration.ofMinutes(5); + + private final StringRedisTemplate stringRedisTemplate; + + public void saveState(String state, String frontendRedirectUri) { + String key = createKey(state); + + stringRedisTemplate.opsForValue() + .set(key, frontendRedirectUri, GOOGLE_OAUTH_STATE_TTL); + } + + public String consumeState(String state) { + if (state == null || state.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_OAUTH_STATE); + } + + String key = createKey(state); + String frontendRedirectUri = stringRedisTemplate.opsForValue().get(key); + + if (frontendRedirectUri == null || frontendRedirectUri.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_OAUTH_STATE); + } + + stringRedisTemplate.delete(key); + + return frontendRedirectUri; + } + + private String createKey(String state) { + return GOOGLE_OAUTH_STATE_KEY_PREFIX + state; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/config/FrontendOAuthProperties.java b/src/main/java/ceos/ipx/global/config/FrontendOAuthProperties.java new file mode 100644 index 0000000..409a4be --- /dev/null +++ b/src/main/java/ceos/ipx/global/config/FrontendOAuthProperties.java @@ -0,0 +1,12 @@ +package ceos.ipx.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "frontend.oauth") +public record FrontendOAuthProperties( + String googleCallbackUri, + List allowedRedirectUris +) { +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/config/GoogleOAuthProperties.java b/src/main/java/ceos/ipx/global/config/GoogleOAuthProperties.java new file mode 100644 index 0000000..68781fe --- /dev/null +++ b/src/main/java/ceos/ipx/global/config/GoogleOAuthProperties.java @@ -0,0 +1,17 @@ +package ceos.ipx.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "google.oauth") +public record GoogleOAuthProperties( + String clientId, + String clientSecret, + String redirectUri, + String authUri, + String tokenUri, + String userInfoUri, + List scope +) { +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index 481ab93..34bf6f1 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -27,6 +27,9 @@ public enum ErrorCode { PASSWORD_RESET_TOKEN_EXPIRED(HttpStatus.GONE, "AU014", "비밀번호 재설정 토큰이 만료되었거나 존재하지 않습니다."), INVALID_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "AU015", "비밀번호 형식이 올바르지 않습니다."), SAME_AS_OLD_PASSWORD(HttpStatus.BAD_REQUEST, "AU016", "기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), + INVALID_OAUTH_REDIRECT_URI(HttpStatus.BAD_REQUEST, "AU017", "허용되지 않은 OAuth redirectUri입니다."), + OAUTH_URL_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AU018", "OAuth URL 생성에 실패했습니다."), + INVALID_OAUTH_STATE(HttpStatus.BAD_REQUEST, "AU019", "유효하지 않은 OAuth state입니다."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java index 4149e1c..0ea9d3c 100644 --- a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java +++ b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java @@ -24,6 +24,9 @@ private SecurityWhitelist() { "/api/auth/email/send", "/api/auth/email/verify", + // Google OAuth + "/api/auth/oauth/**", + // OAuth 콜백 (Google 로그인) "/oauth2/**", "/login/oauth2/**", @@ -50,7 +53,6 @@ private SecurityWhitelist() { // 검색 (로그인한 사용자만 사용 가능하도록) "/api/search/**", // 특허 검색 - // 인증 관련 "/api/auth/logout", // 로그아웃 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 666dd6d..4df4a30 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -26,3 +26,24 @@ search-server: server: port: 8080 + +google: + oauth: + client-id: ${GOOGLE_OAUTH_CLIENT_ID} + client-secret: ${GOOGLE_OAUTH_CLIENT_SECRET} + redirect-uri: ${GOOGLE_OAUTH_REDIRECT_URI} + auth-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo + scope: + - openid + - email + - profile + +frontend: + oauth: + google-callback-uri: ${FRONTEND_GOOGLE_OAUTH_CALLBACK_URI:http://localhost:3000/oauth/google/callback} + allowed-redirect-uris: + - http://localhost:3000/oauth/google/callback + - https://ipx-patent.com/oauth/google/callback + - https://www.ipx-patent.com/oauth/google/callback \ No newline at end of file