From cf491a1c070d3ac0b003c38ea0f38b613874295b Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Tue, 4 Feb 2025 19:34:02 +0900 Subject: [PATCH 01/21] =?UTF-8?q?refactor:=20kakao=20oauth=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B0=92=EC=9D=84=20ConfigurationProperties=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/KakaoOAuthClient.java | 23 +++++-------------- .../client/KakaoOAuthClientProperties.java | 12 ++++++++++ .../{rest => client}/RestTemplateConfig.java | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java rename src/main/java/com/example/solidconnection/config/{rest => client}/RestTemplateConfig.java (91%) diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 9862d0074..2beafc4b4 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -2,9 +2,9 @@ import com.example.solidconnection.auth.dto.kakao.KakaoTokenDto; import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.config.client.KakaoOAuthClientProperties; import com.example.solidconnection.custom.exception.CustomException; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -25,18 +25,7 @@ public class KakaoOAuthClient { private final RestTemplate restTemplate; - - @Value("${kakao.redirect_uri}") - public String redirectUri; - - @Value("${kakao.client_id}") - private String clientId; - - @Value("${kakao.token_url}") - private String tokenUrl; - - @Value("${kakao.user_info_url}") - private String userInfoUrl; + private final KakaoOAuthClientProperties kakaoOAuthClientProperties; /* * 클라이언트에서 사용자가 카카오 로그인을 하면, 클라이언트는 '카카오 인가 코드'를 받아, 서버에 넘겨준다. @@ -74,10 +63,10 @@ private String getKakaoAccessToken(String code) { // 카카오 엑세스 토큰 요청하는 URI 생성 private String buildTokenUri(String code) { - return UriComponentsBuilder.fromHttpUrl(tokenUrl) + return UriComponentsBuilder.fromHttpUrl(kakaoOAuthClientProperties.tokenUrl()) .queryParam("grant_type", "authorization_code") - .queryParam("client_id", clientId) - .queryParam("redirect_uri", redirectUri) + .queryParam("client_id", kakaoOAuthClientProperties.clientId()) + .queryParam("redirect_uri", kakaoOAuthClientProperties.redirectUrl()) .queryParam("code", code) .toUriString(); } @@ -89,7 +78,7 @@ private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { // 사용자의 정보 요청 ResponseEntity response = restTemplate.exchange( - userInfoUrl, + kakaoOAuthClientProperties.userInfoUrl(), HttpMethod.GET, new HttpEntity<>(headers), KakaoUserInfoDto.class diff --git a/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java new file mode 100644 index 000000000..73b196d76 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.kakao") +public record KakaoOAuthClientProperties( + String tokenUrl, + String userInfoUrl, + String redirectUrl, + String clientId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java similarity index 91% rename from src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java rename to src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java index 51f7205be..36ce3f67b 100644 --- a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.rest; +package com.example.solidconnection.config.client; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; From a0b5252dcda3515b8b50195b9024dcf47d54c577 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 10:28:24 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor:=20Oauth=20->=20OAuth=20?= =?UTF-8?q?=EC=9A=A9=EC=96=B4=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/auth/client/KakaoOAuthClient.java | 2 +- .../solidconnection/auth/controller/AuthController.java | 6 +++--- .../example/solidconnection/auth/dto/SignInResponse.java | 4 ++-- .../solidconnection/auth/dto/kakao/FirstAccessResponse.java | 2 +- .../{KakaoOauthResponse.java => KakaoOAuthResponse.java} | 2 +- .../example/solidconnection/auth/service/SignInService.java | 6 +++--- .../java/com/example/solidconnection/e2e/SignInTest.java | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) rename src/main/java/com/example/solidconnection/auth/dto/kakao/{KakaoOauthResponse.java => KakaoOAuthResponse.java} (59%) diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 2beafc4b4..2c05c21ab 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -35,7 +35,7 @@ public class KakaoOAuthClient { * 그리고 카카오 엑세스 토큰으로 카카오 서버에 요청해 '카카오 사용자 정보'를 받아온다. * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info * */ - public KakaoUserInfoDto processOauth(String code) { + public KakaoUserInfoDto processOAuth(String code) { String kakaoAccessToken = getKakaoAccessToken(code); return getKakaoUserInfo(kakaoAccessToken); } diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 1f6415157..47d91fe9d 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -4,7 +4,7 @@ import com.example.solidconnection.auth.dto.SignUpRequest; import com.example.solidconnection.auth.dto.SignUpResponse; import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; +import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; import com.example.solidconnection.auth.service.AuthService; import com.example.solidconnection.auth.service.SignInService; import com.example.solidconnection.auth.service.SignUpService; @@ -31,10 +31,10 @@ public class AuthController { private final SignInService signInService; @PostMapping("/kakao") - public ResponseEntity processKakaoOauth( + public ResponseEntity processKakaoOAuth( @RequestBody KakaoCodeRequest kakaoCodeRequest ) { - KakaoOauthResponse kakaoOauthResponse = signInService.signIn(kakaoCodeRequest); + KakaoOAuthResponse kakaoOauthResponse = signInService.signIn(kakaoCodeRequest); return ResponseEntity.ok(kakaoOauthResponse); } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java index 400491b42..54e306f70 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -1,9 +1,9 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; +import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; public record SignInResponse( boolean isRegistered, String accessToken, - String refreshToken) implements KakaoOauthResponse { + String refreshToken) implements KakaoOAuthResponse { } diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java index 6d7130bf0..4a68c7268 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java @@ -5,7 +5,7 @@ public record FirstAccessResponse( String nickname, String email, String profileImageUrl, - String kakaoOauthToken) implements KakaoOauthResponse { + String kakaoOauthToken) implements KakaoOAuthResponse { public static FirstAccessResponse of(KakaoUserInfoDto kakaoUserInfoDto, String kakaoOauthToken) { return new FirstAccessResponse( diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java similarity index 59% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java rename to src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java index 1e2320e35..38c6a564a 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java @@ -1,4 +1,4 @@ package com.example.solidconnection.auth.dto.kakao; -public interface KakaoOauthResponse { +public interface KakaoOAuthResponse { } diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/SignInService.java index 8ca39eb62..bcb6cea8b 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignInService.java @@ -4,7 +4,7 @@ import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; +import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; @@ -35,8 +35,8 @@ public class SignInService { * - 회원가입할 때 클라이언트는 이때 발급받은 kakaoOauthToken 를 요청에 포함해 요청한다. (SignUpService 참고) * */ @Transactional - public KakaoOauthResponse signIn(KakaoCodeRequest kakaoCodeRequest) { - KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.processOauth(kakaoCodeRequest.code()); + public KakaoOAuthResponse signIn(KakaoCodeRequest kakaoCodeRequest) { + KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.processOAuth(kakaoCodeRequest.code()); String email = kakaoUserInfoDto.kakaoAccountDto().email(); Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO); diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java index 8d3ddc75f..9d7fca7f4 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignInTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -45,7 +45,7 @@ class SignInTest extends BaseEndToEndTest { String kakaoCode = "kakaoCode"; String email = "email@email.com"; KakaoUserInfoDto kakaoUserInfoDto = createKakaoUserInfoDtoByEmail(email); - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.processOAuth(kakaoCode)) .willReturn(kakaoUserInfoDto); // request - body 생성 및 요청 @@ -75,7 +75,7 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.processOAuth(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 사용자 정보 저장 @@ -105,7 +105,7 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.processOAuth(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 계정 복구 기간이 되지 않은 사용자 저장 From 49cdfead60fb857cba20ac282f2e241b433c6951 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:09:08 +0900 Subject: [PATCH 03/21] =?UTF-8?q?refactor:=20SignInService=20=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EB=A7=8C=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/OAuthSignInResponse.java | 9 ++ .../auth/dto/SignInResponse.java | 6 +- .../auth/service/KakaoOAuthService.java | 61 +++++++++++++ .../auth/service/SignInService.java | 55 ++---------- .../auth/service/SignInServiceTest.java | 88 +++++++++++++++++++ 5 files changed, 166 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java create mode 100644 src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java create mode 100644 src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java diff --git a/src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java new file mode 100644 index 000000000..1e2d6cbfa --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.auth.dto; + +import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; + +public record OAuthSignInResponse( + boolean isRegistered, + String accessToken, + String refreshToken) implements KakaoOAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java index 54e306f70..a4ae442e2 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -1,9 +1,7 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; - public record SignInResponse( - boolean isRegistered, String accessToken, - String refreshToken) implements KakaoOAuthResponse { + String refreshToken +) { } diff --git a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java new file mode 100644 index 000000000..6871c8ebc --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.client.KakaoOAuthClient; +import com.example.solidconnection.auth.dto.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; +import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; +import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; +import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/* + * 카카오에서 받아온 사용자 정보에 있는 이메일을 통해 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. + * 기존 회원 : 로그인 + * - 우리 서비스의 탈퇴 회원 방침을 적용한다. (계정 복구 기간 안에 접속하면 탈퇴를 무효화) + * - 액세스 토큰과 리프레시 토큰을 발급한다. + * 신규 회원 : 회원가입 페이지로 리다이렉트할 때 필요한 정보 제공 + * - 회원가입 시 입력하는 '닉네임'과 '프로필 사진' 부분을 미리 채우기 위해 사용자 정보를 리턴한다. + * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'kakaoOauthToken' 을 발급해서 응답한다. + * - 회원가입할 때 클라이언트는 이때 발급받은 kakaoOauthToken 를 요청에 포함해 요청한다. (SignUpService 참고) + * */ +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final AuthTokenProvider authTokenProvider; + private final SignUpTokenProvider signUpTokenProvider; + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + private final KakaoOAuthClient kakaoOAuthClient; + + @Transactional + public KakaoOAuthResponse processOAuth(KakaoCodeRequest kakaoCodeRequest) { + KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.getUserInfo(kakaoCodeRequest.code()); + String email = kakaoUserInfoDto.kakaoAccountDto().email(); + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO); + + if (optionalSiteUser.isPresent()) { + return getSignInInfo(optionalSiteUser.get()); + } + + return getFirstAccessInfo(kakaoUserInfoDto); + } + + private OAuthSignInResponse getSignInInfo(SiteUser siteUser) { + SignInResponse signInResponse = signInService.signIn(siteUser); + return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); + } + + private FirstAccessResponse getFirstAccessInfo(KakaoUserInfoDto kakaoUserInfoDto) { + String kakaoOauthToken = signUpTokenProvider.generateAndSaveSignUpToken(kakaoUserInfoDto.kakaoAccountDto().email()); + return FirstAccessResponse.of(kakaoUserInfoDto, kakaoOauthToken); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/SignInService.java index bcb6cea8b..820d2e573 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignInService.java @@ -1,72 +1,29 @@ package com.example.solidconnection.auth.service; -import com.example.solidconnection.auth.client.KakaoOAuthClient; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; -import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -@RequiredArgsConstructor @Service +@RequiredArgsConstructor public class SignInService { private final AuthTokenProvider authTokenProvider; - private final SignUpTokenProvider signUpTokenProvider; - private final SiteUserRepository siteUserRepository; - private final KakaoOAuthClient kakaoOAuthClient; - /* - * 카카오에서 받아온 사용자 정보에 있는 이메일을 통해 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. - * 기존 회원 : 로그인 - * - 우리 서비스의 탈퇴 회원 방침을 적용한다. (계정 복구 기간 안에 접속하면 탈퇴를 무효화) - * - 액세스 토큰과 리프레시 토큰을 발급한다. - * 신규 회원 : 회원가입 페이지로 리다이렉트할 때 필요한 정보 제공 - * - 회원가입 시 입력하는 '닉네임'과 '프로필 사진' 부분을 미리 채우기 위해 사용자 정보를 리턴한다. - * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'kakaoOauthToken' 을 발급해서 응답한다. - * - 회원가입할 때 클라이언트는 이때 발급받은 kakaoOauthToken 를 요청에 포함해 요청한다. (SignUpService 참고) - * */ @Transactional - public KakaoOAuthResponse signIn(KakaoCodeRequest kakaoCodeRequest) { - KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.processOAuth(kakaoCodeRequest.code()); - String email = kakaoUserInfoDto.kakaoAccountDto().email(); - Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO); - - if (optionalSiteUser.isPresent()) { - SiteUser siteUser = optionalSiteUser.get(); - resetQuitedAt(siteUser); - return getSignInInfo(siteUser); - } - - return getFirstAccessInfo(kakaoUserInfoDto); + public SignInResponse signIn(SiteUser siteUser) { + resetQuitedAt(siteUser); + String accessToken = authTokenProvider.generateAccessToken(siteUser); + String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + return new SignInResponse(accessToken, refreshToken); } - // 계적 복구 기한이 지난 회원은 자정마다 삭제된다. (UserRemovalScheduler 참고) - // 따라서 DB 에서 조회되었다면 아직 기한이 지나지 않았다는 뜻이므로, 탈퇴 날짜를 초기화한다. private void resetQuitedAt(SiteUser siteUser) { if (siteUser.getQuitedAt() == null) { return; } - siteUser.setQuitedAt(null); } - - private SignInResponse getSignInInfo(SiteUser siteUser) { - String accessToken = authTokenProvider.generateAccessToken(siteUser); - String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); - return new SignInResponse(true, accessToken, refreshToken); - } - - private FirstAccessResponse getFirstAccessInfo(KakaoUserInfoDto kakaoUserInfoDto) { - String kakaoOauthToken = signUpTokenProvider.generateAndSaveSignUpToken(kakaoUserInfoDto.kakaoAccountDto().email()); - return FirstAccessResponse.of(kakaoUserInfoDto, kakaoOauthToken); - } } diff --git a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java new file mode 100644 index 000000000..b80c4ca5d --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.JwtUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("로그인 서비스 테스트") +@TestContainerSpringBootTest +class SignInServiceTest { + + @Autowired + private SignInService signInService; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + private String subject; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + subject = siteUser.getId().toString(); + } + + @Test + void 성공적으로_로그인한다() { + // when + SignInResponse signInResponse = signInService.signIn(siteUser); + + // then + String accessTokenSubject = JwtUtils.parseSubject(signInResponse.accessToken(), jwtProperties.secret()); + String refreshTokenSubject = JwtUtils.parseSubject(signInResponse.refreshToken(), jwtProperties.secret()); + Optional savedRefreshToken = authTokenProvider.findRefreshToken(subject); + assertAll( + () -> assertThat(accessTokenSubject).isEqualTo(subject), + () -> assertThat(refreshTokenSubject).isEqualTo(subject), + () -> assertThat(savedRefreshToken).hasValue(signInResponse.refreshToken())); + } + + @Test + void 탈퇴한_이력이_있으면_초기화한다() { + // given + siteUser.setQuitedAt(LocalDate.now().minusDays(1)); + siteUserRepository.save(siteUser); + + // when + signInService.signIn(siteUser); + + // then + assertThat(siteUser.getQuitedAt()).isNull(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} From 0b9e7c53dd9e078afa75c44b06f1f15fe89826ef Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:09:33 +0900 Subject: [PATCH 04/21] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20=EC=9D=98=EB=AF=B8=EA=B0=80=20=EB=93=9C=EB=9F=AC?= =?UTF-8?q?=EB=82=98=EB=8F=84=EB=A1=9D=20=ED=95=A8=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/KakaoOAuthClient.java | 2 +- .../auth/controller/AuthController.java | 6 +++--- .../example/solidconnection/e2e/SignInTest.java | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 2c05c21ab..132f93a9b 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -35,7 +35,7 @@ public class KakaoOAuthClient { * 그리고 카카오 엑세스 토큰으로 카카오 서버에 요청해 '카카오 사용자 정보'를 받아온다. * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info * */ - public KakaoUserInfoDto processOAuth(String code) { + public KakaoUserInfoDto getUserInfo(String code) { String kakaoAccessToken = getKakaoAccessToken(code); return getKakaoUserInfo(kakaoAccessToken); } diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 47d91fe9d..7481a4f4f 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -6,7 +6,7 @@ import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; import com.example.solidconnection.auth.service.AuthService; -import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.auth.service.KakaoOAuthService; import com.example.solidconnection.auth.service.SignUpService; import com.example.solidconnection.custom.resolver.AuthorizedUser; import com.example.solidconnection.custom.resolver.ExpiredToken; @@ -28,13 +28,13 @@ public class AuthController { private final AuthService authService; private final SignUpService signUpService; - private final SignInService signInService; + private final KakaoOAuthService kakaoOAuthService; @PostMapping("/kakao") public ResponseEntity processKakaoOAuth( @RequestBody KakaoCodeRequest kakaoCodeRequest ) { - KakaoOAuthResponse kakaoOauthResponse = signInService.signIn(kakaoCodeRequest); + KakaoOAuthResponse kakaoOauthResponse = kakaoOAuthService.processOAuth(kakaoCodeRequest); return ResponseEntity.ok(kakaoOauthResponse); } diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java index 9d7fca7f4..b324f1955 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignInTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -1,7 +1,7 @@ package com.example.solidconnection.e2e; import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.OAuthSignInResponse; import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; @@ -45,7 +45,7 @@ class SignInTest extends BaseEndToEndTest { String kakaoCode = "kakaoCode"; String email = "email@email.com"; KakaoUserInfoDto kakaoUserInfoDto = createKakaoUserInfoDtoByEmail(email); - given(kakaoOAuthClient.processOAuth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(kakaoUserInfoDto); // request - body 생성 및 요청 @@ -75,7 +75,7 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOAuth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 사용자 정보 저장 @@ -83,13 +83,13 @@ class SignInTest extends BaseEndToEndTest { // request - body 생성 및 요청 KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - SignInResponse response = RestAssured.given().log().all() + OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) .body(kakaoCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignInResponse.class); + .extract().as(OAuthSignInResponse.class); assertAll("리프레스 토큰과 엑세스 토큰을 응답한다.", () -> assertThat(response.isRegistered()).isTrue(), @@ -105,7 +105,7 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOAuth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 계정 복구 기간이 되지 않은 사용자 저장 @@ -116,13 +116,13 @@ class SignInTest extends BaseEndToEndTest { // request - body 생성 및 요청 KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - SignInResponse response = RestAssured.given().log().all() + OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) .body(kakaoCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignInResponse.class); + .extract().as(OAuthSignInResponse.class); SiteUser updatedSiteUser = siteUserRepository.findById(siteUser.getId()).get(); assertAll("리프레스 토큰과 엑세스 토큰을 응답하고, 탈퇴 날짜를 초기화한다.", From 84ccc90a73bbc170d4a233406fd0914469d125a3 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:17:23 +0900 Subject: [PATCH 05/21] =?UTF-8?q?refactor:=20=EB=8B=A4=EC=96=91=ED=95=9C?= =?UTF-8?q?=20OAuth=20=EC=A2=85=EB=A5=98=EB=A5=BC=20=ED=8F=AC=EA=B4=84?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/KakaoOAuthClient.java | 4 +-- .../auth/controller/AuthController.java | 12 ++++----- .../auth/dto/OAuthSignInResponse.java | 9 ------- .../auth/dto/SignUpRequest.java | 2 +- .../auth/dto/kakao/FirstAccessResponse.java | 19 -------------- .../auth/dto/kakao/KakaoCodeRequest.java | 5 ---- .../auth/dto/kakao/KakaoOAuthResponse.java | 4 --- .../dto/{kakao => oauth}/KakaoTokenDto.java | 2 +- .../{kakao => oauth}/KakaoUserInfoDto.java | 20 +++++++++++++-- .../auth/dto/oauth/OAuthCodeRequest.java | 5 ++++ .../auth/dto/oauth/OAuthResponse.java | 4 +++ .../auth/dto/oauth/OAuthSignInResponse.java | 7 ++++++ .../auth/dto/oauth/OAuthUserInfoDto.java | 10 ++++++++ .../auth/dto/oauth/SignUpPrepareResponse.java | 19 ++++++++++++++ .../auth/service/KakaoOAuthService.java | 25 +++++++++---------- .../auth/service/SignUpService.java | 6 ++--- .../solidconnection/e2e/DynamicFixture.java | 2 +- .../solidconnection/e2e/SignInTest.java | 24 +++++++++--------- 18 files changed, 101 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java delete mode 100644 src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java delete mode 100644 src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java delete mode 100644 src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java rename src/main/java/com/example/solidconnection/auth/dto/{kakao => oauth}/KakaoTokenDto.java (85%) rename src/main/java/com/example/solidconnection/auth/dto/{kakao => oauth}/KakaoUserInfoDto.java (61%) create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 132f93a9b..206365b1b 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -1,7 +1,7 @@ package com.example.solidconnection.auth.client; -import com.example.solidconnection.auth.dto.kakao.KakaoTokenDto; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.KakaoTokenDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.config.client.KakaoOAuthClientProperties; import com.example.solidconnection.custom.exception.CustomException; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 7481a4f4f..0a136343b 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -3,8 +3,8 @@ import com.example.solidconnection.auth.dto.ReissueResponse; import com.example.solidconnection.auth.dto.SignUpRequest; import com.example.solidconnection.auth.dto.SignUpResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.service.AuthService; import com.example.solidconnection.auth.service.KakaoOAuthService; import com.example.solidconnection.auth.service.SignUpService; @@ -31,11 +31,11 @@ public class AuthController { private final KakaoOAuthService kakaoOAuthService; @PostMapping("/kakao") - public ResponseEntity processKakaoOAuth( - @RequestBody KakaoCodeRequest kakaoCodeRequest + public ResponseEntity processKakaoOAuth( + @RequestBody OAuthCodeRequest oAuthCodeRequest ) { - KakaoOAuthResponse kakaoOauthResponse = kakaoOAuthService.processOAuth(kakaoCodeRequest); - return ResponseEntity.ok(kakaoOauthResponse); + OAuthResponse oAuthResponse = kakaoOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); } @PostMapping("/sign-up") diff --git a/src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java deleted file mode 100644 index 1e2d6cbfa..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/OAuthSignInResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.solidconnection.auth.dto; - -import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; - -public record OAuthSignInResponse( - boolean isRegistered, - String accessToken, - String refreshToken) implements KakaoOAuthResponse { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index fcb68cad1..96b84fcfb 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -10,7 +10,7 @@ import java.util.List; public record SignUpRequest( - String kakaoOauthToken, + String signUpToken, List interestedRegions, List interestedCountries, PreparationStatus preparationStatus, diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java deleted file mode 100644 index 4a68c7268..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public record FirstAccessResponse( - boolean isRegistered, - String nickname, - String email, - String profileImageUrl, - String kakaoOauthToken) implements KakaoOAuthResponse { - - public static FirstAccessResponse of(KakaoUserInfoDto kakaoUserInfoDto, String kakaoOauthToken) { - return new FirstAccessResponse( - false, - kakaoUserInfoDto.kakaoAccountDto().profile().nickname(), - kakaoUserInfoDto.kakaoAccountDto().email(), - kakaoUserInfoDto.kakaoAccountDto().profile().profileImageUrl(), - kakaoOauthToken - ); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java deleted file mode 100644 index 4fcfc5576..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public record KakaoCodeRequest( - String code) { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java deleted file mode 100644 index 38c6a564a..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOAuthResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public interface KakaoOAuthResponse { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java similarity index 85% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java rename to src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java index 767645e3b..6d4ccd10c 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.dto.kakao; +package com.example.solidconnection.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java similarity index 61% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java rename to src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java index 85aea091d..fbd975b50 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java @@ -1,11 +1,11 @@ -package com.example.solidconnection.auth.dto.kakao; +package com.example.solidconnection.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record KakaoUserInfoDto( - @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) { + @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) implements OAuthUserInfoDto { @JsonIgnoreProperties(ignoreUnknown = true) public record KakaoAccountDto( @@ -16,6 +16,22 @@ public record KakaoAccountDto( public record KakaoProfileDto( @JsonProperty("profile_image_url") String profileImageUrl, String nickname) { + } } + + @Override + public String getEmail() { + return kakaoAccountDto.email; + } + + @Override + public String getProfileImageUrl() { + return kakaoAccountDto.profile.profileImageUrl; + } + + @Override + public String getNickname() { + return kakaoAccountDto.profile.nickname; + } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java new file mode 100644 index 000000000..49ed95a13 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record OAuthCodeRequest( + String code) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java new file mode 100644 index 000000000..ddbe121f7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java @@ -0,0 +1,4 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java new file mode 100644 index 000000000..8ad429876 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record OAuthSignInResponse( + boolean isRegistered, + String accessToken, + String refreshToken) implements OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java new file mode 100644 index 000000000..ed794851b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthUserInfoDto { + + String getEmail(); + + String getProfileImageUrl(); + + String getNickname(); +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java new file mode 100644 index 000000000..081793503 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record SignUpPrepareResponse( + boolean isRegistered, + String nickname, + String email, + String profileImageUrl, + String kakaoOauthToken) implements OAuthResponse { + + public static SignUpPrepareResponse of(OAuthUserInfoDto oAuthUserInfoDto, String kakaoOauthToken) { + return new SignUpPrepareResponse( + false, + oAuthUserInfoDto.getNickname(), + oAuthUserInfoDto.getEmail(), + oAuthUserInfoDto.getProfileImageUrl(), + kakaoOauthToken + ); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java index 6871c8ebc..3a06adac1 100644 --- a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java @@ -1,12 +1,12 @@ package com.example.solidconnection.auth.service; import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOAuthResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -23,22 +23,21 @@ * - 액세스 토큰과 리프레시 토큰을 발급한다. * 신규 회원 : 회원가입 페이지로 리다이렉트할 때 필요한 정보 제공 * - 회원가입 시 입력하는 '닉네임'과 '프로필 사진' 부분을 미리 채우기 위해 사용자 정보를 리턴한다. - * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'kakaoOauthToken' 을 발급해서 응답한다. - * - 회원가입할 때 클라이언트는 이때 발급받은 kakaoOauthToken 를 요청에 포함해 요청한다. (SignUpService 참고) + * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'signUpToken' 을 발급해서 응답한다. + * - 회원가입할 때 클라이언트는 이때 발급받은 signUpToken 를 요청에 포함해 요청한다. (SignUpService 참고) * */ @Service @RequiredArgsConstructor public class KakaoOAuthService { - private final AuthTokenProvider authTokenProvider; private final SignUpTokenProvider signUpTokenProvider; private final SignInService signInService; private final SiteUserRepository siteUserRepository; private final KakaoOAuthClient kakaoOAuthClient; @Transactional - public KakaoOAuthResponse processOAuth(KakaoCodeRequest kakaoCodeRequest) { - KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.getUserInfo(kakaoCodeRequest.code()); + public OAuthResponse processOAuth(OAuthCodeRequest oAuthCodeRequest) { + KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.getUserInfo(oAuthCodeRequest.code()); String email = kakaoUserInfoDto.kakaoAccountDto().email(); Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO); @@ -46,7 +45,7 @@ public KakaoOAuthResponse processOAuth(KakaoCodeRequest kakaoCodeRequest) { return getSignInInfo(optionalSiteUser.get()); } - return getFirstAccessInfo(kakaoUserInfoDto); + return getSignUpPrepareResponse(kakaoUserInfoDto); } private OAuthSignInResponse getSignInInfo(SiteUser siteUser) { @@ -54,8 +53,8 @@ private OAuthSignInResponse getSignInInfo(SiteUser siteUser) { return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); } - private FirstAccessResponse getFirstAccessInfo(KakaoUserInfoDto kakaoUserInfoDto) { + private SignUpPrepareResponse getSignUpPrepareResponse(KakaoUserInfoDto kakaoUserInfoDto) { String kakaoOauthToken = signUpTokenProvider.generateAndSaveSignUpToken(kakaoUserInfoDto.kakaoAccountDto().email()); - return FirstAccessResponse.of(kakaoUserInfoDto, kakaoOauthToken); + return SignUpPrepareResponse.of(kakaoUserInfoDto, kakaoOauthToken); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java index 788b07e44..34bbc95e0 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java @@ -36,7 +36,7 @@ public class SignUpService { /* * 회원가입을 한다. - * - 카카오로 최초 로그인 시 우리 서비스에서 발급한 카카오 토큰 kakaoOauthToken 을 검증한다. + * - 카카오로 최초 로그인 시 우리 서비스에서 발급한 카카오 토큰 signUpToken 을 검증한다. * - 이는 '카카오 인증을 하지 않고 회원가입 api 만으로 회원가입 하는 상황'을 방지하기 위함이다. * - 만약 api 만으로 회원가입을 한다면, 카카오 인증과 이메일에 대한 검증 없이 회원가입이 가능해진다. * - 이메일은 우리 서비스에서 사용자를 식별하는 중요한 정보이기 때문에 '우리 서비스에서 발급한 카카오 토큰인지 검증하는' 단계가 필요하다. @@ -49,8 +49,8 @@ public class SignUpService { @Transactional public SignUpResponse signUp(SignUpRequest signUpRequest) { // 검증 - tokenValidator.validateKakaoToken(signUpRequest.kakaoOauthToken()); - String email = authTokenProvider.getEmail(signUpRequest.kakaoOauthToken()); + tokenValidator.validateKakaoToken(signUpRequest.signUpToken()); + String email = authTokenProvider.getEmail(signUpRequest.signUpToken()); validateNicknameDuplicated(signUpRequest.nickname()); validateUserNotDuplicated(email); diff --git a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java index a549b62a2..09c97d46e 100644 --- a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java +++ b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java @@ -2,7 +2,7 @@ import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.LanguageTestType; diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java index b324f1955..c9ed4b6ae 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignInTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -1,10 +1,10 @@ package com.example.solidconnection.e2e; import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.OAuthSignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import io.restassured.RestAssured; @@ -49,14 +49,14 @@ class SignInTest extends BaseEndToEndTest { .willReturn(kakaoUserInfoDto); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - FirstAccessResponse response = RestAssured.given().log().all() + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + SignUpPrepareResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(OAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(FirstAccessResponse.class); + .extract().as(SignUpPrepareResponse.class); KakaoUserInfoDto.KakaoAccountDto.KakaoProfileDto kakaoProfileDto = kakaoUserInfoDto.kakaoAccountDto().profile(); assertAll("카카오톡 사용자 정보를 응답한다.", @@ -82,10 +82,10 @@ class SignInTest extends BaseEndToEndTest { SiteUser siteUser = siteUserRepository.save(createSiteUserByEmail(email)); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); + OAuthCodeRequest oAuthCodeRequest = new OAuthCodeRequest(kakaoCode); OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(oAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) @@ -115,10 +115,10 @@ class SignInTest extends BaseEndToEndTest { SiteUser siteUser = siteUserRepository.save(siteUserFixture); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(OAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) From b761b9b54c9bb8507a8bfae8ee3dd2bacd34e7f9 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:27:00 +0900 Subject: [PATCH 06/21] =?UTF-8?q?refactor:=20OAuthService=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/KakaoOAuthService.java | 55 ++++------------ .../auth/service/OAuthService.java | 63 +++++++++++++++++++ 2 files changed, 75 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/auth/service/OAuthService.java diff --git a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java index 3a06adac1..6da377ba8 100644 --- a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java @@ -1,60 +1,29 @@ package com.example.solidconnection.auth.service; import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; -import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; -import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; -import com.example.solidconnection.auth.dto.oauth.OAuthResponse; -import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -/* - * 카카오에서 받아온 사용자 정보에 있는 이메일을 통해 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. - * 기존 회원 : 로그인 - * - 우리 서비스의 탈퇴 회원 방침을 적용한다. (계정 복구 기간 안에 접속하면 탈퇴를 무효화) - * - 액세스 토큰과 리프레시 토큰을 발급한다. - * 신규 회원 : 회원가입 페이지로 리다이렉트할 때 필요한 정보 제공 - * - 회원가입 시 입력하는 '닉네임'과 '프로필 사진' 부분을 미리 채우기 위해 사용자 정보를 리턴한다. - * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'signUpToken' 을 발급해서 응답한다. - * - 회원가입할 때 클라이언트는 이때 발급받은 signUpToken 를 요청에 포함해 요청한다. (SignUpService 참고) - * */ @Service -@RequiredArgsConstructor -public class KakaoOAuthService { +public class KakaoOAuthService extends OAuthService { - private final SignUpTokenProvider signUpTokenProvider; - private final SignInService signInService; - private final SiteUserRepository siteUserRepository; private final KakaoOAuthClient kakaoOAuthClient; - @Transactional - public OAuthResponse processOAuth(OAuthCodeRequest oAuthCodeRequest) { - KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.getUserInfo(oAuthCodeRequest.code()); - String email = kakaoUserInfoDto.kakaoAccountDto().email(); - Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO); - - if (optionalSiteUser.isPresent()) { - return getSignInInfo(optionalSiteUser.get()); - } - - return getSignUpPrepareResponse(kakaoUserInfoDto); + public KakaoOAuthService(SignUpTokenProvider signUpTokenProvider, SiteUserRepository siteUserRepository, + KakaoOAuthClient kakaoOAuthClient, SignInService signInService) { + super(signUpTokenProvider, siteUserRepository, signInService); + this.kakaoOAuthClient = kakaoOAuthClient; } - private OAuthSignInResponse getSignInInfo(SiteUser siteUser) { - SignInResponse signInResponse = signInService.signIn(siteUser); - return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return kakaoOAuthClient.getUserInfo(code); } - private SignUpPrepareResponse getSignUpPrepareResponse(KakaoUserInfoDto kakaoUserInfoDto) { - String kakaoOauthToken = signUpTokenProvider.generateAndSaveSignUpToken(kakaoUserInfoDto.kakaoAccountDto().email()); - return SignUpPrepareResponse.of(kakaoUserInfoDto, kakaoOauthToken); + @Override + protected AuthType getAuthType() { + return AuthType.KAKAO; } } diff --git a/src/main/java/com/example/solidconnection/auth/service/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/OAuthService.java new file mode 100644 index 000000000..9d637aba4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/OAuthService.java @@ -0,0 +1,63 @@ +package com.example.solidconnection.auth.service; + + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + + +/* + * OAuth 제공자로부터 이메일을 받아 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. + * 기존 회원 + * - 로그인한다. + * 신규 회원 + * - 회원가입 페이지로 리다이렉트할 때 필요한 정보를 제공한다. + * */ +public abstract class OAuthService { + + private final SignUpTokenProvider signUpTokenProvider; + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + + protected OAuthService(SignUpTokenProvider signUpTokenProvider, SiteUserRepository siteUserRepository, SignInService signInService) { + this.signUpTokenProvider = signUpTokenProvider; + this.siteUserRepository = siteUserRepository; + this.signInService = signInService; + } + + @Transactional + public OAuthResponse processOAuth(OAuthCodeRequest oauthCodeRequest) { + OAuthUserInfoDto userInfoDto = getOAuthUserInfo(oauthCodeRequest.code()); + String email = userInfoDto.getEmail(); + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, getAuthType()); + + if (optionalSiteUser.isPresent()) { + SiteUser siteUser = optionalSiteUser.get(); + return getSignInInfo(siteUser); + } + + return getSignUpPrepareResponse(userInfoDto); + } + + protected OAuthSignInResponse getSignInInfo(SiteUser siteUser) { + SignInResponse signInResponse = signInService.signIn(siteUser); + return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); + } + + protected SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail()); + return SignUpPrepareResponse.of(userInfoDto, signUpToken); + } + + protected abstract OAuthUserInfoDto getOAuthUserInfo(String code); + protected abstract AuthType getAuthType(); +} From 8c3f986f34cd1fc13ea2f5494fe4fc8dec625269 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:28:48 +0900 Subject: [PATCH 07/21] =?UTF-8?q?refactor:=20oauth=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/auth/controller/AuthController.java | 2 +- .../auth/service/{ => oauth}/KakaoOAuthService.java | 3 ++- .../solidconnection/auth/service/{ => oauth}/OAuthService.java | 3 ++- .../auth/service/{ => oauth}/SignUpTokenProvider.java | 3 ++- .../solidconnection/auth/service/SignUpTokenProviderTest.java | 1 + src/test/java/com/example/solidconnection/e2e/SignUpTest.java | 2 +- 6 files changed, 9 insertions(+), 5 deletions(-) rename src/main/java/com/example/solidconnection/auth/service/{ => oauth}/KakaoOAuthService.java (89%) rename src/main/java/com/example/solidconnection/auth/service/{ => oauth}/OAuthService.java (95%) rename src/main/java/com/example/solidconnection/auth/service/{ => oauth}/SignUpTokenProvider.java (88%) diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 0a136343b..c30925458 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -6,7 +6,7 @@ import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.service.AuthService; -import com.example.solidconnection.auth.service.KakaoOAuthService; +import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; import com.example.solidconnection.auth.service.SignUpService; import com.example.solidconnection.custom.resolver.AuthorizedUser; import com.example.solidconnection.custom.resolver.ExpiredToken; diff --git a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java similarity index 89% rename from src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java rename to src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java index 6da377ba8..5dc6faea1 100644 --- a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java @@ -1,7 +1,8 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.oauth; import com.example.solidconnection.auth.client.KakaoOAuthClient; import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/example/solidconnection/auth/service/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java similarity index 95% rename from src/main/java/com/example/solidconnection/auth/service/OAuthService.java rename to src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java index 9d637aba4..f75397cf6 100644 --- a/src/main/java/com/example/solidconnection/auth/service/OAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.oauth; import com.example.solidconnection.auth.dto.SignInResponse; @@ -7,6 +7,7 @@ import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.service.SignInService; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java similarity index 88% rename from src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java rename to src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java index f04bf112b..20a6af613 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java @@ -1,6 +1,7 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.oauth; import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.service.TokenProvider; import com.example.solidconnection.config.security.JwtProperties; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; diff --git a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java index 382008d8c..d8ac35717 100644 --- a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java @@ -1,6 +1,7 @@ package com.example.solidconnection.auth.service; import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.service.oauth.SignUpTokenProvider; import com.example.solidconnection.config.security.JwtProperties; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.util.JwtUtils; diff --git a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java index 1bbe150a8..0a9090f8e 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java @@ -3,7 +3,7 @@ import com.example.solidconnection.auth.dto.SignUpRequest; import com.example.solidconnection.auth.dto.SignUpResponse; import com.example.solidconnection.auth.service.AuthTokenProvider; -import com.example.solidconnection.auth.service.SignUpTokenProvider; +import com.example.solidconnection.auth.service.oauth.SignUpTokenProvider; import com.example.solidconnection.custom.response.ErrorResponse; import com.example.solidconnection.entity.Country; import com.example.solidconnection.entity.InterestedCountry; From 4afb17d7c5cb30d21161c8e2e340f0849791ce10 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Wed, 5 Feb 2025 13:43:19 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20OAuth=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A6=AC=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/client/AppleOAuthClientProperties.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java new file mode 100644 index 000000000..b0f9aa2b8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.apple") +public record AppleOAuthClientProperties( + String tokenUrl, + String clientSecretAudienceUrl, + String redirectUrl, + String clientId, + String teamId, + String keyId +) { +} From e21d8ad046475c80b80c9ec5283bd57051923167 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:36:49 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20client=20sec?= =?UTF-8?q?ret=20=EC=83=9D=EC=84=B1=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppleOAuthClientSecretProvider.java | 71 +++++++++++++++++++ .../custom/exception/ErrorCode.java | 6 ++ 2 files changed, 77 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java new file mode 100644 index 000000000..00a144589 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -0,0 +1,71 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.apache.tomcat.util.codec.binary.Base64; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; + +/* + * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClientSecretProvider { + + private static final String KEY_ID_HEADER = "kid"; + private static final long TOKEN_DURATION = 1000 * 60; // 10min + private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8"; + + private final AppleOAuthClientProperties appleOAuthClientProperties; + private PrivateKey privateKey; + + @PostConstruct + private void initPrivateKey() { + privateKey = readPrivateKey(); + } + + public String generateClientSecret() { + Date now = new Date(); + Date expiration = new Date(now.getTime() + TOKEN_DURATION); + + return Jwts.builder() + .setHeaderParam("alg", "ES256") + .setHeaderParam(KEY_ID_HEADER, appleOAuthClientProperties.keyId()) + .setSubject(appleOAuthClientProperties.clientId()) + .setIssuer(appleOAuthClientProperties.teamId()) + .setAudience(appleOAuthClientProperties.clientSecretAudienceUrl()) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.ES256, privateKey) + .compact(); + } + + private PrivateKey readPrivateKey() { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(SECRET_KEY_PATH); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + + String secretKey = reader.lines().collect(Collectors.joining("\n")); + byte[] encoded = Base64.decodeBase64(secretKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new CustomException(FAILED_TO_READ_APPLE_PRIVATE_KEY); + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index 8c3032284..fd07a8c5f 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -11,6 +11,12 @@ @AllArgsConstructor public enum ErrorCode { + // apple + APPLE_AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST.value(), "애플 인증에 실패했습니다."), + APPLE_ID_TOKEN_MISSING_EMAIL(HttpStatus.BAD_REQUEST.value(), "애플 idToken 에 email 이 포함되어 있지 않습니다."), + FAILED_TO_READ_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 private key 파일을 읽을 수 없습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 client secret JWT 생성에 실패했습니다."), + // kakao KAKAO_REDIRECT_URI_MISMATCH(HttpStatus.BAD_REQUEST.value(), "리다이렉트 uri가 잘못되었습니다."), INVALID_OR_EXPIRED_KAKAO_AUTH_CODE(HttpStatus.BAD_REQUEST.value(), "사용할 수 없는 카카오 인증 코드입니다. 카카오 인증 코드는 일회용이며, 인증 만료 시간은 10분입니다."), From 34bf7a6bf164af6ed4c8b67e0e21df5c2037096c Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:38:40 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20OAuthClient?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/AppleOAuthClient.java | 78 +++++++++++++++++++ .../auth/dto/oauth/AppleTokenDto.java | 10 +++ .../auth/dto/oauth/AppleUserInfoDto.java | 19 +++++ 3 files changed, 107 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java create mode 100644 src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java new file mode 100644 index 000000000..11b330b0b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -0,0 +1,78 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.auth.dto.oauth.AppleTokenDto; +import com.example.solidconnection.auth.dto.oauth.AppleUserInfoDto; +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_MISSING_EMAIL; + +/* + * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClient { + + private final RestTemplate restTemplate; + private final AppleOAuthClientProperties properties; + private final AppleOAuthClientSecretProvider clientSecretProvider; + + public AppleUserInfoDto processOAuth(String code) { + String idToken = requestIdToken(code); + return new AppleUserInfoDto(parseEmailFromToken(idToken)); + } + + public String requestIdToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap formData = buildFormData(code); + + try { + ResponseEntity response = restTemplate.exchange( + properties.tokenUrl(), + HttpMethod.POST, + new HttpEntity<>(formData, headers), + AppleTokenDto.class + ); + return Objects.requireNonNull(response.getBody()).idToken(); + } catch (Exception e) { + throw new CustomException(APPLE_AUTHORIZATION_FAILED, e.getMessage()); + } + } + + private MultiValueMap buildFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", properties.clientId()); + formData.add("client_secret", clientSecretProvider.generateClientSecret()); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", properties.redirectUrl()); + return formData; + } + + private String parseEmailFromToken(String idToken) { + try { + return Jwts.parser() + .parseClaimsJwt(idToken) + .getBody() + .get("email", String.class); + } catch (Exception e) { + throw new CustomException(APPLE_ID_TOKEN_MISSING_EMAIL); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java new file mode 100644 index 000000000..6772cb2c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppleTokenDto( + @JsonProperty("id_token") String idToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java new file mode 100644 index 000000000..60a60131d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record AppleUserInfoDto(String email) implements OAuthUserInfoDto { + + @Override + public String getEmail() { + return email; + } + + @Override + public String getProfileImageUrl() { + return null; + } + + @Override + public String getNickname() { + return null; + } +} From 14f3510b624aa08b1f47cea6d42c95b2e74f996f Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:44:20 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20OAuthService?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/oauth/AppleOAuthService.java | 30 +++++++++++++++++++ .../auth/service/oauth/OAuthService.java | 8 ++--- 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java new file mode 100644 index 000000000..2af82e07d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.AppleOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class AppleOAuthService extends OAuthService { + + private final AppleOAuthClient appleOAuthClient; + + public AppleOAuthService(SignUpTokenProvider signUpTokenProvider, SiteUserRepository siteUserRepository, + AppleOAuthClient appleOAuthClient, SignInService signInService) { + super(signUpTokenProvider, siteUserRepository, signInService); + this.appleOAuthClient = appleOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return appleOAuthClient.processOAuth(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.APPLE; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java index f75397cf6..0b1ef7b01 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -21,7 +21,7 @@ * 기존 회원 * - 로그인한다. * 신규 회원 - * - 회원가입 페이지로 리다이렉트할 때 필요한 정보를 제공한다. + * - 회원가입할 때 필요한 정보를 제공한다. * */ public abstract class OAuthService { @@ -43,18 +43,18 @@ public OAuthResponse processOAuth(OAuthCodeRequest oauthCodeRequest) { if (optionalSiteUser.isPresent()) { SiteUser siteUser = optionalSiteUser.get(); - return getSignInInfo(siteUser); + return getSignInResponse(siteUser); } return getSignUpPrepareResponse(userInfoDto); } - protected OAuthSignInResponse getSignInInfo(SiteUser siteUser) { + protected final OAuthSignInResponse getSignInResponse(SiteUser siteUser) { SignInResponse signInResponse = signInService.signIn(siteUser); return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); } - protected SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { + protected final SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail()); return SignUpPrepareResponse.of(userInfoDto, signUpToken); } From d4d4c5256e673ab96abac9a7a1d362cf5783ab84 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 11:47:56 +0900 Subject: [PATCH 12/21] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/AppleOAuthClient.java | 1 + .../auth/client/KakaoOAuthClient.java | 19 ++++++------------- .../auth/service/oauth/OAuthService.java | 7 ++----- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java index 11b330b0b..3e84efa75 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -22,6 +22,7 @@ import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_MISSING_EMAIL; /* + * 애플 인증을 위한 OAuth2 클라이언트 * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens * */ @Component diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 206365b1b..5d625cb7c 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -20,6 +20,12 @@ import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH; import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_USER_INFO_FAIL; +/* + * 카카오 인증을 위한 OAuth2 클라이언트 + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + * */ @Component @RequiredArgsConstructor public class KakaoOAuthClient { @@ -27,20 +33,11 @@ public class KakaoOAuthClient { private final RestTemplate restTemplate; private final KakaoOAuthClientProperties kakaoOAuthClientProperties; - /* - * 클라이언트에서 사용자가 카카오 로그인을 하면, 클라이언트는 '카카오 인가 코드'를 받아, 서버에 넘겨준다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code - * 서버는 카카오 인증 코드를 사용해 카카오 서버로부터 '카카오 토큰'을 받아온다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token - * 그리고 카카오 엑세스 토큰으로 카카오 서버에 요청해 '카카오 사용자 정보'를 받아온다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info - * */ public KakaoUserInfoDto getUserInfo(String code) { String kakaoAccessToken = getKakaoAccessToken(code); return getKakaoUserInfo(kakaoAccessToken); } - // 카카오 토큰 요청 private String getKakaoAccessToken(String code) { try { ResponseEntity response = restTemplate.exchange( @@ -61,7 +58,6 @@ private String getKakaoAccessToken(String code) { } } - // 카카오 엑세스 토큰 요청하는 URI 생성 private String buildTokenUri(String code) { return UriComponentsBuilder.fromHttpUrl(kakaoOAuthClientProperties.tokenUrl()) .queryParam("grant_type", "authorization_code") @@ -71,12 +67,10 @@ private String buildTokenUri(String code) { .toUriString(); } - // 카카오 사용자 정보 요청 private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); - // 사용자의 정보 요청 ResponseEntity response = restTemplate.exchange( kakaoOAuthClientProperties.userInfoUrl(), HttpMethod.GET, @@ -84,7 +78,6 @@ private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { KakaoUserInfoDto.class ); - // 응답 예외처리 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { return response.getBody(); } else { diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java index 0b1ef7b01..2926d82f9 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -15,13 +15,10 @@ import java.util.Optional; - /* * OAuth 제공자로부터 이메일을 받아 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. - * 기존 회원 - * - 로그인한다. - * 신규 회원 - * - 회원가입할 때 필요한 정보를 제공한다. + * 기존 회원 : 로그인한다. + * 신규 회원 : 회원가입할 때 필요한 정보를 제공한다. * */ public abstract class OAuthService { From 37181d50b28b73b0655bd44692d2648c5315fcbb Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 14:43:57 +0900 Subject: [PATCH 13/21] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=86=A0=ED=81=B0=EC=97=90=20=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EB=B0=A9=EB=B2=95=EC=9D=B4=20=ED=8F=AC=ED=95=A8=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20SignUpTokenProvider=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/oauth/OAuthService.java | 2 +- .../service/oauth/SignUpTokenProvider.java | 66 ++++++- .../custom/exception/ErrorCode.java | 4 + .../solidconnection/util/JwtUtils.java | 10 +- .../auth/service/SignUpTokenProviderTest.java | 71 ------- .../oauth/SignUpTokenProviderTest.java | 182 ++++++++++++++++++ .../solidconnection/e2e/SignUpTest.java | 4 +- 7 files changed, 254 insertions(+), 85 deletions(-) delete mode 100644 src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java create mode 100644 src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java index 2926d82f9..4f37db060 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -52,7 +52,7 @@ protected final OAuthSignInResponse getSignInResponse(SiteUser siteUser) { } protected final SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { - String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail()); + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), getAuthType()); return SignUpPrepareResponse.of(userInfoDto, signUpToken); } diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java index 20a6af613..5399dc1eb 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java @@ -3,25 +3,79 @@ import com.example.solidconnection.auth.domain.TokenType; import com.example.solidconnection.auth.service.TokenProvider; import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; -import java.util.Optional; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static com.example.solidconnection.util.JwtUtils.parseClaims; +import static com.example.solidconnection.util.JwtUtils.parseSubject; @Component public class SignUpTokenProvider extends TokenProvider { + static final String AUTH_TYPE_CLAIM_KEY = "authType"; + public SignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { super(jwtProperties, redisTemplate); } - public String generateAndSaveSignUpToken(String email) { - String signUpToken = generateToken(email, TokenType.SIGN_UP); + public String generateAndSaveSignUpToken(String email, AuthType authType) { + Map authTypeClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, authType)); + Claims claims = Jwts.claims(authTypeClaim).setSubject(email); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); + + String signUpToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); return saveToken(signUpToken, TokenType.SIGN_UP); } - public Optional findSignUpToken(String email) { - String signUpKey = TokenType.SIGN_UP.addPrefix(email); - return Optional.ofNullable(redisTemplate.opsForValue().get(signUpKey)); + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + String email = parseEmail(token); + validateIssuedByServer(email); + } + + private void validateFormatAndExpiration(String token) { + try { + Claims claims = parseClaims(token, jwtProperties.secret()); + Objects.requireNonNull(claims.getSubject()); + String serializedAuthType = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + AuthType.valueOf(serializedAuthType); + } catch (Exception e) { + throw new CustomException(OAUTH_SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + if (redisTemplate.opsForValue().get(key) == null) { + throw new CustomException(OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + } + } + + public String parseEmail(String token) { + return parseSubject(token, jwtProperties.secret()); + } + + public AuthType parseAuthType(String token) { + Claims claims = parseClaims(token, jwtProperties.secret()); + String authTypeStr = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(authTypeStr); } } diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index fd07a8c5f..a07165b6f 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -24,6 +24,10 @@ public enum ErrorCode { KAKAO_USER_INFO_FAIL(HttpStatus.BAD_REQUEST.value(), "카카오 사용자 정보 조회에 실패했습니다."), INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN(HttpStatus.BAD_REQUEST.value(), "우리 서비스에서 발급한 카카오 토큰이 아닙니다"), + // oauth + OAUTH_SIGN_UP_TOKEN_INVALID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 회원가입 토큰입니다."), + OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER(HttpStatus.BAD_REQUEST.value(), "회원가입 토큰이 우리 서버에서 발급되지 않았습니다."), + // data not found UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 대학교 지원 정보입니다."), UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM(HttpStatus.NOT_FOUND.value(), "해당하는 대학교가 이번 모집 기간에 열리지 않았습니다."), diff --git a/src/main/java/com/example/solidconnection/util/JwtUtils.java b/src/main/java/com/example/solidconnection/util/JwtUtils.java index 3a1b58520..d3ea8fed9 100644 --- a/src/main/java/com/example/solidconnection/util/JwtUtils.java +++ b/src/main/java/com/example/solidconnection/util/JwtUtils.java @@ -1,6 +1,7 @@ package com.example.solidconnection.util; import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import jakarta.servlet.http.HttpServletRequest; @@ -29,7 +30,7 @@ public static String parseTokenFromRequest(HttpServletRequest request) { public static String parseSubjectIgnoringExpiration(String token, String secretKey) { try { - return extractSubject(token, secretKey); + return parseClaims(token, secretKey).getSubject(); } catch (ExpiredJwtException e) { return e.getClaims().getSubject(); } catch (Exception e) { @@ -39,7 +40,7 @@ public static String parseSubjectIgnoringExpiration(String token, String secretK public static String parseSubject(String token, String secretKey) { try { - return extractSubject(token, secretKey); + return parseClaims(token, secretKey).getSubject(); } catch (Exception e) { throw new CustomException(INVALID_TOKEN); } @@ -58,11 +59,10 @@ public static boolean isExpired(String token, String secretKey) { } } - private static String extractSubject(String token, String secretKey) throws ExpiredJwtException { + public static Claims parseClaims(String token, String secretKey) throws ExpiredJwtException { return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) - .getBody() - .getSubject(); + .getBody(); } } diff --git a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java deleted file mode 100644 index d8ac35717..000000000 --- a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.auth.service.oauth.SignUpTokenProvider; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.util.JwtUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@TestContainerSpringBootTest -@DisplayName("회원가입 토큰 제공자 테스트") -class SignUpTokenProviderTest { - - @Autowired - private SignUpTokenProvider signUpTokenProvider; - - @Autowired - private RedisTemplate redisTemplate; - - @Autowired - private JwtProperties jwtProperties; - - @Test - void 회원가입_토큰을_생성하고_저장한다() { - // when - String email = "email"; - String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email); - - // then - String actualSubject = JwtUtils.parseSubject(signUpToken, jwtProperties.secret()); - String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); - assertAll( - () -> assertThat(actualSubject).isEqualTo(email), - () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) - ); - } - - @Test - void 저장된_회원가입_토큰을_조회한다() { - // given - String email = "email"; - String signUpToken = "token"; - redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), signUpToken); - - // when - Optional actualSignUpToken = signUpTokenProvider.findSignUpToken(email); - - // then - assertThat(actualSignUpToken).hasValue(signUpToken); - } - - @Test - void 저장되지_않은_회원가입_토큰을_조회한다() { - // given - String email = "email"; - - // when - Optional actualSignUpToken = signUpTokenProvider.findSignUpToken(email); - - // then - assertThat(actualSignUpToken).isEmpty(); - } -} diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java new file mode 100644 index 000000000..d3a1efac1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java @@ -0,0 +1,182 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.util.JwtUtils; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static com.example.solidconnection.auth.service.oauth.SignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("회원가입 토큰 제공자 테스트") +class SignUpTokenProviderTest { + + @Autowired + private SignUpTokenProvider signUpTokenProvider; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + @Test + void 회원가입_토큰을_생성하고_저장한다() { + // given + String email = "email"; + AuthType authType = AuthType.KAKAO; + + // when + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType); + + // then + Claims claims = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()); + String actualSubject = claims.getSubject(); + AuthType actualAuthType = AuthType.valueOf(claims.get(AUTH_TYPE_CLAIM_KEY, String.class)); + String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); + assertAll( + () -> assertThat(actualSubject).isEqualTo(email), + () -> assertThat(actualAuthType).isEqualTo(authType), + () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) + ); + } + + @Nested + class 주어진_회원가입_토큰을_검증한다 { + + @Test + void 검증_성공한다() { + // given + String email = "email@test.com"; + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); + } + + @Test + void 만료되었으면_예외_응답을_반환한다() { + // given + String expiredToken = createExpiredToken(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(expiredToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_jwt_가_아닌_토큰() { + // given + String notJwt = "not jwt"; + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(notJwt)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_authType_클래스_불일치() { + // given + Map wrongClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, "카카오")); + String wrongAuthType = createBaseJwtBuilder().addClaims(wrongClaim).compact(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(wrongAuthType)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_subject_누락() { + // given + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String noSubject = createBaseJwtBuilder().addClaims(claim).compact(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(noSubject)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 우리_서버에_발급된_토큰이_아니면_예외_응답을_반환한다() { + // given + Map validClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject("email").compact(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(signUpToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER.getMessage()); + } + } + + @Test + void 회원가입_토큰에서_이메일을_추출한다() { + // given + String email = "email@test.com"; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when + String extractedEmail = signUpTokenProvider.parseEmail(validToken); + + // then + assertThat(extractedEmail).isEqualTo(email); + } + + @Test + void 회원가입_토큰에서_인증_타입을_추출한다() { + // given + AuthType authType = AuthType.APPLE; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, authType); + String validToken = createBaseJwtBuilder().setSubject("email").addClaims(claim).compact(); + + // when + AuthType extractedAuthType = signUpTokenProvider.parseAuthType(validToken); + + // then + assertThat(extractedAuthType).isEqualTo(authType); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private JwtBuilder createBaseJwtBuilder() { + return Jwts.builder() + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java index 0a9090f8e..001fc0728 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java @@ -29,8 +29,8 @@ import java.util.List; import static com.example.solidconnection.auth.domain.TokenType.REFRESH; -import static com.example.solidconnection.custom.exception.ErrorCode.JWT_EXCEPTION; import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_INVALID; import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByNickName; @@ -181,6 +181,6 @@ class SignUpTest extends BaseEndToEndTest { .extract().as(ErrorResponse.class); assertThat(errorResponse.message()) - .contains(JWT_EXCEPTION.getMessage()); + .contains(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); } } From c9d62c3e5dd5278031d21a6f3759d5901ef81705 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 16:05:46 +0900 Subject: [PATCH 14/21] =?UTF-8?q?refactor:=20=EB=8B=A4=EC=96=91=ED=95=9C?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85=EC=9D=B4=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 12 +-- .../auth/dto/SignUpRequest.java | 8 +- .../auth/dto/SignUpResponse.java | 6 -- .../auth/service/TokenValidator.java | 84 ------------------- .../OAuthSignUpService.java} | 54 ++++++------ .../solidconnection/e2e/SignUpTest.java | 12 +-- 6 files changed, 41 insertions(+), 135 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java delete mode 100644 src/main/java/com/example/solidconnection/auth/service/TokenValidator.java rename src/main/java/com/example/solidconnection/auth/service/{SignUpService.java => oauth/OAuthSignUpService.java} (62%) diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index c30925458..363e456af 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -1,13 +1,13 @@ package com.example.solidconnection.auth.controller; import com.example.solidconnection.auth.dto.ReissueResponse; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.service.AuthService; import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; -import com.example.solidconnection.auth.service.SignUpService; +import com.example.solidconnection.auth.service.oauth.OAuthSignUpService; import com.example.solidconnection.custom.resolver.AuthorizedUser; import com.example.solidconnection.custom.resolver.ExpiredToken; import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; @@ -27,7 +27,7 @@ public class AuthController { private final AuthService authService; - private final SignUpService signUpService; + private final OAuthSignUpService oAuthSignUpService; private final KakaoOAuthService kakaoOAuthService; @PostMapping("/kakao") @@ -39,11 +39,11 @@ public ResponseEntity processKakaoOAuth( } @PostMapping("/sign-up") - public ResponseEntity signUp( + public ResponseEntity signUp( @Valid @RequestBody SignUpRequest signUpRequest ) { - SignUpResponse signUpResponseDto = signUpService.signUp(signUpRequest); - return ResponseEntity.ok(signUpResponseDto); + SignInResponse signInResponse = oAuthSignUpService.signUp(signUpRequest); + return ResponseEntity.ok(signInResponse); } @PostMapping("/sign-out") diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index 96b84fcfb..b28b467bd 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PreparationStatus; @@ -23,15 +24,16 @@ public record SignUpRequest( @JsonFormat(pattern = "yyyy-MM-dd") String birth) { - public SiteUser toSiteUser(String email, Role role) { + public SiteUser toSiteUser(String email, AuthType authType) { return new SiteUser( email, this.nickname, this.profileImageUrl, this.birth, this.preparationStatus, - role, - this.gender + Role.MENTEE, + this.gender, + authType ); } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java deleted file mode 100644 index 2d74610cc..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.solidconnection.auth.dto; - -public record SignUpResponse( - String accessToken, - String refreshToken) { -} diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenValidator.java b/src/main/java/com/example/solidconnection/auth/service/TokenValidator.java deleted file mode 100644 index a87a4aa2c..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/TokenValidator.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.custom.exception.CustomException; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import java.util.Date; -import java.util.Objects; - -import static com.example.solidconnection.auth.domain.TokenType.ACCESS; -import static com.example.solidconnection.auth.domain.TokenType.REFRESH; -import static com.example.solidconnection.auth.domain.TokenType.SIGN_UP; -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_TOKEN_EXPIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.EMPTY_TOKEN; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN; -import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; - -@Component -@RequiredArgsConstructor -public class TokenValidator { - - private final RedisTemplate redisTemplate; - - @Value("${jwt.secret}") - private String secretKey; - - public void validateAccessToken(String token) { - validateTokenNotEmpty(token); - validateTokenNotExpired(token, ACCESS); - validateRefreshToken(token); - } - - public void validateKakaoToken(String token) { - validateTokenNotEmpty(token); - validateTokenNotExpired(token, SIGN_UP); - validateKakaoTokenNotUsed(token); - } - - private void validateTokenNotEmpty(String token) { - if (!StringUtils.hasText(token)) { - throw new CustomException(EMPTY_TOKEN); - } - } - - private void validateTokenNotExpired(String token, TokenType tokenType) { - Date expiration = getClaim(token).getExpiration(); - long now = new Date().getTime(); - if ((expiration.getTime() - now) < 0) { - if (tokenType.equals(ACCESS)) { - throw new CustomException(ACCESS_TOKEN_EXPIRED); - } - if (token.equals(SIGN_UP)) { - throw new CustomException(INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN); - } - } - } - - private void validateRefreshToken(String token) { - String email = getClaim(token).getSubject(); - if (redisTemplate.opsForValue().get(REFRESH.addPrefix(email)) == null) { - throw new CustomException(REFRESH_TOKEN_EXPIRED); - } - } - - private void validateKakaoTokenNotUsed(String token) { - String email = getClaim(token).getSubject(); - if (!Objects.equals(redisTemplate.opsForValue().get(SIGN_UP.addPrefix(email)), token)) { - throw new CustomException(INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN); - } - } - - private Claims getClaim(String token) { - return Jwts.parser() - .setSigningKey(this.secretKey) - .parseClaimsJws(token) - .getBody(); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java similarity index 62% rename from src/main/java/com/example/solidconnection/auth/service/SignUpService.java rename to src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java index 34bbc95e0..7b6d44d26 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java @@ -1,7 +1,8 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.oauth; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; +import com.example.solidconnection.auth.service.SignInService; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.entity.InterestedCountry; import com.example.solidconnection.entity.InterestedRegion; @@ -12,7 +13,6 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Role; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,10 +24,10 @@ @RequiredArgsConstructor @Service -public class SignUpService { +public class OAuthSignUpService { - private final TokenValidator tokenValidator; - private final AuthTokenProvider authTokenProvider; + private final SignUpTokenProvider signUpTokenProvider; + private final SignInService signInService; private final SiteUserRepository siteUserRepository; private final RegionRepository regionRepository; private final InterestedRegionRepository interestedRegionRepository; @@ -35,43 +35,31 @@ public class SignUpService { private final InterestedCountyRepository interestedCountyRepository; /* - * 회원가입을 한다. - * - 카카오로 최초 로그인 시 우리 서비스에서 발급한 카카오 토큰 signUpToken 을 검증한다. - * - 이는 '카카오 인증을 하지 않고 회원가입 api 만으로 회원가입 하는 상황'을 방지하기 위함이다. - * - 만약 api 만으로 회원가입을 한다면, 카카오 인증과 이메일에 대한 검증 없이 회원가입이 가능해진다. - * - 이메일은 우리 서비스에서 사용자를 식별하는 중요한 정보이기 때문에 '우리 서비스에서 발급한 카카오 토큰인지 검증하는' 단계가 필요하다. + * OAuth 인증 후 회원가입을 한다. + * - 우리 서버에서 OAuth 인증했음을 확인하기 위한 signUpToken 을 검증한다. * - 사용자 정보를 DB에 저장한다. * - 관심 국가와 지역을 DB에 저장한다. * - 관심 국가와 지역은 site_user_id를 참조하므로, 사용자 저장 후 저장한다. * - 바로 로그인하도록 액세스 토큰과 리프레시 토큰을 발급한다. * */ - // todo: 여러가지 가입 방법 적용해야 함 @Transactional - public SignUpResponse signUp(SignUpRequest signUpRequest) { + public SignInResponse signUp(SignUpRequest signUpRequest) { // 검증 - tokenValidator.validateKakaoToken(signUpRequest.signUpToken()); - String email = authTokenProvider.getEmail(signUpRequest.signUpToken()); + signUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); validateNicknameDuplicated(signUpRequest.nickname()); - validateUserNotDuplicated(email); + String email = signUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = signUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + validateUserNotDuplicated(email, authType); // 사용자 저장 - SiteUser siteUser = signUpRequest.toSiteUser(email, Role.MENTEE); - SiteUser savedSiteUser = siteUserRepository.save(siteUser); + SiteUser siteUser = siteUserRepository.save(signUpRequest.toSiteUser(email, authType)); // 관심 지역, 국가 저장 - saveInterestedRegion(signUpRequest, savedSiteUser); - saveInterestedCountry(signUpRequest, savedSiteUser); + saveInterestedRegion(signUpRequest, siteUser); + saveInterestedCountry(signUpRequest, siteUser); - // 토큰 발급 - String accessToken = authTokenProvider.generateAccessToken(siteUser); - String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); - return new SignUpResponse(accessToken, refreshToken); - } - - private void validateUserNotDuplicated(String email) { - if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.KAKAO)) { - throw new CustomException(USER_ALREADY_EXISTED); - } + // 로그인 + return signInService.signIn(siteUser); } private void validateNicknameDuplicated(String nickname) { @@ -80,6 +68,12 @@ private void validateNicknameDuplicated(String nickname) { } } + private void validateUserNotDuplicated(String email, AuthType authType) { + if (siteUserRepository.existsByEmailAndAuthType(email, authType)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + private void saveInterestedRegion(SignUpRequest signUpRequest, SiteUser savedSiteUser) { List interestedRegionNames = signUpRequest.interestedRegions(); List interestedRegions = regionRepository.findByKoreanNames(interestedRegionNames).stream() diff --git a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java index 001fc0728..7d01f365c 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java @@ -1,7 +1,7 @@ package com.example.solidconnection.e2e; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.auth.service.oauth.SignUpTokenProvider; import com.example.solidconnection.custom.response.ErrorResponse; @@ -74,20 +74,20 @@ class SignUpTest extends BaseEndToEndTest { // setup - 카카오 토큰 발급 String email = "email@email.com"; - String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email); + String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); // request - body 생성 및 요청 List interestedRegionNames = List.of("유럽"); List interestedCountryNames = List.of("프랑스", "독일"); SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, interestedRegionNames, interestedCountryNames, PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, "nickname", "2000-01-01"); - SignUpResponse response = RestAssured.given().log().all() + SignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) .body(signUpRequest) .when().post("/auth/sign-up") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignUpResponse.class); + .extract().as(SignInResponse.class); SiteUser savedSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO).get(); assertAll( @@ -126,7 +126,7 @@ class SignUpTest extends BaseEndToEndTest { // setup - 카카오 토큰 발급 String email = "email@email.com"; - String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email); + String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); // request - body 생성 및 요청 SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, @@ -151,7 +151,7 @@ class SignUpTest extends BaseEndToEndTest { siteUserRepository.save(alreadyExistUser); // setup - 카카오 토큰 발급 - String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(alreadyExistEmail); + String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(alreadyExistEmail, AuthType.KAKAO); // request - body 생성 및 요청 SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, From f6ee7d6ff61ccf0fd15b6df1ae16aa649fd20049 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 16:07:26 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 363e456af..e35bc59e2 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -6,6 +6,7 @@ import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.service.AuthService; +import com.example.solidconnection.auth.service.oauth.AppleOAuthService; import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; import com.example.solidconnection.auth.service.oauth.OAuthSignUpService; import com.example.solidconnection.custom.resolver.AuthorizedUser; @@ -28,8 +29,17 @@ public class AuthController { private final AuthService authService; private final OAuthSignUpService oAuthSignUpService; + private final AppleOAuthService appleOAuthService; private final KakaoOAuthService kakaoOAuthService; + @PostMapping("/apple") + public ResponseEntity processAppleOAuth( + @RequestBody OAuthCodeRequest oAuthCodeRequest + ) { + OAuthResponse oAuthResponse = appleOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); + } + @PostMapping("/kakao") public ResponseEntity processKakaoOAuth( @RequestBody OAuthCodeRequest oAuthCodeRequest From 9e732e9daf890622a6b5bb341b879db921d778a4 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 20:00:28 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=ED=82=A4=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=99=80?= =?UTF-8?q?=EC=84=9C=20id=5Ftoken=20=EC=9D=84=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/AppleOAuthClient.java | 14 ++-- .../AppleOAuthClientSecretProvider.java | 2 + .../auth/client/ApplePublicKeyProvider.java | 81 +++++++++++++++++++ .../client/AppleOAuthClientProperties.java | 1 + .../custom/exception/ErrorCode.java | 5 +- 5 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java index 3e84efa75..aef1309af 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -16,10 +16,11 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import java.security.PublicKey; import java.util.Objects; import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; -import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_MISSING_EMAIL; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; /* * 애플 인증을 위한 OAuth2 클라이언트 @@ -32,10 +33,12 @@ public class AppleOAuthClient { private final RestTemplate restTemplate; private final AppleOAuthClientProperties properties; private final AppleOAuthClientSecretProvider clientSecretProvider; + private final ApplePublicKeyProvider publicKeyProvider; public AppleUserInfoDto processOAuth(String code) { String idToken = requestIdToken(code); - return new AppleUserInfoDto(parseEmailFromToken(idToken)); + PublicKey applePublicKey = publicKeyProvider.getApplePublicKey(idToken); + return new AppleUserInfoDto(parseEmailFromToken(applePublicKey, idToken)); } public String requestIdToken(String code) { @@ -66,14 +69,15 @@ private MultiValueMap buildFormData(String code) { return formData; } - private String parseEmailFromToken(String idToken) { + private String parseEmailFromToken(PublicKey applePublicKey, String idToken) { try { return Jwts.parser() - .parseClaimsJwt(idToken) + .setSigningKey(applePublicKey) + .parseClaimsJws(idToken) .getBody() .get("email", String.class); } catch (Exception e) { - throw new CustomException(APPLE_ID_TOKEN_MISSING_EMAIL); + throw new CustomException(INVALID_APPLE_ID_TOKEN); } } } diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java index 00a144589..308494c34 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -22,6 +22,8 @@ import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; /* + * 애플 OAuth 에 필요하 클라이언트 시크릿은 매번 동적으로 생성해야 한다. + * 클라이언트 시크릿은 애플 개발자 계정에서 발급받은 개인키(*.p8)를 사용하여 JWT 를 생성한다. * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret * */ @Component diff --git a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java new file mode 100644 index 000000000..19e875cbb --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java @@ -0,0 +1,81 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; +import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; + +/* +* idToken 검증을 위해서 애플의 공개키를 가져온다. +* - 애플 공개키는 주기적으로 바뀌므로, 항상 새롭게 받아와야 한다. +* - 애플 공개키를 받아오는 url 에 요청하여 JSON 형식의 공개키 목록을 받아온다. +* - 이중에서 idToken 의 헤더에 있는 kid 값과 일치하는 공개키로 PublicKey 객체를 생성한다. +* https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature +* */ +@Component +@RequiredArgsConstructor +public class ApplePublicKeyProvider { + + private final AppleOAuthClientProperties properties; + private final RestTemplate restTemplate; + + public PublicKey getApplePublicKey(String idToken) { + try { + String kid = getKeyIdFromTokenHeader(idToken); + JsonNode appleKeys = fetchApplePublicKeys(); + return getMatchingPublicKey(appleKeys, kid); + } catch (ExpiredJwtException e) { + throw new CustomException(APPLE_ID_TOKEN_EXPIRED); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } + + private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException { + String[] jwtParts = idToken.split("\\."); + if (jwtParts.length < 2) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0]), StandardCharsets.UTF_8); + return new ObjectMapper().readTree(headerJson).get("kid").asText(); + } + + private JsonNode fetchApplePublicKeys() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + ResponseEntity response = restTemplate.getForEntity(properties.publicKeyUrl(), String.class); + return objectMapper.readTree(response.getBody()).get("keys"); + } + + private PublicKey getMatchingPublicKey(JsonNode appleKeys, String kid) throws Exception { + for (JsonNode key : appleKeys) { + if (key.get("kid").asText().equals(kid)) { + return generatePublicKey(key); + } + } + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + + private PublicKey generatePublicKey(JsonNode key) throws Exception { + BigInteger modulus = new BigInteger(1, decodeBase64URLSafe(key.get("n").asText())); + BigInteger exponent = new BigInteger(1, decodeBase64URLSafe(key.get("e").asText())); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } +} diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java index b0f9aa2b8..609e9ee89 100644 --- a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java +++ b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java @@ -7,6 +7,7 @@ public record AppleOAuthClientProperties( String tokenUrl, String clientSecretAudienceUrl, String redirectUrl, + String publicKeyUrl, String clientId, String teamId, String keyId diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index a07165b6f..ca8dde723 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -13,7 +13,10 @@ public enum ErrorCode { // apple APPLE_AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST.value(), "애플 인증에 실패했습니다."), - APPLE_ID_TOKEN_MISSING_EMAIL(HttpStatus.BAD_REQUEST.value(), "애플 idToken 에 email 이 포함되어 있지 않습니다."), + APPLE_ID_TOKEN_MISSING_EMAIL(HttpStatus.BAD_REQUEST.value(), "애플 idToken 에 이메일이 없습니다."), + APPLE_ID_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 이 만료되었습니다."), + INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 애플 idToken 입니다."), + APPLE_ID_TOKEN_MALFORMED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 의 형식이 잘못되었습니다."), FAILED_TO_READ_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 private key 파일을 읽을 수 없습니다."), APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 client secret JWT 생성에 실패했습니다."), From 428de7a94ccbd803c6240170935263abbb07cd52 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 22:16:09 +0900 Subject: [PATCH 17/21] =?UTF-8?q?refactor:=20=EA=B3=B5=EA=B0=9C=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=BA=90=EC=8B=B1=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/ApplePublicKeyProvider.java | 41 ++++++++++++------- .../custom/exception/ErrorCode.java | 1 + 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java index 19e875cbb..1cc708cc7 100644 --- a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java +++ b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java @@ -17,16 +17,19 @@ import java.security.PublicKey; import java.security.spec.RSAPublicKeySpec; import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND; import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; /* * idToken 검증을 위해서 애플의 공개키를 가져온다. -* - 애플 공개키는 주기적으로 바뀌므로, 항상 새롭게 받아와야 한다. -* - 애플 공개키를 받아오는 url 에 요청하여 JSON 형식의 공개키 목록을 받아온다. -* - 이중에서 idToken 의 헤더에 있는 kid 값과 일치하는 공개키로 PublicKey 객체를 생성한다. +* - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다. +* - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다. +* - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다. * https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature * */ @Component @@ -36,11 +39,21 @@ public class ApplePublicKeyProvider { private final AppleOAuthClientProperties properties; private final RestTemplate restTemplate; + private final Map applePublicKeyCache = new ConcurrentHashMap<>(); + public PublicKey getApplePublicKey(String idToken) { try { String kid = getKeyIdFromTokenHeader(idToken); - JsonNode appleKeys = fetchApplePublicKeys(); - return getMatchingPublicKey(appleKeys, kid); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } + + fetchApplePublicKeys(); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } else { + throw new CustomException(APPLE_PUBLIC_KEY_NOT_FOUND); + } } catch (ExpiredJwtException e) { throw new CustomException(APPLE_ID_TOKEN_EXPIRED); } catch (Exception e) { @@ -48,6 +61,10 @@ public PublicKey getApplePublicKey(String idToken) { } } + /* + * idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다 + * 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다. + * */ private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException { String[] jwtParts = idToken.split("\\."); if (jwtParts.length < 2) { @@ -57,19 +74,15 @@ private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingExce return new ObjectMapper().readTree(headerJson).get("kid").asText(); } - private JsonNode fetchApplePublicKeys() throws Exception { + private void fetchApplePublicKeys() throws Exception { ObjectMapper objectMapper = new ObjectMapper(); ResponseEntity response = restTemplate.getForEntity(properties.publicKeyUrl(), String.class); - return objectMapper.readTree(response.getBody()).get("keys"); - } + JsonNode jsonNode = objectMapper.readTree(response.getBody()).get("keys"); - private PublicKey getMatchingPublicKey(JsonNode appleKeys, String kid) throws Exception { - for (JsonNode key : appleKeys) { - if (key.get("kid").asText().equals(kid)) { - return generatePublicKey(key); - } + applePublicKeyCache.clear(); + for (JsonNode key : jsonNode) { + applePublicKeyCache.put(key.get("kid").asText(), generatePublicKey(key)); } - throw new CustomException(INVALID_APPLE_ID_TOKEN); } private PublicKey generatePublicKey(JsonNode key) throws Exception { diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index ca8dde723..d3fdf136d 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -17,6 +17,7 @@ public enum ErrorCode { APPLE_ID_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 이 만료되었습니다."), INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 애플 idToken 입니다."), APPLE_ID_TOKEN_MALFORMED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 의 형식이 잘못되었습니다."), + APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR.value(), "idToken 를 서명한 애플 공개키를 찾을 수 없습니다"), FAILED_TO_READ_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 private key 파일을 읽을 수 없습니다."), APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 client secret JWT 생성에 실패했습니다."), From 3aca3f9291a64c8acf83a3f13ec2df1272e7dcaf Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 22:17:22 +0900 Subject: [PATCH 18/21] =?UTF-8?q?refactor:=20=EC=9E=98=EB=AA=BB=EB=90=9C?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/AppleOAuthClientSecretProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java index 308494c34..2de0b7291 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -31,7 +31,7 @@ public class AppleOAuthClientSecretProvider { private static final String KEY_ID_HEADER = "kid"; - private static final long TOKEN_DURATION = 1000 * 60; // 10min + private static final long TOKEN_DURATION = 1000 * 60 * 10; // 10min private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8"; private final AppleOAuthClientProperties appleOAuthClientProperties; From ce1d1444da28f03e8bb90de902d5f07a66a76616 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 22:30:17 +0900 Subject: [PATCH 19/21] =?UTF-8?q?refactor:=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/oauth/SignUpPrepareResponse.java | 6 +++--- .../java/com/example/solidconnection/e2e/SignInTest.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java index 081793503..5a6c60c57 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java @@ -5,15 +5,15 @@ public record SignUpPrepareResponse( String nickname, String email, String profileImageUrl, - String kakaoOauthToken) implements OAuthResponse { + String signUpToken) implements OAuthResponse { - public static SignUpPrepareResponse of(OAuthUserInfoDto oAuthUserInfoDto, String kakaoOauthToken) { + public static SignUpPrepareResponse of(OAuthUserInfoDto oAuthUserInfoDto, String signUpToken) { return new SignUpPrepareResponse( false, oAuthUserInfoDto.getNickname(), oAuthUserInfoDto.getEmail(), oAuthUserInfoDto.getProfileImageUrl(), - kakaoOauthToken + signUpToken ); } } diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java index c9ed4b6ae..cc16f71c1 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignInTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -64,10 +64,10 @@ class SignInTest extends BaseEndToEndTest { () -> assertThat(response.email()).isEqualTo(email), () -> assertThat(response.nickname()).isEqualTo(kakaoProfileDto.nickname()), () -> assertThat(response.profileImageUrl()).isEqualTo(kakaoProfileDto.profileImageUrl()), - () -> assertThat(response.kakaoOauthToken()).isNotNull()); + () -> assertThat(response.signUpToken()).isNotNull()); assertThat(redisTemplate.opsForValue().get(SIGN_UP.addPrefix(email))) .as("카카오 인증 토큰을 저장한다.") - .isEqualTo(response.kakaoOauthToken()); + .isEqualTo(response.signUpToken()); } @Test From eded031d1913b41b3bc1be8affbadb7e6098881c Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Thu, 6 Feb 2025 22:30:32 +0900 Subject: [PATCH 20/21] =?UTF-8?q?refactor:=20code=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/auth/controller/AuthController.java | 4 ++-- .../solidconnection/auth/dto/oauth/OAuthCodeRequest.java | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index e35bc59e2..aa3ce4f20 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -34,7 +34,7 @@ public class AuthController { @PostMapping("/apple") public ResponseEntity processAppleOAuth( - @RequestBody OAuthCodeRequest oAuthCodeRequest + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest ) { OAuthResponse oAuthResponse = appleOAuthService.processOAuth(oAuthCodeRequest); return ResponseEntity.ok(oAuthResponse); @@ -42,7 +42,7 @@ public ResponseEntity processAppleOAuth( @PostMapping("/kakao") public ResponseEntity processKakaoOAuth( - @RequestBody OAuthCodeRequest oAuthCodeRequest + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest ) { OAuthResponse oAuthResponse = kakaoOAuthService.processOAuth(oAuthCodeRequest); return ResponseEntity.ok(oAuthResponse); diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java index 49ed95a13..abbdb7802 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java @@ -1,5 +1,9 @@ package com.example.solidconnection.auth.dto.oauth; +import jakarta.validation.constraints.NotBlank; + public record OAuthCodeRequest( + + @NotBlank(message = "인증 코드를 입력해주세요.") String code) { } From 473cd20037235e0cc845c323a0fcbd650ef9c218 Mon Sep 17 00:00:00 2001 From: nayonsoso Date: Fri, 7 Feb 2025 14:23:32 +0900 Subject: [PATCH 21/21] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/auth/dto/oauth/AppleUserInfoDto.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java index 60a60131d..5c4363e51 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java @@ -1,5 +1,10 @@ package com.example.solidconnection.auth.dto.oauth; +/* +* 애플로부터 사용자의 정보를 받아올 때 사용한다. +* 카카오와 달리 애플은 더 엄격하게 사용자 정보를 관리하여, 이름이나 프로필 이미지 url 을 제공하지 않는다. +* 따라서 닉네임, 프로필 정보는 null 을 반환한다. +* */ public record AppleUserInfoDto(String email) implements OAuthUserInfoDto { @Override