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/java/org/example/studylog/client/GoogleClient.java b/src/main/java/org/example/studylog/client/GoogleClient.java new file mode 100644 index 0000000..886f239 --- /dev/null +++ b/src/main/java/org/example/studylog/client/GoogleClient.java @@ -0,0 +1,22 @@ +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); + +} 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..4dba8e6 --- /dev/null +++ b/src/main/java/org/example/studylog/client/KakaoApiClient.java @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..9a3ecec --- /dev/null +++ b/src/main/java/org/example/studylog/client/KakaoAuthClient.java @@ -0,0 +1,18 @@ +package org.example.studylog.client; + +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); +} 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); + } } 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) diff --git a/src/main/java/org/example/studylog/controller/UserController.java b/src/main/java/org/example/studylog/controller/UserController.java index 7943d29..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; @@ -121,4 +126,63 @@ public ResponseEntity updateBackground( return ResponseUtil.buildResponse(500, "내부 서버 오류입니다", null); } } + + @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, + 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); + } 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/dto/oauth/OAuthTokenResponse.java b/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java new file mode 100644 index 0000000..cd3ab33 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/OAuthTokenResponse.java @@ -0,0 +1,19 @@ +package org.example.studylog.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@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 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; } 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 24e9a2b..15c23c9 100644 --- a/src/main/java/org/example/studylog/entity/user/User.java +++ b/src/main/java/org/example/studylog/entity/user/User.java @@ -68,6 +68,9 @@ public class User { @OneToMany(mappedBy = "user") private List categories = new ArrayList<>(); + @Column(length = 1000) + private String refreshToken; + // 기록 수 증가 public void incrementRecordCount(){ this.recordCount++; diff --git a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java index 6338c6e..b9fcf1b 100644 --- a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java +++ b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java @@ -5,13 +5,19 @@ 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.AesGcmEncryptor; import org.example.studylog.util.CookieUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; 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; @@ -28,10 +34,19 @@ public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JWTUtil jwtUtil; private final TokenService tokenService; - - public CustomSuccessHandler(JWTUtil jwtUtil, TokenService tokenService) { + private final OAuth2AuthorizedClientService authorizedClientService; + private final UserRepository userRepository; + private final AesGcmEncryptor encryptor; + + public CustomSuccessHandler(JWTUtil jwtUtil, TokenService tokenService, + OAuth2AuthorizedClientService authorizedClientService, + UserRepository userRepository, + AesGcmEncryptor encryptor) { this.jwtUtil = jwtUtil; this.tokenService = tokenService; + this.authorizedClientService = authorizedClientService; + this.userRepository = userRepository; + this.encryptor = encryptor; } @Override @@ -42,6 +57,32 @@ 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 업데이트 + log.info("제공자로부터 받은 refreshToken을 DB에 저장"); + String raw = refreshToken.getTokenValue(); + String enc = encryptor.encrypt(raw); // 암호화 + User user = userRepository.findByOauthId(oauthId); + if (user != null) { + log.info("최초 로그인 시 RefreshToken 저장"); + user.setRefreshToken(enc); + userRepository.save(user); + } + } + Collection authorities = authentication.getAuthorities(); Iterator iterator = authorities.iterator(); GrantedAuthority auth = iterator.next(); 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); } diff --git a/src/main/java/org/example/studylog/service/UserService.java b/src/main/java/org/example/studylog/service/UserService.java index bdb3905..3d855ef 100644 --- a/src/main/java/org/example/studylog/service/UserService.java +++ b/src/main/java/org/example/studylog/service/UserService.java @@ -6,7 +6,9 @@ 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.service.oauth.ExternalOAuthUnlinkService; import org.example.studylog.util.ResponseUtil; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +22,8 @@ public class UserService { private final UserRepository userRepository; private final AwsS3Service awsS3Service; private final FriendRepository friendRepository; + private final RefreshRepository refreshRepository; + private final ExternalOAuthUnlinkService externalOAuthUnlinkService; @Transactional public ProfileResponseDTO createUserProfile(ProfileCreateRequestDTO request, String oauthId){ @@ -134,4 +138,39 @@ 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); + 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. 외부 연동 해제 + try { + externalOAuthUnlinkService.unlink(user); + } catch (Exception e) { + log.error("외부 연동 해제 중 오류(계정 삭제는 계속): {}", e.getMessage(), e); + } + + // 4. 유저 삭제 + userRepository.delete(user); + + } } 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..b5baac9 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; 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..63a1edb --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/ExternalOAuthUnlinkService.java @@ -0,0 +1,80 @@ +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 생략"); + } + } +} 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..9269bcb --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/GoogleOAuthService.java @@ -0,0 +1,16 @@ +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); + } +} 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..af5dd15 --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/KakaoOAuthService.java @@ -0,0 +1,64 @@ +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); + + } +} 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); + } + } + +} diff --git a/src/main/resources/db/migration/V1__add_column_and_add_constraint.sql b/src/main/resources/db/migration/V1__add_column_and_add_constraint.sql new file mode 100644 index 0000000..07882ac --- /dev/null +++ b/src/main/resources/db/migration/V1__add_column_and_add_constraint.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