diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index a262c044..1d6c01c2 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -1,24 +1,32 @@ package com.mr.domain.auth.controller; +import com.mr.domain.auth.dto.OAuthCredential; import com.mr.domain.auth.dto.req.AuthRequestDTO; import com.mr.domain.auth.dto.res.AuthResponseDTO; import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.service.AuthService; +import com.mr.domain.auth.service.OAuthClientService; import com.mr.global.apipayload.ApiResponse; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.SecurityUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.mr.global.security.SecurityUtil; @Tag(name = "Auth API", description = "인증 및 소셜 로그인 관련 API") @RestController @@ -27,11 +35,60 @@ public class AuthController { private final AuthService authService; + private final OAuthClientService oAuthClientService; + + @SecurityRequirements + @Operation( + summary = "소셜 로그인 시작 API (Provider 로그인 이동)", + description = "소셜 로그인 제공자(카카오/구글)의 OAuth 인가 페이지로 HTTP 302 리다이렉트합니다." + ) + @GetMapping("/login/{socialType}") + public void startOAuthLogin( + @Parameter(description = "소셜 로그인 제공자 (KAKAO, GOOGLE)", example = "KAKAO") + @PathVariable(name = "socialType") SocialType socialType, + @Parameter(description = "커스텀 리다이렉트 URI (선택)", example = "http://localhost:8080/api/auth/kakao/callback") + @RequestParam(name = "redirectUri", required = false) String customRedirectUri, + HttpServletResponse response + ) throws IOException { + String authUrl = oAuthClientService.getAuthorizationUrl(socialType, customRedirectUri); + response.sendRedirect(authUrl); + } @SecurityRequirements @Operation( - summary = "소셜 로그인 / 회원가입 API", - description = "카카오 및 구글 OAuth Access Token을 받아 로그인을 진행하고, 서비스 전용 JWT 토큰을 발급합니다." + summary = "소셜 로그인 백엔드 콜백 API (OAuth Server ➔ Backend)", + description = "OAuth 제공자로부터 인가 코드(code)를 받아 토큰 교환 및 소셜 로그인을 수행한 후 프론트엔드 페이지로 리다이렉트합니다." + ) + @GetMapping("/{socialType}/callback") + public void oAuthCallback( + @Parameter(description = "소셜 로그인 제공자 (KAKAO, GOOGLE)", example = "KAKAO") + @PathVariable(name = "socialType") SocialType socialType, + @RequestParam(name = "code", required = false) String code, + @RequestParam(name = "error", required = false) String error, + @RequestParam(name = "redirectUri", required = false) String customRedirectUri, + @RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo, + HttpServletResponse response + ) throws IOException { + if (error != null || code == null || code.isBlank()) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + + OAuthCredential credential = new OAuthCredential(OAuthCredential.CredentialType.AUTHORIZATION_CODE, code); + AuthResponseDTO.LoginResponse loginResponse = authService.socialLogin( + socialType, + credential, + customRedirectUri, + deviceInfo + ); + + String targetFrontendUrl = oAuthClientService.buildFrontendRedirectUrl(loginResponse); + response.sendRedirect(targetFrontendUrl); + } + + @SecurityRequirements + @Operation( + summary = "소셜 로그인 / 회원가입 API (Authorization Code 또는 AccessToken)", + description = "카카오 및 구글 OAuth 인가 코드(code) 또는 Access Token을 전달받아 서비스 전용 JWT 토큰을 발급합니다." ) @PostMapping("/login/{socialType}") public ApiResponse socialLogin( @@ -40,9 +97,15 @@ public ApiResponse socialLogin( @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request, @RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo ) { - AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo); + AuthResponseDTO.LoginResponse response = authService.socialLogin( + socialType, + request.getCredential(), + request.redirectUri(), + deviceInfo + ); return ApiResponse.onSuccess(response); } + @SecurityRequirements @Operation(summary = "토큰 재발급 API", description = "만료된 Access Token을 Refresh Token을 이용해 재발급합니다.") @PostMapping("/reissue") @@ -65,7 +128,13 @@ public ApiResponse linkSocialAccount( @RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo ) { Long userId = SecurityUtil.getCurrentUserId(); - AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount(userId, socialType, request.accessToken(), deviceInfo); + AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount( + userId, + socialType, + request.getCredential(), + request.redirectUri(), + deviceInfo + ); return ApiResponse.onSuccess(tokenInfo); } diff --git a/src/main/java/com/mr/domain/auth/dto/OAuthCredential.java b/src/main/java/com/mr/domain/auth/dto/OAuthCredential.java new file mode 100644 index 00000000..275b125b --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/OAuthCredential.java @@ -0,0 +1,11 @@ +package com.mr.domain.auth.dto; + +public record OAuthCredential( + CredentialType type, + String value +) { + public enum CredentialType { + AUTHORIZATION_CODE, + ACCESS_TOKEN + } +} diff --git a/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java index 888d3870..bcc3091c 100644 --- a/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java +++ b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java @@ -1,13 +1,35 @@ package com.mr.domain.auth.dto.req; +import com.mr.domain.auth.dto.OAuthCredential; +import com.mr.domain.auth.exception.AuthErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; import jakarta.validation.constraints.NotBlank; public class AuthRequestDTO { public record SocialLoginRequest( - @NotBlank(message = "소셜 액세스 토큰은 필수 입력값입니다.") - String accessToken - ) {} + String code, + String authorizationCode, + String accessToken, + String redirectUri + ) { + /** + * code, authorizationCode, accessToken 중 전송된 값으로 OAuthCredential을 생성합니다. + * 필수 입력값 누락 검증도 본 메서드에서 수행합니다. + */ + public OAuthCredential getCredential() { + if (code != null && !code.isBlank()) { + return new OAuthCredential(OAuthCredential.CredentialType.AUTHORIZATION_CODE, code.trim()); + } + if (authorizationCode != null && !authorizationCode.isBlank()) { + return new OAuthCredential(OAuthCredential.CredentialType.AUTHORIZATION_CODE, authorizationCode.trim()); + } + if (accessToken != null && !accessToken.isBlank()) { + return new OAuthCredential(OAuthCredential.CredentialType.ACCESS_TOKEN, accessToken.trim()); + } + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + } public record TokenRefreshRequest( @NotBlank(message = "Refresh Token은 필수 입력값입니다.") diff --git a/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java b/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java index b2e39991..b1e91582 100644 --- a/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java +++ b/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java @@ -1,9 +1,8 @@ package com.mr.domain.auth.dto.res; -import com.fasterxml.jackson.annotation.JsonProperty; - public record GoogleUserResponse( String id, - String picture, - String email + String email, + String name, + String picture ) {} diff --git a/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java b/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java index 7ad47f85..e1727a8d 100644 --- a/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java +++ b/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java @@ -9,10 +9,10 @@ public record KakaoUserResponse( ) { public record KakaoAccount( Profile profile - ) {} - - public record Profile( - @JsonProperty("profile_image_url") - String profileImageUrl - ) {} + ) { + public record Profile( + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + } } diff --git a/src/main/java/com/mr/domain/auth/dto/res/OAuthTokenResponse.java b/src/main/java/com/mr/domain/auth/dto/res/OAuthTokenResponse.java new file mode 100644 index 00000000..ad03365f --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/res/OAuthTokenResponse.java @@ -0,0 +1,16 @@ +package com.mr.domain.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OAuthTokenResponse( + @JsonProperty("access_token") + String accessToken, + @JsonProperty("token_type") + String tokenType, + @JsonProperty("refresh_token") + String refreshToken, + @JsonProperty("expires_in") + Long expiresIn, + @JsonProperty("scope") + String scope +) {} diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 7a968bed..9a2ea123 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -1,5 +1,6 @@ package com.mr.domain.auth.service; +import com.mr.domain.auth.dto.OAuthCredential; import com.mr.domain.auth.dto.OAuthUserInfo; import com.mr.domain.auth.dto.res.AuthResponseDTO; import com.mr.domain.auth.entity.SocialAuth; @@ -7,14 +8,15 @@ import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.repository.SocialAuthRepository; import com.mr.domain.user.entity.User; -import com.mr.domain.user.repository.UserRepository; import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -36,8 +38,9 @@ public class AuthService { @Value("${app.profile.default-image-url}") private String defaultProfileImageUrl; - public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { - OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, OAuthCredential credential, String redirectUri, String deviceInfo) { + OAuthUserInfo userInfo = fetchOAuthUserInfo(socialType, credential, redirectUri); try { return executeSocialLogin(socialType, userInfo, deviceInfo); @@ -46,6 +49,23 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a } } + private OAuthUserInfo fetchOAuthUserInfo(SocialType socialType, OAuthCredential credential, String redirectUri) { + if (credential.type() == OAuthCredential.CredentialType.ACCESS_TOKEN) { + if (redirectUri != null && !redirectUri.isBlank()) { + return oAuthClientService.getUserInfo(socialType, credential.value(), redirectUri); + } + return oAuthClientService.getUserInfo(socialType, credential.value()); + } + if (redirectUri != null && !redirectUri.isBlank()) { + return oAuthClientService.getUserInfo(socialType, credential, redirectUri); + } + return oAuthClientService.getUserInfo(socialType, credential); + } + + public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { + return socialLogin(socialType, new OAuthCredential(OAuthCredential.CredentialType.ACCESS_TOKEN, accessToken), null, deviceInfo); + } + private AuthResponseDTO.LoginResponse executeSocialLogin(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { return transactionTemplate.execute(status -> { Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); @@ -127,8 +147,8 @@ private AuthResponseDTO.LoginResponse executeSocialLoginForExistingUser(SocialTy }); } - public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, String accessToken, String deviceInfo) { - OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); + public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, OAuthCredential credential, String redirectUri, String deviceInfo) { + OAuthUserInfo userInfo = fetchOAuthUserInfo(socialType, credential, redirectUri); return transactionTemplate.execute(status -> { User user = userRepository.findById(userId) @@ -170,6 +190,10 @@ public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socia }); } + public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, String accessToken, String deviceInfo) { + return linkSocialAccount(userId, socialType, new OAuthCredential(OAuthCredential.CredentialType.ACCESS_TOKEN, accessToken), null, deviceInfo); + } + private User registerNewUser(OAuthUserInfo userInfo) { String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index f26ae88e..3e49f77f 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -1,17 +1,23 @@ package com.mr.domain.auth.service; +import com.mr.domain.auth.dto.OAuthCredential; import com.mr.domain.auth.dto.OAuthUserInfo; import com.mr.domain.auth.dto.res.GoogleUserResponse; import com.mr.domain.auth.dto.res.KakaoUserResponse; +import com.mr.domain.auth.dto.res.OAuthTokenResponse; import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.exception.OAuthExceptionMapper; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.config.OAuthProperties; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import java.util.List; import org.springframework.web.client.RestClient; @Slf4j @@ -20,23 +26,99 @@ public class OAuthClientService { private final RestClient restClient; private final OAuthExceptionMapper exceptionMapper; + private final OAuthProperties oAuthProperties; public OAuthClientService( @Qualifier("oauthRestClient") RestClient restClient, - OAuthExceptionMapper exceptionMapper) { + OAuthExceptionMapper exceptionMapper, + OAuthProperties oAuthProperties) { this.restClient = restClient; this.exceptionMapper = exceptionMapper; + this.oAuthProperties = oAuthProperties; + } + + public OAuthUserInfo getUserInfo(SocialType socialType, OAuthCredential credential, String customRedirectUri) { + if (credential.type() == OAuthCredential.CredentialType.ACCESS_TOKEN) { + return getUserInfo(socialType, credential.value(), customRedirectUri); + } + return switch (socialType) { + case KAKAO -> getKakaoUserInfo(credential, customRedirectUri); + case GOOGLE -> getGoogleUserInfo(credential, customRedirectUri); + }; + } + + public OAuthUserInfo getUserInfo(SocialType socialType, OAuthCredential credential) { + if (credential.type() == OAuthCredential.CredentialType.ACCESS_TOKEN) { + return getUserInfo(socialType, credential.value()); + } + return getUserInfo(socialType, credential, null); + } + + public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken, String customRedirectUri) { + return switch (socialType) { + case KAKAO -> getKakaoUserInfo(new OAuthCredential(OAuthCredential.CredentialType.ACCESS_TOKEN, accessToken), customRedirectUri); + case GOOGLE -> getGoogleUserInfo(new OAuthCredential(OAuthCredential.CredentialType.ACCESS_TOKEN, accessToken), customRedirectUri); + }; } public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { + return getUserInfo(socialType, accessToken, null); + } + + private String resolveAccessToken(SocialType socialType, OAuthCredential credential, String customRedirectUri) { + if (credential.type() == OAuthCredential.CredentialType.ACCESS_TOKEN) { + return credential.value(); + } return switch (socialType) { - case KAKAO -> getKakaoUserInfo(accessToken); - case GOOGLE -> getGoogleUserInfo(accessToken); + case KAKAO -> exchangeKakaoCode(credential.value(), customRedirectUri); + case GOOGLE -> exchangeGoogleCode(credential.value(), customRedirectUri); }; } - private OAuthUserInfo getKakaoUserInfo(String accessToken) { + private String exchangeKakaoCode(String code, String customRedirectUri) { + OAuthProperties.ProviderProperties kakaoProps = oAuthProperties != null ? oAuthProperties.kakao() : null; + String clientId = kakaoProps != null ? kakaoProps.clientId() : null; + if (clientId == null || clientId.isBlank()) { + log.error("Kakao OAuth configuration is missing (client_id is not set)"); + throw new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + + String redirectUri = resolveRedirectUri(kakaoProps, customRedirectUri, "Kakao"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + if (kakaoProps.clientSecret() != null && !kakaoProps.clientSecret().isBlank()) { + body.add("client_secret", kakaoProps.clientSecret()); + } + + try { + OAuthTokenResponse response = restClient.post() + .uri("https://kauth.kakao.com/oauth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(OAuthTokenResponse.class); + + if (response == null || response.accessToken() == null || response.accessToken().isBlank()) { + throw new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); + } + return response.accessToken(); + } catch (GeneralException ge) { + throw ge; + } catch (Exception e) { + log.warn("Kakao Authorization Code exchange failed", e); + throw exceptionMapper.map(e, "Kakao Token Exchange"); + } + } + + private OAuthUserInfo getKakaoUserInfo(OAuthCredential credential, String customRedirectUri) { try { + String accessToken = resolveAccessToken(SocialType.KAKAO, credential, customRedirectUri); + KakaoUserResponse response = restClient.get() .uri("https://kapi.kakao.com/v2/user/me") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) @@ -68,8 +150,47 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { } } - private OAuthUserInfo getGoogleUserInfo(String accessToken) { + private String exchangeGoogleCode(String code, String customRedirectUri) { + OAuthProperties.ProviderProperties googleProps = oAuthProperties != null ? oAuthProperties.google() : null; + String clientId = googleProps != null ? googleProps.clientId() : null; + if (clientId == null || clientId.isBlank()) { + log.error("Google OAuth configuration is missing (client_id is not set)"); + throw new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + + String redirectUri = resolveRedirectUri(googleProps, customRedirectUri, "Google"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("client_secret", googleProps != null ? googleProps.clientSecret() : ""); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + try { + OAuthTokenResponse response = restClient.post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(OAuthTokenResponse.class); + + if (response == null || response.accessToken() == null || response.accessToken().isBlank()) { + throw new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); + } + return response.accessToken(); + } catch (GeneralException ge) { + throw ge; + } catch (Exception e) { + log.warn("Google Authorization Code exchange failed", e); + throw exceptionMapper.map(e, "Google Token Exchange"); + } + } + + private OAuthUserInfo getGoogleUserInfo(OAuthCredential credential, String customRedirectUri) { try { + String accessToken = resolveAccessToken(SocialType.GOOGLE, credential, customRedirectUri); + GoogleUserResponse response = restClient.get() .uri("https://www.googleapis.com/oauth2/v2/userinfo") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) @@ -89,4 +210,69 @@ private OAuthUserInfo getGoogleUserInfo(String accessToken) { throw exceptionMapper.map(e, "Google"); } } -} \ No newline at end of file + + private String resolveRedirectUri(OAuthProperties.ProviderProperties providerProps, String customRedirectUri, String providerName) { + List allowedUris = providerProps != null ? providerProps.getAllowedRedirectUris() : List.of(); + + if (allowedUris.isEmpty()) { + log.error("{} OAuth configuration is missing (redirect_uri is not set)", providerName); + throw new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + + if (customRedirectUri == null || customRedirectUri.isBlank()) { + return allowedUris.get(0); + } + + String trimmedCustomUri = customRedirectUri.trim(); + if (!allowedUris.contains(trimmedCustomUri)) { + log.warn("{} OAuth invalid redirect_uri requested: {}", providerName, trimmedCustomUri); + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + + return trimmedCustomUri; + } + + public String getAuthorizationUrl(SocialType socialType, String customRedirectUri) { + OAuthProperties.ProviderProperties props = oAuthProperties != null ? switch (socialType) { + case KAKAO -> oAuthProperties.kakao(); + case GOOGLE -> oAuthProperties.google(); + } : null; + + String clientId = props != null ? props.clientId() : null; + if (clientId == null || clientId.isBlank()) { + log.error("{} OAuth configuration is missing (client_id is not set)", socialType); + throw new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + + String redirectUri = resolveRedirectUri(props, customRedirectUri, socialType.name()); + + return switch (socialType) { + case KAKAO -> org.springframework.web.util.UriComponentsBuilder.fromUriString("https://kauth.kakao.com/oauth/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .build().toUriString(); + case GOOGLE -> org.springframework.web.util.UriComponentsBuilder.fromUriString("https://accounts.google.com/o/oauth2/v2/auth") + .queryParam("response_type", "code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("scope", "email profile openid") + .build().toUriString(); + }; + } + + public String buildFrontendRedirectUrl(com.mr.domain.auth.dto.res.AuthResponseDTO.LoginResponse loginResponse) { + String baseUrl = (oAuthProperties != null && oAuthProperties.frontendRedirectUri() != null && !oAuthProperties.frontendRedirectUri().isBlank()) + ? oAuthProperties.frontendRedirectUri() + : "http://localhost:3000/oauth/callback"; + + return org.springframework.web.util.UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("accessToken", loginResponse.tokenInfo().accessToken()) + .queryParam("refreshToken", loginResponse.tokenInfo().refreshToken()) + .queryParam("userId", loginResponse.userId()) + .queryParam("isNewUser", loginResponse.isNewUser()) + .queryParam("isOnboardingCompleted", loginResponse.isOnboardingCompleted()) + .build() + .toUriString(); + } +} diff --git a/src/main/java/com/mr/global/config/OAuthProperties.java b/src/main/java/com/mr/global/config/OAuthProperties.java new file mode 100644 index 00000000..2432ba46 --- /dev/null +++ b/src/main/java/com/mr/global/config/OAuthProperties.java @@ -0,0 +1,37 @@ +package com.mr.global.config; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth") +public record OAuthProperties( + String frontendRedirectUri, + ProviderProperties kakao, + ProviderProperties google +) { + public record ProviderProperties( + String clientId, + String clientSecret, + String redirectUri, + List redirectUris + ) { + public List getAllowedRedirectUris() { + List allowed = new ArrayList<>(); + if (redirectUris != null) { + for (String uri : redirectUris) { + if (uri != null && !uri.isBlank()) { + allowed.add(uri.trim()); + } + } + } + if (redirectUri != null && !redirectUri.isBlank()) { + String trimmed = redirectUri.trim(); + if (!allowed.contains(trimmed)) { + allowed.add(trimmed); + } + } + return allowed; + } + } +} diff --git a/src/main/java/com/mr/global/config/OAuthRestClientConfig.java b/src/main/java/com/mr/global/config/OAuthRestClientConfig.java index 3befb305..3a786ad0 100644 --- a/src/main/java/com/mr/global/config/OAuthRestClientConfig.java +++ b/src/main/java/com/mr/global/config/OAuthRestClientConfig.java @@ -1,28 +1,29 @@ package com.mr.global.config; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.client.ClientHttpRequestFactories; -import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; import java.time.Duration; @Configuration +@EnableConfigurationProperties(OAuthProperties.class) public class OAuthRestClientConfig { @Bean(name = "oauthRestClient") public RestClient oauthRestClient( @Value("${oauth.connect-timeout:3s}") Duration connectTimeout, - @Value("${oauth.read-timeout:5s}") Duration readTimeout) { - - ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS - .withConnectTimeout(connectTimeout) - .withReadTimeout(readTimeout); + @Value("${oauth.read-timeout:5s}") Duration readTimeout + ) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeout); + factory.setReadTimeout(readTimeout); return RestClient.builder() - .requestFactory(ClientHttpRequestFactories.get(settings)) + .requestFactory(factory) .build(); } } diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java index 45fba52c..fd59b76e 100644 --- a/src/main/java/com/mr/global/security/SecurityConfig.java +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -32,6 +32,7 @@ public class SecurityConfig { "/swagger-ui/**", "/v3/api-docs/**", "/api/auth/login/**", + "/api/auth/*/callback", "/api/auth/reissue" }; diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index 6fa4f75b..b04d05fa 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -31,7 +31,7 @@ class OAuthClientServiceTest { void setUp() { restClientBuilder = RestClient.builder(); mockServer = MockRestServiceServer.bindTo(restClientBuilder).build(); - oAuthClientService = new OAuthClientService(restClientBuilder.build(), new OAuthExceptionMapper()); + oAuthClientService = new OAuthClientService(restClientBuilder.build(), new OAuthExceptionMapper(), null); } @AfterEach @@ -155,4 +155,37 @@ void getGoogleUserInfo_success() { assertThat(userInfo.socialId()).isEqualTo("google_987654321"); assertThat(userInfo.profileImgUrl()).isEqualTo("https://example.com/google_profile.png"); } + + @Test + @DisplayName("OAuth 설정(clientId)이 누락된 경우 authorization_code 교환 요청 시 OAUTH_SERVER_ERROR 예외가 발생한다") + void exchangeCode_missingConfig_throwsOauthServerError() { + com.mr.domain.auth.dto.OAuthCredential credential = new com.mr.domain.auth.dto.OAuthCredential( + com.mr.domain.auth.dto.OAuthCredential.CredentialType.AUTHORIZATION_CODE, "sample_code"); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, credential, null)) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.OAUTH_SERVER_ERROR)); + } + + @Test + @DisplayName("허용되지 않은 customRedirectUri로 authorization_code 교환 요청 시 INVALID_AUTH_REQUEST 예외가 발생한다") + void exchangeCode_invalidCustomRedirectUri_throwsInvalidAuthRequest() { + com.mr.global.config.OAuthProperties.ProviderProperties kakaoProps = new com.mr.global.config.OAuthProperties.ProviderProperties( + "sample_client_id", + "sample_secret", + "https://allowed.example.com/oauth/callback", + java.util.List.of("https://allowed.example.com/oauth/callback") + ); + com.mr.global.config.OAuthProperties oAuthProperties = new com.mr.global.config.OAuthProperties( + "http://localhost:3000/oauth/callback", kakaoProps, null); + OAuthClientService serviceWithProps = new OAuthClientService( + restClientBuilder.build(), new OAuthExceptionMapper(), oAuthProperties); + + com.mr.domain.auth.dto.OAuthCredential credential = new com.mr.domain.auth.dto.OAuthCredential( + com.mr.domain.auth.dto.OAuthCredential.CredentialType.AUTHORIZATION_CODE, "sample_code"); + + assertThatThrownBy(() -> serviceWithProps.getUserInfo(SocialType.KAKAO, credential, "https://unauthorized.malicious.com/callback")) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.INVALID_AUTH_REQUEST)); + } }