Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/main/java/ceos/ipx/IpxBeApplication.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
}
44 changes: 44 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +37,7 @@
public class AuthController {

private final AuthService authService;
private final GoogleOAuthService googleOAuthService;

@Operation(summary = "일반 회원가입", description = "이메일 인증이 완료된 사용자의 정보를 받아 계정을 생성합니다.")
@ApiResponses({
Expand Down Expand Up @@ -121,4 +128,41 @@ public ResponseEntity<ApiResponse<Void>> logout(

return ResponseEntity.ok(ApiResponse.<Void>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<Void> 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<Void> 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();
}
}
81 changes: 81 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/service/GoogleOAuthService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions src/main/java/ceos/ipx/global/config/FrontendOAuthProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> allowedRedirectUris
) {
}
17 changes: 17 additions & 0 deletions src/main/java/ceos/ipx/global/config/GoogleOAuthProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> scope
) {
}
3 changes: 3 additions & 0 deletions src/main/java/ceos/ipx/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "해당 요청에 권한이 없습니다.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ private SecurityWhitelist() {
"/api/auth/email/send",
"/api/auth/email/verify",

// Google OAuth
"/api/auth/oauth/**",

// OAuth 콜백 (Google 로그인)
"/oauth2/**",
"/login/oauth2/**",
Expand All @@ -50,7 +53,6 @@ private SecurityWhitelist() {
// 검색 (로그인한 사용자만 사용 가능하도록)
"/api/search/**", // 특허 검색


// 인증 관련
"/api/auth/logout", // 로그아웃

Expand Down
21 changes: 21 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading