Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6e737c2
[Feat] 연관관계에 ON DELETE CASCADE 제약조건 추가
y11n Aug 14, 2025
7fa2d92
[Feat] deleteAllByOauthId 메소드 추가
y11n Aug 14, 2025
c695606
[Feat] 최초 로그인 시 저장할 RefreshToken 필드 추가
y11n Aug 14, 2025
1f639a5
[Feat] 회원 탈퇴 API 추가
y11n Aug 14, 2025
32b1dba
[Feat] 최초 로그인 시 RefreshToken을 저장
y11n Aug 14, 2025
33bd382
[Refactor] CustomSuccessHandler로 RefreshToken 저장 로직 이동
y11n Aug 14, 2025
b7cfbcb
Merge branch 'develop' into Feat/#39-delete-user
y11n Aug 18, 2025
49539e2
[Feat] AES-GCM로 암호화하는 유틸 클래스 추가
y11n Aug 22, 2025
419c920
[Feat] 구글 로그인 시 인가요청 리졸버 추가
y11n Aug 22, 2025
feb72ff
[Feat] 로그인 과정에서 인증 서버로부터 받은 RefreshToken을 저장
y11n Aug 22, 2025
2eb36b5
[Feat] GoogleClient, KakaoAuthClient, KakaoApiClient 추가
y11n Aug 22, 2025
faf531e
[Feat] 구글/카카오 토큰 엔드포인트 응답을 받기 위한 공용 DTO 추가
y11n Aug 22, 2025
ce52fc5
[Feat] 구글 OAuth 서버에 토큰 재발급과 토큰 회수(revoke) 요청 보내는 client 추가
y11n Aug 22, 2025
b37edf7
[Feat] googleClient로 unlink 요청을 보내는 GoogleOAuthService 추가
y11n Aug 22, 2025
a512edc
[Feat] 카카오 연동 해제 및 accessToken 재발급 client 추가
y11n Aug 22, 2025
793f87b
[Feat] Kakao 인증 서버로 요청 보내는 KakaoOAuthService 추가
y11n Aug 22, 2025
d3c15a8
[Feat] Google/Kakao 서버와 연결을 해제하는 로직 추가
y11n Aug 22, 2025
e8eb611
[Feat] 회원 탈퇴 로직 추가
y11n Aug 22, 2025
5bcbda9
[Build] Flyway 의존성 추가
y11n Aug 22, 2025
f89c71c
Merge pull request #54 from Team-StudyLog/Feat/#39-delete-user
chaeminyu Aug 24, 2025
54c09da
[Feat] 로그아웃 메소드 추가
y11n Aug 24, 2025
7a54036
[Feat] 로그아웃 요청 처리할 메소드 추가 및 회원 탈퇴 시 refresh 쿠키 리셋
y11n Aug 24, 2025
e3443ff
[Chore] flyway V1 파일 이름 변경
y11n Aug 24, 2025
6a5934f
Merge pull request #57 from Team-StudyLog/Feat/#56-logout-api
y11n Aug 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/org/example/studylog/client/GoogleClient.java
Original file line number Diff line number Diff line change
@@ -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<String, String> form);

// 토큰 리보크(연결 해제)
@PostExchange(url = "/revoke", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
void unlink(@RequestParam String token);

}
17 changes: 17 additions & 0 deletions src/main/java/org/example/studylog/client/KakaoApiClient.java
Original file line number Diff line number Diff line change
@@ -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<String, String> form);
}
18 changes: 18 additions & 0 deletions src/main/java/org/example/studylog/client/KakaoAuthClient.java
Original file line number Diff line number Diff line change
@@ -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<String, String> form);
}
42 changes: 42 additions & 0 deletions src/main/java/org/example/studylog/config/HttpClientConfig.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
}
36 changes: 33 additions & 3 deletions src/main/java/org/example/studylog/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@
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;
import org.springframework.web.cors.CorsConfigurationSource;

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
Expand All @@ -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<String, Object> add = new java.util.LinkedHashMap<>(req.getAdditionalParameters());
add.put("access_type", "offline");
add.put("prompt", "consent");

// 병합 결과를 빌더에 다시 세팅
builder.additionalParameters(add);
}
});


// cors 설정
http
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/org/example/studylog/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
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;
import org.example.studylog.dto.*;
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;
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions src/main/java/org/example/studylog/entity/Friend.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

}
3 changes: 3 additions & 0 deletions src/main/java/org/example/studylog/entity/Streak.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/example/studylog/entity/StudyRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,6 +24,7 @@ public class Notification {
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Enumerated(EnumType.STRING)
Expand Down
Loading
Loading