From 6e737c2ce19977e54bb733640020519b7a24eb41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Thu, 14 Aug 2025 12:57:45 +0900 Subject: [PATCH 01/21] =?UTF-8?q?[Feat]=20=EC=97=B0=EA=B4=80=EA=B4=80?= =?UTF-8?q?=EA=B3=84=EC=97=90=20ON=20DELETE=20CASCADE=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User 삭제 시, 연관되어 있는 Friend, Streak, StudyRecord, Category, Notification, Quiz 데이터를 삭제하기 위해 제약조건을 추가하였음 --- src/main/java/org/example/studylog/entity/Friend.java | 4 ++++ src/main/java/org/example/studylog/entity/Streak.java | 3 +++ src/main/java/org/example/studylog/entity/StudyRecord.java | 4 ++++ .../java/org/example/studylog/entity/category/Category.java | 3 +++ .../example/studylog/entity/notification/Notification.java | 3 +++ src/main/java/org/example/studylog/entity/quiz/Quiz.java | 5 +++++ 6 files changed, 22 insertions(+) diff --git a/src/main/java/org/example/studylog/entity/Friend.java b/src/main/java/org/example/studylog/entity/Friend.java index a330d9d..941a3c6 100644 --- a/src/main/java/org/example/studylog/entity/Friend.java +++ b/src/main/java/org/example/studylog/entity/Friend.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import lombok.*; import org.example.studylog.entity.user.User; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Getter @Setter @@ -18,10 +20,12 @@ public class Friend { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "friend_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private User friend; } diff --git a/src/main/java/org/example/studylog/entity/Streak.java b/src/main/java/org/example/studylog/entity/Streak.java index 57fd7b3..affe3aa 100644 --- a/src/main/java/org/example/studylog/entity/Streak.java +++ b/src/main/java/org/example/studylog/entity/Streak.java @@ -4,6 +4,8 @@ import lombok.*; import lombok.experimental.SuperBuilder; import org.example.studylog.entity.user.User; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.time.LocalDate; @@ -22,6 +24,7 @@ public class Streak extends BaseEntity { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false, unique = true) + @OnDelete(action = OnDeleteAction.CASCADE) private User user; @Column(name = "current_streak", nullable = false) diff --git a/src/main/java/org/example/studylog/entity/StudyRecord.java b/src/main/java/org/example/studylog/entity/StudyRecord.java index 32d9939..aba01b5 100644 --- a/src/main/java/org/example/studylog/entity/StudyRecord.java +++ b/src/main/java/org/example/studylog/entity/StudyRecord.java @@ -9,6 +9,8 @@ import org.example.studylog.entity.category.Category; import org.example.studylog.entity.quiz.Quiz; import org.example.studylog.entity.user.User; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.util.ArrayList; import java.util.List; @@ -37,10 +39,12 @@ public class StudyRecord extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private Category category; @OneToMany(mappedBy = "record", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/org/example/studylog/entity/category/Category.java b/src/main/java/org/example/studylog/entity/category/Category.java index cb72743..c6e4bdf 100644 --- a/src/main/java/org/example/studylog/entity/category/Category.java +++ b/src/main/java/org/example/studylog/entity/category/Category.java @@ -9,6 +9,8 @@ import org.example.studylog.entity.StudyRecord; import org.example.studylog.entity.quiz.Quiz; import org.example.studylog.entity.user.User; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.util.ArrayList; import java.util.List; @@ -33,6 +35,7 @@ public class Category { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private User user; @OneToMany(mappedBy = "category") diff --git a/src/main/java/org/example/studylog/entity/notification/Notification.java b/src/main/java/org/example/studylog/entity/notification/Notification.java index 8668732..8f519a9 100644 --- a/src/main/java/org/example/studylog/entity/notification/Notification.java +++ b/src/main/java/org/example/studylog/entity/notification/Notification.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import lombok.*; import org.example.studylog.entity.user.User; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -22,6 +24,7 @@ public class Notification { private Long id; @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) private User user; @Enumerated(EnumType.STRING) diff --git a/src/main/java/org/example/studylog/entity/quiz/Quiz.java b/src/main/java/org/example/studylog/entity/quiz/Quiz.java index 90a44b5..0eff7cb 100644 --- a/src/main/java/org/example/studylog/entity/quiz/Quiz.java +++ b/src/main/java/org/example/studylog/entity/quiz/Quiz.java @@ -10,6 +10,8 @@ import org.example.studylog.entity.StudyRecord; import org.example.studylog.entity.category.Category; import org.example.studylog.entity.user.User; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.time.LocalDateTime; @@ -44,14 +46,17 @@ public class Quiz extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "record_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private StudyRecord record; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private Category category; } From 7fa2d92a6ab6323a892a4053cb7323d6673e7ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Thu, 14 Aug 2025 12:59:42 +0900 Subject: [PATCH 02/21] =?UTF-8?q?[Feat]=20deleteAllByOauthId=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User 삭제 시 남아 있는 Refresh 토큰 데이터를 모두 삭제하는 메소드 추가 --- .../java/org/example/studylog/repository/RefreshRepository.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/example/studylog/repository/RefreshRepository.java b/src/main/java/org/example/studylog/repository/RefreshRepository.java index 6544bbb..3970f80 100644 --- a/src/main/java/org/example/studylog/repository/RefreshRepository.java +++ b/src/main/java/org/example/studylog/repository/RefreshRepository.java @@ -10,4 +10,6 @@ public interface RefreshRepository extends JpaRepository { @Transactional void deleteByRefresh(String refresh); + + void deleteAllByOauthId(String oauthId); } From c69560636322ec4a26c093caef8f181ec1d8f1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Thu, 14 Aug 2025 14:16:09 +0900 Subject: [PATCH 03/21] =?UTF-8?q?[Feat]=20=EC=B5=9C=EC=B4=88=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=A0=80=EC=9E=A5=ED=95=A0=20?= =?UTF-8?q?RefreshToken=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/studylog/entity/user/User.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/example/studylog/entity/user/User.java b/src/main/java/org/example/studylog/entity/user/User.java index 7dbe16b..2862a19 100644 --- a/src/main/java/org/example/studylog/entity/user/User.java +++ b/src/main/java/org/example/studylog/entity/user/User.java @@ -67,6 +67,9 @@ public class User { @OneToMany(mappedBy = "user") private List categories = new ArrayList<>(); + @Column(length = 1000) + private String refreshToken; + // 기록 수 증가 public void incrementRecordCount(){ this.recordCount++; From 1f639a56940fa31e384493630aa92411626f9b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Thu, 14 Aug 2025 14:17:23 +0900 Subject: [PATCH 04/21] =?UTF-8?q?[Feat]=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studylog/controller/UserController.java | 19 ++++++++++++++ .../example/studylog/service/UserService.java | 25 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/java/org/example/studylog/controller/UserController.java b/src/main/java/org/example/studylog/controller/UserController.java index 7943d29..cd830dd 100644 --- a/src/main/java/org/example/studylog/controller/UserController.java +++ b/src/main/java/org/example/studylog/controller/UserController.java @@ -121,4 +121,23 @@ public ResponseEntity updateBackground( return ResponseUtil.buildResponse(500, "내부 서버 오류입니다", null); } } + + @DeleteMapping + public ResponseEntity deleteUser(@AuthenticationPrincipal CustomOAuth2User currentUser){ + try { + log.info("유저 삭제 요청: 사용자={}", currentUser.getName()); + String oauthId = currentUser.getName(); + userService.deleteAccount(oauthId); + log.info("유저 삭제 완료: 사용자={}", currentUser.getName()); + + return ResponseUtil.buildResponse(204, "유저 삭제 완료", null); + } catch (IllegalStateException e){ + log.warn("유저 삭제 실패 - 잘못된 요청: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + } catch (Exception e){ + log.error("유저 삭제 중 오류 발생", e); + return ResponseUtil.buildResponse(500, "내부 서버 오류입니다", null); + } + + } } diff --git a/src/main/java/org/example/studylog/service/UserService.java b/src/main/java/org/example/studylog/service/UserService.java index bdb3905..7e4ded9 100644 --- a/src/main/java/org/example/studylog/service/UserService.java +++ b/src/main/java/org/example/studylog/service/UserService.java @@ -6,6 +6,7 @@ import org.example.studylog.entity.user.User; import org.example.studylog.exception.UserNotFoundException; import org.example.studylog.repository.FriendRepository; +import org.example.studylog.repository.RefreshRepository; import org.example.studylog.repository.UserRepository; import org.example.studylog.util.ResponseUtil; import org.springframework.stereotype.Service; @@ -20,6 +21,7 @@ public class UserService { private final UserRepository userRepository; private final AwsS3Service awsS3Service; private final FriendRepository friendRepository; + private final RefreshRepository refreshRepository; @Transactional public ProfileResponseDTO createUserProfile(ProfileCreateRequestDTO request, String oauthId){ @@ -134,4 +136,27 @@ public BackgroundDTO.ResponseDTO updateBackground(String oauthId, BackgroundDTO. return responseDTO; } + + @Transactional + public void deleteAccount(String oauthId) { + User user = userRepository.findByOauthId(oauthId); + if(user == null) + throw new IllegalArgumentException("존재하지 않는 사용자"); + + log.info("사용자 삭제 시작: 사용자 = {}", oauthId); + + // 1. S3 버킷의 이미지 삭제 (프로필 + 배경화면 이미지) + awsS3Service.deleteFileByKey(user.getProfileImage()); + awsS3Service.deleteFileByKey(user.getBackImage()); + + // 2. Refresh 토큰 삭제 + refreshRepository.deleteAllByOauthId(oauthId); + + // 3. 외부 연동 해제 + + + // 4. 유저 삭제 + userRepository.delete(user); + + } } From 32b1dba8023b8c00b81b826d8ac150737deb2b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Thu, 14 Aug 2025 14:17:44 +0900 Subject: [PATCH 05/21] =?UTF-8?q?[Feat]=20=EC=B5=9C=EC=B4=88=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20RefreshToken=EC=9D=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/CustomOAuth2UserService.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java b/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java index 24f6aeb..80c8b46 100644 --- a/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java +++ b/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java @@ -5,9 +5,12 @@ import org.example.studylog.entity.user.Role; import org.example.studylog.entity.user.User; import org.example.studylog.repository.UserRepository; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; @@ -19,9 +22,13 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; + private final OAuth2AuthorizedClientService authorizedClientService; - public CustomOAuth2UserService(UserRepository userRepository) { + public CustomOAuth2UserService( + UserRepository userRepository, + OAuth2AuthorizedClientService authorizedClientService) { this.userRepository = userRepository; + this.authorizedClientService = authorizedClientService; } @Override @@ -33,6 +40,20 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String registrationId = userRequest.getClientRegistration().getRegistrationId(); System.out.println(registrationId); + // OAuth2AuthorizedClientService 를 통해 인증된 클라이언트 정보 로드 + OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient( + registrationId, oAuth2User.getName() + ); + + // RefreshToken 가져오기 + OAuth2RefreshToken refreshToken = null; + if (authorizedClient != null){ + refreshToken = authorizedClient.getRefreshToken(); + if (refreshToken != null) { + log.info("Refresh Token for {}: {}", registrationId, refreshToken.getTokenValue()); + } + } + OAuth2Response oAuth2Response = null; if (registrationId.equals("kakao")){ oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); @@ -62,6 +83,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic .uuid(UUID.randomUUID()) .code(generateCode()) .oauthId(oauthId) + .refreshToken(refreshToken.getTokenValue()) .build(); userRepository.save(user); From 33bd382a52600c6766cca04875279a57e1f14cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Thu, 14 Aug 2025 17:11:16 +0900 Subject: [PATCH 06/21] =?UTF-8?q?[Refactor]=20CustomSuccessHandler?= =?UTF-8?q?=EB=A1=9C=20RefreshToken=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studylog/oauth2/CustomSuccessHandler.java | 35 ++++++++++++++++++- .../oauth/CustomOAuth2UserService.java | 21 +---------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java index d8b95c8..6cd4153 100644 --- a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java +++ b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java @@ -5,11 +5,16 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.entity.user.User; import org.example.studylog.jwt.JWTUtil; +import org.example.studylog.repository.UserRepository; import org.example.studylog.service.TokenService; import org.example.studylog.util.CookieUtil; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -23,10 +28,16 @@ public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JWTUtil jwtUtil; private final TokenService tokenService; + private final OAuth2AuthorizedClientService authorizedClientService; + private final UserRepository userRepository; - public CustomSuccessHandler(JWTUtil jwtUtil, TokenService tokenService) { + public CustomSuccessHandler(JWTUtil jwtUtil, TokenService tokenService, + OAuth2AuthorizedClientService authorizedClientService, + UserRepository userRepository) { this.jwtUtil = jwtUtil; this.tokenService = tokenService; + this.authorizedClientService = authorizedClientService; + this.userRepository = userRepository; } @Override @@ -37,6 +48,28 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String oauthId = customUserDetails.getName(); + // registrationId (예: "google", "kakao") + String registrationId = ((org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken) authentication) + .getAuthorizedClientRegistrationId(); + + // 이제 authorizedClientService에서 클라이언트 정보를 가져올 수 있음 + OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient( + registrationId, + oauthId + ); + + // Refresh Token 가져오기 + OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken(); + + if (refreshToken != null) { + // DB에서 사용자 조회 후 Refresh Token 업데이트 + User user = userRepository.findByOauthId(oauthId); + if (user != null) { + user.setRefreshToken(refreshToken.getTokenValue()); + userRepository.save(user); + } + } + Collection authorities = authentication.getAuthorities(); Iterator iterator = authorities.iterator(); GrantedAuthority auth = iterator.next(); diff --git a/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java b/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java index 80c8b46..b5baac9 100644 --- a/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java +++ b/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java @@ -22,13 +22,9 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; - private final OAuth2AuthorizedClientService authorizedClientService; - public CustomOAuth2UserService( - UserRepository userRepository, - OAuth2AuthorizedClientService authorizedClientService) { + public CustomOAuth2UserService(UserRepository userRepository) { this.userRepository = userRepository; - this.authorizedClientService = authorizedClientService; } @Override @@ -40,20 +36,6 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String registrationId = userRequest.getClientRegistration().getRegistrationId(); System.out.println(registrationId); - // OAuth2AuthorizedClientService 를 통해 인증된 클라이언트 정보 로드 - OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient( - registrationId, oAuth2User.getName() - ); - - // RefreshToken 가져오기 - OAuth2RefreshToken refreshToken = null; - if (authorizedClient != null){ - refreshToken = authorizedClient.getRefreshToken(); - if (refreshToken != null) { - log.info("Refresh Token for {}: {}", registrationId, refreshToken.getTokenValue()); - } - } - OAuth2Response oAuth2Response = null; if (registrationId.equals("kakao")){ oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); @@ -83,7 +65,6 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic .uuid(UUID.randomUUID()) .code(generateCode()) .oauthId(oauthId) - .refreshToken(refreshToken.getTokenValue()) .build(); userRepository.save(user); From 49539e283e87f319654d162bc81a45cb21c320d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:41:33 +0900 Subject: [PATCH 07/21] =?UTF-8?q?[Feat]=20AES-GCM=EB=A1=9C=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=ED=95=98=EB=8A=94=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/studylog/client/GoogleClient.java | 2 + .../studylog/client/KakaoApiClient.java | 2 + .../studylog/client/KakaoAuthClient.java | 4 ++ .../dto/oauth/OAuthTokenResponse.java | 23 +++++++ .../oauth/ExternalOAuthUnlinkService.java | 2 + .../service/oauth/GoogleOAuthService.java | 2 + .../service/oauth/KakaoOAuthService.java | 2 + .../studylog/util/AesGcmEncryptor.java | 67 +++++++++++++++++++ 8 files changed, 104 insertions(+) create mode 100644 src/main/java/org/example/studylog/client/GoogleClient.java create mode 100644 src/main/java/org/example/studylog/client/KakaoApiClient.java create mode 100644 src/main/java/org/example/studylog/client/KakaoAuthClient.java create mode 100644 src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java create mode 100644 src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java create mode 100644 src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java create mode 100644 src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java create mode 100644 src/main/java/org/example/studylog/util/AesGcmEncryptor.java diff --git a/src/main/java/org/example/studylog/client/GoogleClient.java b/src/main/java/org/example/studylog/client/GoogleClient.java new file mode 100644 index 0000000..06e6e93 --- /dev/null +++ b/src/main/java/org/example/studylog/client/GoogleClient.java @@ -0,0 +1,2 @@ +package org.example.studylog.client;public interface GoogleClient { +} diff --git a/src/main/java/org/example/studylog/client/KakaoApiClient.java b/src/main/java/org/example/studylog/client/KakaoApiClient.java new file mode 100644 index 0000000..5e9a3a0 --- /dev/null +++ b/src/main/java/org/example/studylog/client/KakaoApiClient.java @@ -0,0 +1,2 @@ +package org.example.studylog.client;public class KakaoApiClient { +} diff --git a/src/main/java/org/example/studylog/client/KakaoAuthClient.java b/src/main/java/org/example/studylog/client/KakaoAuthClient.java new file mode 100644 index 0000000..263eab2 --- /dev/null +++ b/src/main/java/org/example/studylog/client/KakaoAuthClient.java @@ -0,0 +1,4 @@ +package org.example.studylog.client; + +public interface KakaoClient { +} diff --git a/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java b/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java new file mode 100644 index 0000000..d900495 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java @@ -0,0 +1,23 @@ +package org.example.studylog.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class KakaoTokenResponse { + + @JsonProperty("token_type") + private String tokenType; // "bearer" + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("expires_in") + private Long expiresIn; + @JsonProperty("scope") + private String scope; // 선택 + + // refresh 요청 시, 남은 유효기간이 짧을 때만 새 refresh_token을 줄 수 있음 + @JsonProperty("refresh_token") + private String refreshToken; // 선택 + @JsonProperty("refresh_token_expires_in") + private Long refreshTokenExpiresIn; // 선택 +} diff --git a/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java b/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java new file mode 100644 index 0000000..810097c --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java @@ -0,0 +1,2 @@ +package org.example.studylog.service.oauth;public class ExternalOAuthUnlinkService { +} diff --git a/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java b/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java new file mode 100644 index 0000000..9bc279c --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java @@ -0,0 +1,2 @@ +package org.example.studylog.service.oauth;public class GoogleOAuthService { +} diff --git a/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java b/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java new file mode 100644 index 0000000..b4c6053 --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java @@ -0,0 +1,2 @@ +package org.example.studylog.service.oauth;public class KakaoOAuthService { +} diff --git a/src/main/java/org/example/studylog/util/AesGcmEncryptor.java b/src/main/java/org/example/studylog/util/AesGcmEncryptor.java new file mode 100644 index 0000000..f89e6a9 --- /dev/null +++ b/src/main/java/org/example/studylog/util/AesGcmEncryptor.java @@ -0,0 +1,67 @@ +package org.example.studylog.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class AesGcmEncryptor { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LEN = 12; // 96-bit nonce (권장) + private static final int TAG_LEN = 128; // bits + private final SecretKeySpec key; + + public AesGcmEncryptor(@Value("${app.crypto.master-key-base64}") String base64Key) { + byte[] keyBytes = Base64.getDecoder().decode(base64Key); // 32 bytes(256-bit) 권장 + this.key = new SecretKeySpec(keyBytes, "AES"); + } + + public String encrypt(String plaintext) { + if (plaintext == null) return null; + try { + byte[] iv = new byte[IV_LEN]; + SecureRandom.getInstanceStrong().nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LEN, iv)); + byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + ByteBuffer bb = ByteBuffer.allocate(iv.length + ct.length); + bb.put(iv).put(ct); + // 버전접두어(키 로테이션 대비) + return "v1:" + Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array()); + } catch (Exception e) { + throw new IllegalStateException("Encrypt failed", e); + } + } + + public String decrypt(String ciphertext) { + if (ciphertext == null) return null; + try { + String payload = ciphertext.startsWith("v1:") ? ciphertext.substring(3) : ciphertext; + byte[] enc = Base64.getUrlDecoder().decode(payload); + + ByteBuffer bb = ByteBuffer.wrap(enc); + byte[] iv = new byte[IV_LEN]; + bb.get(iv); + byte[] ct = new byte[bb.remaining()]; + bb.get(ct); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LEN, iv)); + byte[] pt = cipher.doFinal(ct); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("Decrypt failed", e); + } + } + +} From 419c920699b048c6541542bd72b6e1d1d9449ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:43:21 +0900 Subject: [PATCH 08/21] =?UTF-8?q?[Feat]=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=9D=B8=EA=B0=80=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=A6=AC=EC=A1=B8=EB=B2=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshToken을 받기 위해 accesstype=offline&prompt=consent 형식으로 요청을 보냄 --- .../studylog/config/SecurityConfig.java | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/studylog/config/SecurityConfig.java b/src/main/java/org/example/studylog/config/SecurityConfig.java index a68d2ef..9e89806 100644 --- a/src/main/java/org/example/studylog/config/SecurityConfig.java +++ b/src/main/java/org/example/studylog/config/SecurityConfig.java @@ -14,6 +14,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; @@ -21,6 +23,10 @@ import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.REGISTRATION_ID; @Configuration @@ -42,7 +48,31 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomSuc } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + public SecurityFilterChain filterChain(HttpSecurity http, + ClientRegistrationRepository repo) throws Exception{ + + // 1) 인가요청 리졸버: 구글일 때만 offline/consent 추가 + var resolver = new DefaultOAuth2AuthorizationRequestResolver( + repo, "/oauth2/authorization"); + + resolver.setAuthorizationRequestCustomizer(builder -> { + // 먼저 한 번 build() 해서 현재 값들을 읽음 + var req = builder.build(); + String regId = (String) req.getAttribute( + org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.REGISTRATION_ID + ); + + if ("google".equals(regId)) { + // 기존 추가 파라미터를 보존하면서 병합 + Map add = new java.util.LinkedHashMap<>(req.getAdditionalParameters()); + add.put("access_type", "offline"); + add.put("prompt", "consent"); + + // 병합 결과를 빌더에 다시 세팅 + builder.additionalParameters(add); + } + }); + // cors 설정 http @@ -90,10 +120,10 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { http .addFilterAfter(new ProfileCheckFilter(userRepository), OAuth2LoginAuthenticationFilter.class); - // oauth2 - + // OAuth2 로그인: 리졸버 연결 + 기존 설정 유지 http .oauth2Login((oauth2) -> oauth2 + .authorizationEndpoint(a -> a.authorizationRequestResolver(resolver)) .userInfoEndpoint((UserInfoEndpointConfig) -> UserInfoEndpointConfig .userService(customOAuth2UserService)) .successHandler(customSuccessHandler) From feb72ff428c69378f788a49afde4a55336b1c6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:44:41 +0900 Subject: [PATCH 09/21] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A1=9C=EB=B6=80=ED=84=B0=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20RefreshToken=EC=9D=84=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studylog/oauth2/CustomSuccessHandler.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java index 1fcea2d..b9fcf1b 100644 --- a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java +++ b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java @@ -9,6 +9,7 @@ import org.example.studylog.jwt.JWTUtil; import org.example.studylog.repository.UserRepository; import org.example.studylog.service.TokenService; +import org.example.studylog.util.AesGcmEncryptor; import org.example.studylog.util.CookieUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; @@ -35,14 +36,17 @@ public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final TokenService tokenService; private final OAuth2AuthorizedClientService authorizedClientService; private final UserRepository userRepository; + private final AesGcmEncryptor encryptor; public CustomSuccessHandler(JWTUtil jwtUtil, TokenService tokenService, OAuth2AuthorizedClientService authorizedClientService, - UserRepository userRepository) { + UserRepository userRepository, + AesGcmEncryptor encryptor) { this.jwtUtil = jwtUtil; this.tokenService = tokenService; this.authorizedClientService = authorizedClientService; this.userRepository = userRepository; + this.encryptor = encryptor; } @Override @@ -68,9 +72,13 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo if (refreshToken != null) { // DB에서 사용자 조회 후 Refresh Token 업데이트 + log.info("제공자로부터 받은 refreshToken을 DB에 저장"); + String raw = refreshToken.getTokenValue(); + String enc = encryptor.encrypt(raw); // 암호화 User user = userRepository.findByOauthId(oauthId); if (user != null) { - user.setRefreshToken(refreshToken.getTokenValue()); + log.info("최초 로그인 시 RefreshToken 저장"); + user.setRefreshToken(enc); userRepository.save(user); } } From 2eb36b5dcf59de4404e9764696bd278634c226bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:49:44 +0900 Subject: [PATCH 10/21] =?UTF-8?q?[Feat]=20GoogleClient,=20KakaoAuthClient,?= =?UTF-8?q?=20KakaoApiClient=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoogleClient: 구글 OAuth 토큰 관리 KakaoAuthClient: 카카오 토큰 관리 KakaoApiClient: 카카오 사용자/리소스 API 호출(언링크 등) --- .../studylog/config/HttpClientConfig.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/main/java/org/example/studylog/config/HttpClientConfig.java b/src/main/java/org/example/studylog/config/HttpClientConfig.java index 06536fe..c65d0d3 100644 --- a/src/main/java/org/example/studylog/config/HttpClientConfig.java +++ b/src/main/java/org/example/studylog/config/HttpClientConfig.java @@ -1,9 +1,14 @@ package org.example.studylog.config; import org.example.studylog.client.ChatGptClient; +import org.example.studylog.client.GoogleClient; +import org.example.studylog.client.KakaoApiClient; +import org.example.studylog.client.KakaoAuthClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientAdapter; import org.springframework.web.service.invoker.HttpServiceProxyFactory; @@ -27,4 +32,41 @@ public ChatGptClient chatGptClient(){ return factory.createClient(ChatGptClient.class); } + + @Bean + public GoogleClient googleClient(){ + RestClient restClient = RestClient.builder() + .baseUrl("https://oauth2.googleapis.com") + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + + HttpServiceProxyFactory factory = HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(restClient)) + .build(); + + return factory.createClient(GoogleClient.class); + + } + + @Bean + public KakaoAuthClient kakaoAuthClient(RestClient.Builder builder) { + RestClient rc = builder + .baseUrl("https://kauth.kakao.com") + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rc)) + .build() + .createClient(KakaoAuthClient.class); + } + + @Bean + public KakaoApiClient kakaoApiClient(RestClient.Builder builder) { + RestClient rc = builder + .baseUrl("https://kapi.kakao.com") + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rc)) + .build() + .createClient(KakaoApiClient.class); + } } From faf531e70a6af6d1112fac759b3904c31d18d1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:52:18 +0900 Subject: [PATCH 11/21] =?UTF-8?q?[Feat]=20=EA=B5=AC=EA=B8=80/=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=ED=86=A0=ED=81=B0=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EC=9C=84=ED=95=9C=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/oauth/OAuthTokenResponse.java | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java b/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java index d900495..cd3ab33 100644 --- a/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java +++ b/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java @@ -1,23 +1,19 @@ package org.example.studylog.dto.oauth; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data -public class KakaoTokenResponse { - - @JsonProperty("token_type") - private String tokenType; // "bearer" - @JsonProperty("access_token") - private String accessToken; - @JsonProperty("expires_in") - private Long expiresIn; - @JsonProperty("scope") - private String scope; // 선택 - - // refresh 요청 시, 남은 유효기간이 짧을 때만 새 refresh_token을 줄 수 있음 - @JsonProperty("refresh_token") - private String refreshToken; // 선택 - @JsonProperty("refresh_token_expires_in") - private Long refreshTokenExpiresIn; // 선택 -} +@JsonIgnoreProperties(ignoreUnknown = true) +public class OAuthTokenResponse { + @JsonProperty("access_token") private String accessToken; + @JsonProperty("expires_in") private Long expiresIn; + @JsonProperty("token_type") private String tokenType; // "Bearer"/"bearer" + @JsonProperty("scope") private String scope; // 선택 + @JsonProperty("refresh_token") private String refreshToken; // 선택 + // 카카오 전용(선택) + @JsonProperty("refresh_token_expires_in") private Long refreshTokenExpiresIn; + // 구글 OIDC 선택 + @JsonProperty("id_token") private String idToken; +} \ No newline at end of file From ce52fc52a9c4d272082c3be2fafbe20ee9af599b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:55:26 +0900 Subject: [PATCH 12/21] =?UTF-8?q?[Feat]=20=EA=B5=AC=EA=B8=80=20OAuth=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=97=90=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=EA=B3=BC=20=ED=86=A0=ED=81=B0=20=ED=9A=8C?= =?UTF-8?q?=EC=88=98(revoke)=20=EC=9A=94=EC=B2=AD=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EB=8A=94=20client=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/studylog/client/GoogleClient.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/studylog/client/GoogleClient.java b/src/main/java/org/example/studylog/client/GoogleClient.java index 06e6e93..886f239 100644 --- a/src/main/java/org/example/studylog/client/GoogleClient.java +++ b/src/main/java/org/example/studylog/client/GoogleClient.java @@ -1,2 +1,22 @@ -package org.example.studylog.client;public interface GoogleClient { +package org.example.studylog.client; + +import org.example.studylog.dto.oauth.OAuthTokenResponse; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +@HttpExchange(accept = MediaType.APPLICATION_JSON_VALUE) +public interface GoogleClient { + + // Refresh 토큰으로 액세스 토큰 재발급 + @PostExchange(url = "/token", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + OAuthTokenResponse reissueToken(@RequestBody MultiValueMap form); + + // 토큰 리보크(연결 해제) + @PostExchange(url = "/revoke", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + void unlink(@RequestParam String token); + } From b37edf76711e6dc9db21383585ac3c2f95592e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 19:57:16 +0900 Subject: [PATCH 13/21] =?UTF-8?q?[Feat]=20googleClient=EB=A1=9C=20unlink?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B3=B4=EB=82=B4=EB=8A=94=20?= =?UTF-8?q?GoogleOAuthService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/oauth/GoogleOAuthService.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java b/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java index 9bc279c..9269bcb 100644 --- a/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java +++ b/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java @@ -1,2 +1,16 @@ -package org.example.studylog.service.oauth;public class GoogleOAuthService { +package org.example.studylog.service.oauth; + +import lombok.RequiredArgsConstructor; +import org.example.studylog.client.GoogleClient; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GoogleOAuthService { + + private final GoogleClient googleClient; + + public void revoke(String token) { + googleClient.unlink(token); + } } From a512edc1e24fd667a48f03efc1348e2525698cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 20:06:51 +0900 Subject: [PATCH 14/21] =?UTF-8?q?[Feat]=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20=EB=B0=8F=20accessTok?= =?UTF-8?q?en=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20client=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KakaoAuthClient: accessToken 재발급 client KakaoApiClient: 연동 해제 client --- .../example/studylog/client/KakaoApiClient.java | 17 ++++++++++++++++- .../studylog/client/KakaoAuthClient.java | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/studylog/client/KakaoApiClient.java b/src/main/java/org/example/studylog/client/KakaoApiClient.java index 5e9a3a0..4dba8e6 100644 --- a/src/main/java/org/example/studylog/client/KakaoApiClient.java +++ b/src/main/java/org/example/studylog/client/KakaoApiClient.java @@ -1,2 +1,17 @@ -package org.example.studylog.client;public class KakaoApiClient { +package org.example.studylog.client; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +@HttpExchange(accept = MediaType.APPLICATION_JSON_VALUE) +public interface KakaoApiClient { + + @PostExchange(url = "/v1/user/unlink", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + void unlink(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestBody MultiValueMap form); } diff --git a/src/main/java/org/example/studylog/client/KakaoAuthClient.java b/src/main/java/org/example/studylog/client/KakaoAuthClient.java index 263eab2..9a3ecec 100644 --- a/src/main/java/org/example/studylog/client/KakaoAuthClient.java +++ b/src/main/java/org/example/studylog/client/KakaoAuthClient.java @@ -1,4 +1,18 @@ package org.example.studylog.client; -public interface KakaoClient { +import com.nimbusds.oauth2.sdk.TokenResponse; +import org.example.studylog.dto.oauth.OAuthTokenResponse; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +@HttpExchange(accept = MediaType.APPLICATION_JSON_VALUE) +public interface KakaoAuthClient { + + // POST /oauth/token (x-www-form-urlencoded) + @PostExchange(url = "/oauth/token", + contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + OAuthTokenResponse refreshToken(@RequestBody MultiValueMap form); } From 793f87b795b3c6e1924788ede61c4d36b6903673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 20:11:07 +0900 Subject: [PATCH 15/21] =?UTF-8?q?[Feat]=20Kakao=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A1=9C=20=EC=9A=94=EC=B2=AD=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EB=8A=94=20KakaoOAuthService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. refreshToken으로 accessToken 재발급 요청 보내는 메소드 2. accessToken으로 unlink 요청 보내는 메소드 3. adminKey와 kakaoUserId로 unlink 요청 보내는 메소드 --- .../service/oauth/KakaoOAuthService.java | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java b/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java index b4c6053..af5dd15 100644 --- a/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java +++ b/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java @@ -1,2 +1,64 @@ -package org.example.studylog.service.oauth;public class KakaoOAuthService { +package org.example.studylog.service.oauth; + +import lombok.RequiredArgsConstructor; +import org.example.studylog.client.KakaoApiClient; +import org.example.studylog.client.KakaoAuthClient; +import org.example.studylog.dto.oauth.OAuthTokenResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final KakaoAuthClient kakaoAuthClient; // https://kauth.kakao.com + private final KakaoApiClient kakaoApiClient; // https://kapi.kakao.com + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.kakao.client-secret:}") + private String clientSecret; + + // Admin Key는 서버 비밀(.env/Parameter Store/KMS 등)로 관리 + @Value("${kakao.admin-key:}") + private String adminKey; + + public boolean hasAdminKey() { + return adminKey != null && !adminKey.isBlank(); + } + + // refresh -> access 재발급 + public OAuthTokenResponse refreshAccessToken(String refreshToken) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "refresh_token"); + form.add("client_id", clientId); + if (clientSecret != null && !clientSecret.isBlank()) { + form.add("client_secret", clientSecret); + } + form.add("refresh_token", refreshToken); + return kakaoAuthClient.refreshToken(form); + } + + // (사용자 토큰 방식) access_token으로 언링크 + public void unlinkWithAccessToken(String accessToken) { + MultiValueMap form = new LinkedMultiValueMap<>(); + kakaoApiClient.unlink("Bearer " + accessToken, form); + } + + // (서버 Admin Key 방식) kakao user_id로 언링크 + public void unlinkWithAdminKey(long kakaoUserId){ + if (adminKey == null || adminKey.isBlank()){ + throw new IllegalStateException("Kakao Admin Key is not configured."); + } + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("target_id_type", "user_id"); + form.add("target_id", String.valueOf(kakaoUserId)); + + kakaoApiClient.unlink("KakaoAK " + adminKey, form); + + } } From d3c15a8b49f4a23e5abe7436661da48dc39c26b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 20:13:09 +0900 Subject: [PATCH 16/21] =?UTF-8?q?[Feat]=20Google/Kakao=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=99=80=20=EC=97=B0=EA=B2=B0=EC=9D=84=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=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 Google/Kakao 유저를 구분하여 그에 맞는 연결 해제 로직 수행 --- .../oauth/ExternalOAuthUnlinkService.java | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java b/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java index 810097c..63a1edb 100644 --- a/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java +++ b/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java @@ -1,2 +1,80 @@ -package org.example.studylog.service.oauth;public class ExternalOAuthUnlinkService { +package org.example.studylog.service.oauth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.OAuthTokenResponse; +import org.example.studylog.entity.user.User; +import org.example.studylog.util.AesGcmEncryptor; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ExternalOAuthUnlinkService { + + private final GoogleOAuthService googleOAuthService; + private final KakaoOAuthService kakaoOAuthService; + private final AesGcmEncryptor encryptor; + + public void unlink(User user){ + if (user == null || user.getOauthId() == null) return; + + String oauthId = user.getOauthId(); + String[] parts = oauthId.split("_", 2); + if(parts.length < 2){ + log.warn("올바른 oauthId 형식이 아님: {}", oauthId); + return; + } + String provider = parts[0]; + String providerUserId = parts[1]; + + try { + if(provider.equals("google")){ + unlinkGoogle(user); + } + else if(provider.equals("kakao")){ + unlinkKakao(user, providerUserId); + } + else { + log.info("지원하지 않는 provider: {}", provider); + } + } catch (Exception e){ + log.error("외부 연동 해제 실패: provider={}, oauthId={}, msg={}", + provider, oauthId, e.getMessage()); + } + } + + private void unlinkGoogle(User user){ + String enc = user.getRefreshToken(); + String refresh = encryptor.decrypt(enc); // 복호화 + + // RefreshToken으로 revoke 요청 보내기 + if (refresh != null && !refresh.isBlank()){ + googleOAuthService.revoke(refresh); + log.info("Google revoke by refresh_token 완료"); + } else { + log.info("Google refresh_token 없음 -> revoke 생략"); + } + } + + private void unlinkKakao(User user, String kakaoUserId){ + // 1순위: Admin Key 방식(사용자 토큰 불필요) + if(kakaoOAuthService.hasAdminKey()){ + kakaoOAuthService.unlinkWithAdminKey(Long.parseLong(kakaoUserId)); + log.info("Kakao unlink by AdminKey 완료: user_id={}", kakaoUserId); + return; + } + + + // 2순위: 사용자의 AccessToken으로 revoke 요청 보내기 + String enc = user.getRefreshToken(); + String refresh = encryptor.decrypt(enc); // 복호화 + if (refresh != null && !refresh.isBlank()) { + OAuthTokenResponse tr = kakaoOAuthService.refreshAccessToken(refresh); + kakaoOAuthService.unlinkWithAccessToken(tr.getAccessToken()); + log.info("Kakao unlink by refreshed access_token 완료"); + } else { + log.info("Kakao refresh_token 없음 -> revoke 생략"); + } + } } From e8eb611f124389fbe7b32cbeaf29263a2307d835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 20:15:33 +0900 Subject: [PATCH 17/21] =?UTF-8?q?[Feat]=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/studylog/controller/UserController.java | 6 +++++- .../java/org/example/studylog/service/UserService.java | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/studylog/controller/UserController.java b/src/main/java/org/example/studylog/controller/UserController.java index cd830dd..1c28728 100644 --- a/src/main/java/org/example/studylog/controller/UserController.java +++ b/src/main/java/org/example/studylog/controller/UserController.java @@ -122,6 +122,11 @@ public ResponseEntity updateBackground( } } + @Operation(summary = "회원 탈퇴") + @ApiResponse(responseCode = "204", description = "유저 삭제 완료", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDTO.class))) @DeleteMapping public ResponseEntity deleteUser(@AuthenticationPrincipal CustomOAuth2User currentUser){ try { @@ -138,6 +143,5 @@ public ResponseEntity deleteUser(@AuthenticationPrincipal CustomOAuth2User cu log.error("유저 삭제 중 오류 발생", e); return ResponseUtil.buildResponse(500, "내부 서버 오류입니다", null); } - } } diff --git a/src/main/java/org/example/studylog/service/UserService.java b/src/main/java/org/example/studylog/service/UserService.java index 7e4ded9..41c0fad 100644 --- a/src/main/java/org/example/studylog/service/UserService.java +++ b/src/main/java/org/example/studylog/service/UserService.java @@ -8,6 +8,7 @@ import org.example.studylog.repository.FriendRepository; import org.example.studylog.repository.RefreshRepository; import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.oauth.ExternalOAuthUnlinkService; import org.example.studylog.util.ResponseUtil; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +23,7 @@ public class UserService { private final AwsS3Service awsS3Service; private final FriendRepository friendRepository; private final RefreshRepository refreshRepository; + private final ExternalOAuthUnlinkService externalOAuthUnlinkService; @Transactional public ProfileResponseDTO createUserProfile(ProfileCreateRequestDTO request, String oauthId){ @@ -153,7 +155,11 @@ public void deleteAccount(String oauthId) { refreshRepository.deleteAllByOauthId(oauthId); // 3. 외부 연동 해제 - + try { + externalOAuthUnlinkService.unlink(user); + } catch (Exception e) { + log.error("외부 연동 해제 중 오류(계정 삭제는 계속): {}", e.getMessage(), e); + } // 4. 유저 삭제 userRepository.delete(user); From 5bcbda905932e0890cb16058718778ea6e197373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Fri, 22 Aug 2025 22:19:51 +0900 Subject: [PATCH 18/21] =?UTF-8?q?[Build]=20Flyway=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/main/resources/db/migration/V1__add_column_to_users.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 src/main/resources/db/migration/V1__add_column_to_users.sql diff --git a/build.gradle b/build.gradle index 84f9123..f332cde 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // flyway + implementation "org.flywaydb:flyway-core:11.11.1" + implementation "org.flywaydb:flyway-database-postgresql:11.11.1" } tasks.named('test') { diff --git a/src/main/resources/db/migration/V1__add_column_to_users.sql b/src/main/resources/db/migration/V1__add_column_to_users.sql new file mode 100644 index 0000000..07882ac --- /dev/null +++ b/src/main/resources/db/migration/V1__add_column_to_users.sql @@ -0,0 +1,3 @@ +-- 인증 서버로부터 받은 refresh_token을 저장할 컬럼 생성 +ALTER TABLE users + ADD COLUMN IF NOT EXISTS refresh_token VARCHAR(1000); \ No newline at end of file From 54c09da552812be18265a16fdf8ca173688f1b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Sun, 24 Aug 2025 15:59:30 +0900 Subject: [PATCH 19/21] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/studylog/service/UserService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/org/example/studylog/service/UserService.java b/src/main/java/org/example/studylog/service/UserService.java index 41c0fad..3d855ef 100644 --- a/src/main/java/org/example/studylog/service/UserService.java +++ b/src/main/java/org/example/studylog/service/UserService.java @@ -139,6 +139,14 @@ public BackgroundDTO.ResponseDTO updateBackground(String oauthId, BackgroundDTO. return responseDTO; } + public void logout(String refresh) { + // 리프레시 토큰이 DB에 존재하면 삭제 + if(refresh != null){ + log.info("로그아웃 요청으로 인한 RefreshToken 삭제"); + refreshRepository.deleteByRefresh(refresh); + } + } + @Transactional public void deleteAccount(String oauthId) { User user = userRepository.findByOauthId(oauthId); From 7a540361c3fa4d77f25ad01025edffda6b6aa76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Sun, 24 Aug 2025 16:00:09 +0900 Subject: [PATCH 20/21] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=9A=94=EC=B2=AD=20=EC=B2=98=EB=A6=AC=ED=95=A0=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20refresh?= =?UTF-8?q?=20=EC=BF=A0=ED=82=A4=20=EB=A6=AC=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studylog/controller/UserController.java | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/studylog/controller/UserController.java b/src/main/java/org/example/studylog/controller/UserController.java index 1c28728..d066ffd 100644 --- a/src/main/java/org/example/studylog/controller/UserController.java +++ b/src/main/java/org/example/studylog/controller/UserController.java @@ -4,6 +4,9 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -11,8 +14,10 @@ import org.example.studylog.dto.oauth.CustomOAuth2User; import org.example.studylog.dto.oauth.TokenDTO; import org.example.studylog.service.UserService; +import org.example.studylog.util.CookieUtil; import org.example.studylog.util.ResponseUtil; import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -122,17 +127,53 @@ public ResponseEntity updateBackground( } } + @Operation(summary = "로그아웃 API") + @ApiResponse(responseCode = "204", description = "로그아웃 완료", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDTO.class))) + @PostMapping("/logout") + public ResponseEntity logout(@AuthenticationPrincipal CustomOAuth2User currentUser, + HttpServletRequest request, + HttpServletResponse response){ + // 쿠키에서 refresh 토큰 얻기 + String refresh = null; + Cookie[] cookies = request.getCookies(); + for(Cookie cookie : cookies){ + if(cookie.getName().equals("refresh")){ + refresh = cookie.getValue(); + } + } + + log.info("사용자 로그아웃 시작: 사용자 = {}", currentUser.getName()); + userService.logout(refresh); + + log.info("쿠키 만료 재발급"); + ResponseCookie cookie = CookieUtil.createCookie("refresh", ""); + response.addHeader("Set-Cookie", cookie.toString()); + + log.info("사용자 로그아웃 완료: 사용자 = {}", currentUser.getName()); + + return ResponseUtil.buildResponse(204, "로그아웃 완료", null); + } + + @Operation(summary = "회원 탈퇴") @ApiResponse(responseCode = "204", description = "유저 삭제 완료", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ResponseDTO.class))) @DeleteMapping - public ResponseEntity deleteUser(@AuthenticationPrincipal CustomOAuth2User currentUser){ + public ResponseEntity deleteUser(@AuthenticationPrincipal CustomOAuth2User currentUser, + HttpServletResponse response){ try { log.info("유저 삭제 요청: 사용자={}", currentUser.getName()); String oauthId = currentUser.getName(); userService.deleteAccount(oauthId); + + ResponseCookie cookie = CookieUtil.createCookie("refresh", ""); + response.addHeader("Set-Cookie", cookie.toString()); + log.info("유저 삭제 완료: 사용자={}", currentUser.getName()); return ResponseUtil.buildResponse(204, "유저 삭제 완료", null); From e3443ff6d2cb93e478595f72edaa82cd2c04b8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9C=A4?= Date: Sun, 24 Aug 2025 16:03:02 +0900 Subject: [PATCH 21/21] =?UTF-8?q?[Chore]=20flyway=20V1=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._column_to_users.sql => V1__add_column_and_add_constraint.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V1__add_column_to_users.sql => V1__add_column_and_add_constraint.sql} (100%) diff --git a/src/main/resources/db/migration/V1__add_column_to_users.sql b/src/main/resources/db/migration/V1__add_column_and_add_constraint.sql similarity index 100% rename from src/main/resources/db/migration/V1__add_column_to_users.sql rename to src/main/resources/db/migration/V1__add_column_and_add_constraint.sql