From 71b5a0300aaca6dc7184d760a9a6e2475b86dcdc Mon Sep 17 00:00:00 2001 From: dungbik Date: Mon, 28 Jul 2025 14:58:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/OAuthController.java | 121 +++++++++++++++--- .../auth/exception/AuthErrorCode.java | 4 +- .../flipnote/auth/service/OAuthService.java | 57 +++++++-- .../common/config/ClientProperties.java | 4 +- .../security/config/SecurityConfig.java | 2 +- .../flipnote/infra/oauth/OAuthApiClient.java | 12 +- .../repository/UserOAuthLinkRepository.java | 16 +++ src/main/resources/application.yml | 2 + 8 files changed, 180 insertions(+), 38 deletions(-) diff --git a/src/main/java/project/flipnote/auth/controller/OAuthController.java b/src/main/java/project/flipnote/auth/controller/OAuthController.java index 4e7811c1..00e4e0c2 100644 --- a/src/main/java/project/flipnote/auth/controller/OAuthController.java +++ b/src/main/java/project/flipnote/auth/controller/OAuthController.java @@ -4,13 +4,16 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -18,10 +21,14 @@ import project.flipnote.auth.constants.OAuthConstants; import project.flipnote.auth.exception.AuthErrorCode; import project.flipnote.auth.model.AuthorizationRedirect; +import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.service.OAuthService; import project.flipnote.common.config.ClientProperties; import project.flipnote.common.exception.BizException; import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.jwt.JwtConstants; +import project.flipnote.common.security.jwt.JwtProperties; +import project.flipnote.common.util.CookieUtil; @Slf4j @RequiredArgsConstructor @@ -30,6 +37,8 @@ public class OAuthController { private final OAuthService oAuthService; private final ClientProperties clientProperties; + private final JwtProperties jwtProperties; + private final CookieUtil cookieUtil; @GetMapping("/oauth2/authorization/{provider}") public ResponseEntity redirectToProviderAuthorization( @@ -37,7 +46,7 @@ public ResponseEntity redirectToProviderAuthorization( HttpServletRequest request, @AuthenticationPrincipal UserAuth userAuth ) { - AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth.userId()); + AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth); return ResponseEntity.status(HttpStatus.FOUND) .header(HttpHeaders.SET_COOKIE, authRedirect.cookie().toString()) @@ -49,31 +58,103 @@ public ResponseEntity redirectToProviderAuthorization( public ResponseEntity handleCallback( @PathVariable("provider") String provider, @RequestParam("code") String code, - @RequestParam("state") String state, + @RequestParam(name = "state", required = false) String state, @CookieValue(OAuthConstants.VERIFIER_COOKIE_NAME) String codeVerifier, HttpServletRequest request ) { - String redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_SUCCESS); + boolean isSocialLinkRequest = StringUtils.hasText(state); + if (isSocialLinkRequest) { + return handleSocialLink(provider, code, state, codeVerifier, request); + } + return handleSocialLogin(provider, code, codeVerifier, request); + } + + private ResponseEntity handleSocialLink( + String provider, + String code, + String state, + String codeVerifier, + HttpServletRequest request + ) { + URI location; try { oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request); - } catch (BizException exception) { - if (exception.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { - redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_CONFLICT); - } else { - redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_FAILURE); - } - log.warn("BizException handled: code={}, status={}, message={}", - exception.getErrorCode().getCode(), - exception.getErrorCode().getStatus(), - exception.getErrorCode().getMessage() - ); - } catch (Exception exception) { - redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_FAILURE); - log.error("소셜 계정 연동 콜백 처리 중 예상치 못한 오류 발생. provider: {}, state: {}", provider, state, exception); + location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_SUCCESS); + } catch (BizException ex) { + location = resolveRedirectUrlForSocialLink(ex); + logBizException(ex); + } catch (Exception ex) { + location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_FAILURE); + log.error("소셜 계정 연동 콜백 처리 중 예상치 못한 오류 발생. provider: {}, state: {}", provider, state, ex); } - return ResponseEntity.status(HttpStatus.FOUND) - .location(URI.create(redirectUri)) - .build(); + return buildRedirectResponse(location, null); + } + + private ResponseEntity handleSocialLogin( + String provider, + String code, + String codeVerifier, + HttpServletRequest request + ) { + URI location; + ResponseCookie refreshTokenCookie = null; + try { + TokenPair tokenPair = oAuthService.socialLogin(provider, code, codeVerifier, request); + location = buildLoginSuccessRedirectUri(tokenPair.accessToken()); + refreshTokenCookie = createRefreshTokenCookie(tokenPair.refreshToken()); + } catch (BizException ex) { + location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LOGIN_FAILURE); + logBizException(ex); + } catch (Exception ex) { + location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LOGIN_FAILURE); + log.error("소셜 계정 로그인 콜백 처리 중 예상치 못한 오류 발생. provider: {}", provider, ex); + } + + return buildRedirectResponse(location, refreshTokenCookie); + } + + private void logBizException(BizException ex) { + log.warn("BizException handled: code={}, status={}, message={}", + ex.getErrorCode().getCode(), + ex.getErrorCode().getStatus(), + ex.getErrorCode().getMessage() + ); + } + + private ResponseCookie createRefreshTokenCookie(String token) { + long expirationSeconds = jwtProperties.getRefreshTokenExpiration().toSeconds(); + return cookieUtil.createCookie( + JwtConstants.REFRESH_TOKEN, + token, + Math.toIntExact(expirationSeconds) + ); + } + + private URI resolveRedirectUrlForSocialLink(BizException exception) { + if (exception.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { + return buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_CONFLICT); + } + return buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_FAILURE); + } + + private URI buildRedirectUri(ClientProperties.PathKey pathKey) { + return URI.create(clientProperties.buildUrl(pathKey)); + } + + private URI buildLoginSuccessRedirectUri(String accessToken) { + return UriComponentsBuilder + .fromUriString(clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LOGIN_SUCCESS)) + .queryParam("accessToken", accessToken) + .build(true) + .toUri(); + } + + private ResponseEntity buildRedirectResponse(URI location, ResponseCookie cookie) { + ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.FOUND).location(location); + if (cookie != null) { + builder.header(HttpHeaders.SET_COOKIE, cookie.toString()); + } + return builder.build(); } } diff --git a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java index b85e44f8..dd3792df 100644 --- a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java +++ b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java @@ -20,7 +20,9 @@ public enum AuthErrorCode implements ErrorCode { ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.CONFLICT, "AUTH_008", "이미 유효한 비밀번호 재설정 링크가 존재합니다. 이메일을 확인해주세요."), INVALID_PASSWORD_RESET_TOKEN(HttpStatus.NOT_FOUND, "AUTH_009", "비밀번호 재설정 링크가 유효하지 않거나 만료되었습니다."), INVALID_SOCIAL_LINK_TOKEN(HttpStatus.NOT_FOUND, "AUTH_010", "소셜 계정 연동 토큰이 유효하지 않거나 만료되었습니다."), - ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_011", "이미 연동된 소셜 계정입니다."); + ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_011", "이미 연동된 소셜 계정입니다."), + NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "가입되지 않은 소셜 계정입니다."), + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 소셜 제공자입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/auth/service/OAuthService.java b/src/main/java/project/flipnote/auth/service/OAuthService.java index 9754f94a..ca2497c8 100644 --- a/src/main/java/project/flipnote/auth/service/OAuthService.java +++ b/src/main/java/project/flipnote/auth/service/OAuthService.java @@ -1,6 +1,7 @@ package project.flipnote.auth.service; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.springframework.http.ResponseCookie; @@ -13,13 +14,16 @@ import project.flipnote.auth.constants.OAuthConstants; import project.flipnote.auth.exception.AuthErrorCode; import project.flipnote.auth.model.AuthorizationRedirect; +import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.repository.SocialLinkTokenRedisRepository; -import project.flipnote.common.exception.BizException; -import project.flipnote.infra.oauth.model.OAuth2UserInfo; import project.flipnote.common.config.OAuthProperties; +import project.flipnote.common.exception.BizException; +import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.jwt.JwtComponent; import project.flipnote.common.util.CookieUtil; import project.flipnote.common.util.PkceUtil; import project.flipnote.infra.oauth.OAuthApiClient; +import project.flipnote.infra.oauth.model.OAuth2UserInfo; import project.flipnote.user.entity.UserOAuthLink; import project.flipnote.user.repository.UserOAuthLinkRepository; import project.flipnote.user.repository.UserRepository; @@ -37,16 +41,19 @@ public class OAuthService { private final SocialLinkTokenRedisRepository socialLinkTokenRedisRepository; private final UserRepository userRepository; private final UserOAuthLinkRepository userOAuthLinkRepository; + private final JwtComponent jwtComponent; - public AuthorizationRedirect getAuthorizationUri(String providerName, HttpServletRequest request, long userId) { + public AuthorizationRedirect getAuthorizationUri( + String providerName, + HttpServletRequest request, + UserAuth userAuth + ) { OAuthProperties.Provider provider = getProvider(providerName); String codeVerifier = pkceUtil.generateCodeVerifier(); String codeChallenge = pkceUtil.generateCodeChallenge(codeVerifier); - String state = UUID.randomUUID().toString(); - socialLinkTokenRedisRepository.saveToken(userId, state); - + String state = generateStateForSocialLink(userAuth); String authorizeUrl = oAuthApiClient.buildAuthorizeUri(request, provider, codeChallenge, state); ResponseCookie cookie = cookieUtil.createCookie( OAuthConstants.VERIFIER_COOKIE_NAME, @@ -69,10 +76,7 @@ public void linkSocialAccount( .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN)); socialLinkTokenRedisRepository.deleteToken(state); - OAuthProperties.Provider provider = getProvider(providerName); - String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request); - Map userInfoAttributes = oAuthApiClient.requestUserInfo(provider, accessToken); - OAuth2UserInfo userInfo = oAuthApiClient.createUserInfo(providerName, userInfoAttributes); + OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); if (userOAuthLinkRepository.existsByUser_IdAndProviderId(userId, userInfo.getProviderId())) { throw new BizException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); @@ -86,7 +90,38 @@ public void linkSocialAccount( userOAuthLinkRepository.save(userOAuthLink); } + public TokenPair socialLogin(String providerName, String code, String codeVerifier, HttpServletRequest request) { + OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); + + UserOAuthLink userOAuthLink = userOAuthLinkRepository.findByProviderAndProviderIdWithUser( + providerName, userInfo.getProviderId() + ).orElseThrow(() -> new BizException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT)); + + return jwtComponent.generateTokenPair(userOAuthLink.getUser()); + } + + private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, String codeVerifier, + HttpServletRequest request) { + OAuthProperties.Provider provider = getProvider(providerName); + String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request); + Map userInfoAttributes = oAuthApiClient.requestUserInfo(provider, accessToken); + return oAuthApiClient.createUserInfo(providerName, userInfoAttributes); + } + private OAuthProperties.Provider getProvider(String providerName) { - return oauthProperties.getProviders().get(providerName.toLowerCase()); + return Optional.ofNullable(oauthProperties.getProviders().get(providerName.toLowerCase())) + .orElseThrow(() -> { + log.warn("지원하지 않는 OAuth Provider 입니다. provider: {}", providerName); + return new BizException(AuthErrorCode.INVALID_OAUTH_PROVIDER); + }); + } + + private String generateStateForSocialLink(UserAuth userAuth) { + if (userAuth == null) { + return null; + } + String state = UUID.randomUUID().toString(); + socialLinkTokenRedisRepository.saveToken(userAuth.userId(), state); + return state; } } diff --git a/src/main/java/project/flipnote/common/config/ClientProperties.java b/src/main/java/project/flipnote/common/config/ClientProperties.java index 40e36f59..454f56f9 100644 --- a/src/main/java/project/flipnote/common/config/ClientProperties.java +++ b/src/main/java/project/flipnote/common/config/ClientProperties.java @@ -18,7 +18,9 @@ public enum PathKey { PASSWORD_RESET, SOCIAL_LINK_SUCCESS, SOCIAL_LINK_FAILURE, - SOCIAL_LINK_CONFLICT + SOCIAL_LINK_CONFLICT, + SOCIAL_LOGIN_SUCCESS, + SOCIAL_LOGIN_FAILURE } private String url; diff --git a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java index 608fe98a..6b947cec 100644 --- a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java +++ b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java @@ -66,7 +66,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/*/auth/login", "/*/auth/email", "/*/auth/email/confirm" ).permitAll() .requestMatchers( - HttpMethod.GET, "/oauth2/callback/{provider}" + HttpMethod.GET, "/oauth2/authorization/{provider}", "/oauth2/callback/{provider}" ).permitAll() .requestMatchers( "/v3/api-docs/**", diff --git a/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java b/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java index 12137f6f..3261c1d6 100644 --- a/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java +++ b/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java @@ -78,15 +78,19 @@ public String buildAuthorizeUri( String codeChallenge, String state ) { - return UriComponentsBuilder.fromUriString(provider.getAuthorizationUri()) + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(provider.getAuthorizationUri()) .queryParam("client_id", provider.getClientId()) .queryParam("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())) .queryParam("response_type", "code") .queryParam("scope", String.join(" ", provider.getScope())) .queryParam("code_challenge", codeChallenge) - .queryParam("code_challenge_method", "S256") - .queryParam("state", state) - .toUriString(); + .queryParam("code_challenge_method", "S256"); + + if (state != null) { + builder.queryParam("state", state); + } + + return builder.toUriString(); } private String buildRedirectUri(HttpServletRequest request, String path) { diff --git a/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java b/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java index 052bdf7c..5e55d36c 100644 --- a/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java +++ b/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java @@ -1,9 +1,12 @@ package project.flipnote.user.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import io.lettuce.core.dynamic.annotation.Param; import project.flipnote.user.entity.UserOAuthLink; public interface UserOAuthLinkRepository extends JpaRepository { @@ -13,4 +16,17 @@ public interface UserOAuthLinkRepository extends JpaRepository findByUser_Id(Long userId); boolean existsByIdAndUser_Id(Long id, Long userId); + + @Query(""" + SELECT uol + FROM UserOAuthLink uol + JOIN FETCH uol.user + WHERE uol.provider = :provider + AND uol.providerId = :providerId + """) + Optional findByProviderAndProviderIdWithUser( + @Param("provider") String provider, + @Param("providerId") String providerId + ); + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f2987815..6cdcd0aa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,6 +62,8 @@ app: social-link-success: /social-link/success social-link-failure: /social-link/failure social-link-conflict: /social-link/conflict + social-login-success: /social-login/success + social-login-failure: /social-login/failure oauth2: providers: From ba1022f8583f988132fb7bf2c744998628faa79e Mon Sep 17 00:00:00 2001 From: dungbik Date: Mon, 28 Jul 2025 15:15:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Fix:=20ALREADY=5FLINKED=5FSOCIAL=5FACCOUNT?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/project/flipnote/auth/exception/AuthErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java index dd3792df..4b2bafb9 100644 --- a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java +++ b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java @@ -20,7 +20,7 @@ public enum AuthErrorCode implements ErrorCode { ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.CONFLICT, "AUTH_008", "이미 유효한 비밀번호 재설정 링크가 존재합니다. 이메일을 확인해주세요."), INVALID_PASSWORD_RESET_TOKEN(HttpStatus.NOT_FOUND, "AUTH_009", "비밀번호 재설정 링크가 유효하지 않거나 만료되었습니다."), INVALID_SOCIAL_LINK_TOKEN(HttpStatus.NOT_FOUND, "AUTH_010", "소셜 계정 연동 토큰이 유효하지 않거나 만료되었습니다."), - ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_011", "이미 연동된 소셜 계정입니다."), + ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_011", "이미 연동된 소셜 계정입니다."), NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "가입되지 않은 소셜 계정입니다."), INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 소셜 제공자입니다."); From 31a39d6e9c889e7a62776cfcd9913f428ca7944d Mon Sep 17 00:00:00 2001 From: dungbik Date: Mon, 28 Jul 2025 15:16:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Fix:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=9E=84=ED=8F=AC=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/repository/UserOAuthLinkRepository.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java b/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java index 5e55d36c..c1e61275 100644 --- a/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java +++ b/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java @@ -5,8 +5,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -import io.lettuce.core.dynamic.annotation.Param; import project.flipnote.user.entity.UserOAuthLink; public interface UserOAuthLinkRepository extends JpaRepository { @@ -18,12 +18,12 @@ public interface UserOAuthLinkRepository extends JpaRepository findByProviderAndProviderIdWithUser( @Param("provider") String provider, @Param("providerId") String providerId