Skip to content

Commit fb5c331

Browse files
joonhyong40foodChaejyZoo2-bi
authored
[URECA-58] Feat: 회원탈퇴 - 소셜 로그인 끊기 (#35)
* Initial commit * chore: 이슈 템플릿 생성 * chore: jira user create * chore: jira issue auto create * chore: jira issue auto close * chore: jira key auto prefix in commit&pr This workflow automatically prefixes pull request titles and new commits with the Jira issue key extracted from the branch name. * chore: GlobalExceptionHandler, ApiResponse 추가 * fix: auto jira key prefix * fix: auto jira key prefix * fix: auto jira key prefix * fix: jira key auto setting on pr * feat: Update code rabbit instruction * fix: pr jira key prefix에 pr type 추가 * fix: create-jira-issue issue type auto setting * fix: issue 양식 수정 Updated issue template title and branch description for clarity. * [URECA-21] Chore: domain/auth 및 global/security 구조 정립 (#8) * chore: domain/auth 구조 및 global/security 구조 분리 * chore: OAuth 공통부분 파일 작성 * chore: KakaoOAuthClient, GoogleOAuthClient 파일 생성 * chore: PR 템플릿 업로드 * chore: OAuthService 시그니처 변경, OAuthProvider Enum 도입, OAuthController 엔드포인트 정리, DTO 불변성 개선 * chore: OAuthController 입력 검증 추가, GlobalExceptionHandler 추가 * chore: GlobalExceptionHandler 에러 발생 시 내부 구현 노출 문제 및 로깅 누락 해결 * [URECA-27] Feat: Ureca 27/feat/oauth client (#14) * Feat: User 엔티티 생성 * Feat OAuthClient 생성 및 Google, Kakao, Naver OAuthClient 구현 * Feat: OAuthServiceImpl 및 UserMapper.xml 구현 -> 로그인 요청 확인 및 DB 연동 * Fix: GoogleOAuthClient, NaverOAuthClient에 name에 대한 fallback 추가 * [URECA-29] Feat: JWT 기반 AccessToken/RefreshToken 발급/재발급 및 로그아웃 구현 (#17) * Feat: User 엔티티 생성 * Feat OAuthClient 생성 및 Google, Kakao, Naver OAuthClient 구현 * Feat: OAuthServiceImpl 및 UserMapper.xml 구현 -> 로그인 요청 확인 및 DB 연동 * Fix: GoogleOAuthClient, NaverOAuthClient에 name에 대한 fallback 추가 * Fix: name처리 * Feat: WebMvcConfig 추가 - CORS 설정 * Feat: JWT 발급 로직 구현 * Feat: JWT 발급 로직 구현 * Feat: JWT 검증: JWT 인증 필터 및 보안 설정 적용 * Feat: JWT 검증: JWT 인증 필터 및 보안 설정 적용 * Feat: JWT 인증 예외 401로 정리 + RefreshToken 설계/저장 * Feat: refresh api 구현 * Feat: 리프레쉬토큰 DB 저장 및 RefreshTokenMapper에 resultMap 적용 * Feat: Refresh Rotation 구현 * Feat: 로드아웃 구현 * Docs: .coderabbit.yaml 파일 수정 * Fix: JJWT 버전 수정 및 의ì¡올바르지 않은 ´ì  의존성 제거 * Fix: 쿠키 보안 설정 개선 및 SameSite 속성 추가 * Fix: 로그아웃 엔드포인트 보호 추가 및 로그아웃 서비스 레이어 결합도 제거 * Fix: ãrefreshtoken을 body로 부터 분리 * Fix: ãrefreRefreshTokenServiceImpl 수정 * Fix CookieUtils 유틸 분리 * Fix: 사ìChore하지 않는 import 정리 * Fix RefreshTokenServiceImpl 죽은 코드 제거 및 jjwt 버전 업그레이드 * Fix: 모든 쿠키 생성/삭제 로직을 CookieUtils로 통일 * Fix: Nitpick Comment 정리 * Fix: OAuthServiceImpl 패키지 헬퍼 메소드 분리 * Fix: JWT 서명 검증 먼저하도록 수정 * Fix: userId 변수 중복 선언 수정 * Fix: Refresh Token Rotation에서 Race Condition 해소 * Chore: 들여쓰기 수정 * Fix: 예외사항 범위 확장 * Fix: DB/JWT 시간 통일 및 Access/Refresh Token 타입 Claim 분리 * Fix: REFRESH_TOKEN_INVALID 적용 범위 확장 * [URECA-29] Feat: 스프링, 그래들 버전 다운그레이드 (#20) * Feat: User 엔티티 생성 * Feat OAuthClient 생성 및 Google, Kakao, Naver OAuthClient 구현 * Feat: OAuthServiceImpl 및 UserMapper.xml 구현 -> 로그인 요청 확인 및 DB 연동 * Fix: GoogleOAuthClient, NaverOAuthClient에 name에 대한 fallback 추가 * Fix: name처리 * Feat: WebMvcConfig 추가 - CORS 설정 * Feat: JWT 발급 로직 구현 * Feat: JWT 발급 로직 구현 * Feat: JWT 검증: JWT 인증 필터 및 보안 설정 적용 * Feat: JWT 검증: JWT 인증 필터 및 보안 설정 적용 * Feat: JWT 인증 예외 401로 정리 + RefreshToken 설계/저장 * Feat: refresh api 구현 * Feat: 리프레쉬토큰 DB 저장 및 RefreshTokenMapper에 resultMap 적용 * Feat: Refresh Rotation 구현 * Feat: 로드아웃 구현 * Docs: .coderabbit.yaml 파일 수정 * Fix: JJWT 버전 수정 및 의ì¡올바르지 않은 ´ì  의존성 제거 * Fix: 쿠키 보안 설정 개선 및 SameSite 속성 추가 * Fix: 로그아웃 엔드포인트 보호 추가 및 로그아웃 서비스 레이어 결합도 제거 * Fix: ãrefreshtoken을 body로 부터 분리 * Fix: ãrefreRefreshTokenServiceImpl 수정 * Fix CookieUtils 유틸 분리 * Fix: 사ìChore하지 않는 import 정리 * Fix RefreshTokenServiceImpl 죽은 코드 제거 및 jjwt 버전 업그레이드 * Fix: 모든 쿠키 생성/삭제 로직을 CookieUtils로 통일 * Fix: Nitpick Comment 정리 * Fix: OAuthServiceImpl 패키지 헬퍼 메소드 분리 * Fix: JWT 서명 검증 먼저하도록 수정 * Fix: userId 변수 중복 선언 수정 * Fix: Refresh Token Rotation에서 Race Condition 해소 * Chore: 들여쓰기 수정 * Fix: 예외사항 범위 확장 * Fix: DB/JWT 시간 통일 및 Access/Refresh Token 타입 Claim 분리 * Fix: REFRESH_TOKEN_INVALID 적용 범위 확장 * fix: spring, gradle 버전 다운그레이드 * [URECA-41] Feat: Stt 구현 (#22) * feat: stt 1차 짧은 대화 성공 * fix: stt m4a to wav convert 추가 --------- Co-authored-by: 40food <40food@naver.com> * [URECA-48] Feat: 제미나이 요약 (#24) * chore: remove secrets and use env variables * URECA-48: remove secrets and use env variables * URECA-48: restore summary and gemini feature code (without secrets) * fix: STT 오류 고려한 Gemini 요약 프롬프트 보강 * fix: 오타 수정 * fix: Gemini 응답의 null 값에 대한 방어 로직 * fix: SummaryRequest 역직렬화 및 입력 검증 보강 * fix: Summary 저장 트랜잭션 처리 추가 * [URECA-49] Feat: DB 수정 반영 (#26) * fix: jwt필터에서 logout엔드í �¬인트 제거 * fix: users 테이블 관련 수정 * fix: refresh_tokens 테이블 관련 수정 * feat: 카카오 로그인 시 이메일, 닉네임 저장 가능하도록 ì수정 * chore: ErrorCode 확장 * feat: OAuthToken 도메인 추가 * feat: provider 토큰 저장하도록 로직 수정 * feat: provideP unlink/revoke 클라이언트 추가 * feat: 기존 회원탈퇴 로직에 unlink, 토큰삭제 넣기 * feat: 재가입 회원도 requireOnboarding 값을 true로 받도록 수정 * fix: unlink 어노테이션 수정 * fix: 복원된 사용자 인메모리 상태 동기화 수정 * fix: 마이너 리뷰 수정 * fix: provider 검증 추가 * fix: ãunlink를 트랜잭션 밖으로 분리하ê * fix: RestTemplate 타임아웃 설정 * fix: RestTemplate 타임아웃 설정 * feat: TypeHandler를 통해 OAuthToken 암호화 * fix: TImeTemplate을 의존성 주입으로 변경 및 oauth_tokens expires_at 컬럼 시간대 통일 * fix: 코드 래빗 리뷰 수정 및 oauth * fix: oauth_tokens expires_at 컬럼 시간대 KST로 변경 * fix: ErrorCode 문구 수정 --------- Co-authored-by: 박승연 <70251709+40food@users.noreply.github.com> Co-authored-by: Jiyeon <144954836+Chaejy@users.noreply.github.com> Co-authored-by: 박주이 <bagjui068@gmail.com> Co-authored-by: 40food <40food@naver.com>
1 parent 7590181 commit fb5c331

27 files changed

Lines changed: 714 additions & 92 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.ureca.unity.domain.auth.dto;
2+
3+
public record OAuthAuthResult(
4+
OAuthUserInfo userInfo,
5+
OAuthTokenInfo tokenInfo
6+
) {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.ureca.unity.domain.auth.dto;
2+
3+
public record OAuthTokenInfo(
4+
String accessToken,
5+
String refreshToken, // nullable
6+
Long expiresInSeconds // nullable
7+
) {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.ureca.unity.domain.auth.mapper;
2+
3+
import com.ureca.unity.domain.auth.model.OAuthToken;
4+
import org.apache.ibatis.annotations.Mapper;
5+
import org.apache.ibatis.annotations.Param;
6+
7+
import java.util.Optional;
8+
9+
@Mapper
10+
public interface OAuthTokenMapper {
11+
void insert(OAuthToken token);
12+
Optional<OAuthToken> findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
13+
void deleteByUserId(@Param("userId") Long userId);
14+
void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.ureca.unity.domain.auth.model;
2+
3+
import lombok.*;
4+
import java.time.LocalDateTime;
5+
6+
@Getter
7+
@Builder
8+
@AllArgsConstructor
9+
@NoArgsConstructor
10+
public class OAuthToken {
11+
private Long id;
12+
private Long userId;
13+
private String provider; // google / naver / kakao
14+
private String accessToken;
15+
private String refreshToken; // nullable
16+
private LocalDateTime expiresAt; // nullable
17+
}
Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
package com.ureca.unity.domain.auth.service;
22

33
import com.ureca.unity.domain.auth.constant.OAuthProvider;
4-
import com.ureca.unity.domain.auth.dto.OAuthLoginResponse;
5-
import com.ureca.unity.domain.auth.dto.OAuthLoginResult;
6-
import com.ureca.unity.domain.auth.dto.OAuthUserInfo;
7-
import com.ureca.unity.domain.auth.dto.TokenResponse;
4+
import com.ureca.unity.domain.auth.dto.*;
5+
import com.ureca.unity.domain.auth.model.OAuthToken;
86
import com.ureca.unity.domain.auth.service.oauth.OAuthClient;
97
import com.ureca.unity.domain.user.mapper.UserMapper;
108
import com.ureca.unity.domain.user.model.User;
119
import com.ureca.unity.global.exception.CustomException;
1210
import com.ureca.unity.global.exception.ErrorCode;
1311
import com.ureca.unity.global.security.JwtIssuer;
1412
import lombok.RequiredArgsConstructor;
13+
import org.springframework.dao.DataAccessException;
1514
import org.springframework.stereotype.Service;
15+
16+
import java.time.Instant;
17+
import java.time.LocalDateTime;
18+
import java.time.ZoneId;
19+
import java.time.ZoneOffset;
1620
import java.util.Map;
1721

1822
@Service
@@ -23,66 +27,90 @@ public class OAuthServiceImpl implements OAuthService {
2327
private final UserMapper userMapper;
2428
private final JwtIssuer jwtIssuer;
2529
private final RefreshTokenService refreshTokenService;
30+
private final OAuthTokenService oAuthTokenService;
2631

2732
@Override
2833
public OAuthLoginResult login(OAuthProvider provider, String authorizationCode) {
29-
// 1. OAuthClient 선택
3034
OAuthClient oAuthClient = oauthClients.get(provider.value());
3135
if (oAuthClient == null) {
3236
throw new CustomException(ErrorCode.INVALID_OAUTH_PROVIDER);
3337
}
3438

35-
// 2. OAuth 사용자 정보 조회
36-
OAuthUserInfo userInfo = oAuthClient.getUserInfo(authorizationCode);
39+
OAuthAuthResult auth = oAuthClient.authenticate(authorizationCode);
40+
OAuthUserInfo userInfo = auth.userInfo();
41+
OAuthTokenInfo tokenInfo = auth.tokenInfo();
3742

38-
// 3. 사용자 조회
3943
return userMapper
40-
.findByProviderAndProviderId(
41-
userInfo.getProvider(),
42-
userInfo.getProviderId()
43-
)
44-
.map(existingUser -> createLoginResult(existingUser.getId(), false)
45-
)
44+
.findByProviderAndProviderId(userInfo.getProvider(), userInfo.getProviderId())
45+
.map(u -> createLoginResult(u.getId(), false, provider.value(), tokenInfo))
4646
.orElseGet(() ->
47-
userMapper
48-
.findAnyByProviderAndProviderId(userInfo.getProvider(), userInfo.getProviderId())
47+
userMapper.findAnyByProviderAndProviderId(userInfo.getProvider(), userInfo.getProviderId())
4948
.map(anyUser -> {
50-
// 4-1. 탈퇴 유저면 복구
51-
if (anyUser.getDeletedAt() != null) {
49+
boolean wasDeleted = anyUser.getDeletedAt() != null;
50+
51+
if (wasDeleted) {
5252
userMapper.restoreById(
5353
anyUser.getId(),
5454
userInfo.getEmail(),
5555
userInfo.getName()
5656
);
5757
}
58-
return createLoginResult(anyUser.getId(), false);
58+
59+
// 재가입이면 true, 기존이면 false
60+
return createLoginResult(anyUser.getId(), wasDeleted, provider.value(), tokenInfo);
61+
})
62+
.orElseGet(() -> {
63+
User newUser = User.builder()
64+
.provider(userInfo.getProvider())
65+
.providerId(userInfo.getProviderId())
66+
.email(userInfo.getEmail())
67+
.name(userInfo.getName())
68+
.role("ROLE_USER")
69+
.build();
70+
userMapper.insert(newUser);
71+
return createLoginResult(newUser.getId(), true, provider.value(), tokenInfo);
5972
})
60-
.orElseGet(() -> {
61-
// 4-2. 신규 사용자 생성
62-
User newUser = User.builder()
63-
.provider(userInfo.getProvider())
64-
.providerId(userInfo.getProviderId())
65-
.email(userInfo.getEmail())
66-
.name(userInfo.getName())
67-
.role("ROLE_USER")
68-
.build();
69-
userMapper.insert(newUser);
70-
return createLoginResult(newUser.getId(), true);
71-
})
7273
);
7374
}
74-
private OAuthLoginResult createLoginResult(Long userId, boolean requiresOnboarding) {
75-
String refreshToken = jwtIssuer.issueRefreshToken(userId);
76-
refreshTokenService.saveRefreshToken(userId, refreshToken);
7775

78-
TokenResponse accessToken = jwtIssuer.issueAccessToken(userId);
76+
private OAuthLoginResult createLoginResult(
77+
Long userId,
78+
boolean requiresOnboarding,
79+
String provider,
80+
OAuthTokenInfo tokenInfo
81+
) {
82+
try {
83+
oAuthTokenService.upsert(
84+
OAuthToken.builder()
85+
.userId(userId)
86+
.provider(provider)
87+
.accessToken(tokenInfo.accessToken())
88+
.refreshToken(tokenInfo.refreshToken())
89+
.expiresAt(
90+
tokenInfo.expiresInSeconds() != null
91+
? LocalDateTime.ofInstant(
92+
Instant.now().plusSeconds(tokenInfo.expiresInSeconds()),
93+
ZoneId.of("Asia/Seoul")
94+
)
95+
: null
96+
)
97+
.build()
98+
);
7999

80-
return new OAuthLoginResult(
81-
OAuthLoginResponse.builder()
82-
.token(accessToken)
83-
.requiresOnboarding(requiresOnboarding)
84-
.build(),
85-
refreshToken
86-
);
100+
String refreshToken = jwtIssuer.issueRefreshToken(userId);
101+
refreshTokenService.saveRefreshToken(userId, refreshToken);
102+
103+
TokenResponse accessToken = jwtIssuer.issueAccessToken(userId);
104+
105+
return new OAuthLoginResult(
106+
OAuthLoginResponse.builder()
107+
.token(accessToken)
108+
.requiresOnboarding(requiresOnboarding)
109+
.build(),
110+
refreshToken
111+
);
112+
} catch (DataAccessException e) {
113+
throw new CustomException(ErrorCode.AUTH_STORAGE_FAILED);
114+
}
87115
}
88116
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ureca.unity.domain.auth.service;
2+
3+
import com.ureca.unity.domain.auth.model.OAuthToken;
4+
5+
import java.util.Optional;
6+
7+
public interface OAuthTokenService {
8+
void upsert(OAuthToken token);
9+
Optional<OAuthToken> find(Long userId, String provider);
10+
void deleteByUserId(Long userId);
11+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.ureca.unity.domain.auth.service;
2+
3+
import com.ureca.unity.domain.auth.mapper.OAuthTokenMapper;
4+
import com.ureca.unity.domain.auth.model.OAuthToken;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.transaction.annotation.Transactional;
8+
9+
import java.util.Optional;
10+
11+
@Service
12+
@RequiredArgsConstructor
13+
public class OAuthTokenServiceImpl implements OAuthTokenService {
14+
15+
private final OAuthTokenMapper oAuthTokenMapper;
16+
17+
@Transactional
18+
@Override
19+
public void upsert(OAuthToken token) {
20+
oAuthTokenMapper.deleteByUserIdAndProvider(token.getUserId(), token.getProvider());
21+
oAuthTokenMapper.insert(token);
22+
}
23+
24+
@Override
25+
public Optional<OAuthToken> find(Long userId, String provider) {
26+
return oAuthTokenMapper.findByUserIdAndProvider(userId, provider);
27+
}
28+
29+
@Transactional
30+
@Override
31+
public void deleteByUserId(Long userId) {
32+
oAuthTokenMapper.deleteByUserId(userId);
33+
}
34+
}

src/main/java/com/ureca/unity/domain/auth/service/oauth/GoogleOAuthClient.java

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.ureca.unity.domain.auth.service.oauth;
22

3+
import com.ureca.unity.domain.auth.dto.OAuthAuthResult;
4+
import com.ureca.unity.domain.auth.dto.OAuthTokenInfo;
35
import com.ureca.unity.domain.auth.dto.OAuthUserInfo;
46
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
58
import org.springframework.beans.factory.annotation.Value;
69
import org.springframework.http.*;
710
import org.springframework.stereotype.Component;
@@ -11,6 +14,7 @@
1114

1215
import java.util.Map;
1316

17+
@Slf4j
1418
@Component("google")
1519
@RequiredArgsConstructor
1620
public class GoogleOAuthClient implements OAuthClient {
@@ -30,15 +34,34 @@ public class GoogleOAuthClient implements OAuthClient {
3034
@Value("${oauth.google.redirect-uri}")
3135
private String redirectUri;
3236

33-
private final RestTemplate restTemplate = new RestTemplate();
37+
private final RestTemplate restTemplate;
3438

3539
@Override
36-
public OAuthUserInfo getUserInfo(String authorizationCode) {
37-
String accessToken = getAccessToken(authorizationCode);
38-
return fetchUserInfo(accessToken);
40+
public OAuthAuthResult authenticate(String authorizationCode) {
41+
Map<String, Object> token = getTokenResponse(authorizationCode); // access/refresh/expires_in
42+
43+
String accessToken = token.get("access_token").toString();
44+
String refreshToken = token.get("refresh_token") != null ? String.valueOf(token.get("refresh_token")) : null;
45+
Long expiresIn = null;
46+
if (token.get("expires_in") != null) {
47+
try {
48+
expiresIn = Long.valueOf(String.valueOf(token.get("expires_in")));
49+
} catch (NumberFormatException e) {
50+
log.warn("Google token response has non-numeric expires_in: {}", token.get("expires_in"));
51+
// expires_in 파싱 실패 시 null로 처리
52+
}
53+
}
54+
55+
OAuthUserInfo userInfo = fetchUserInfo(accessToken);
56+
57+
return new OAuthAuthResult(
58+
userInfo,
59+
new OAuthTokenInfo(accessToken, refreshToken, expiresIn)
60+
);
3961
}
4062

41-
private String getAccessToken(String code) {
63+
@SuppressWarnings({"rawtypes", "unchecked"})
64+
private Map<String, Object> getTokenResponse(String code) {
4265

4366
HttpHeaders headers = new HttpHeaders();
4467
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
@@ -50,8 +73,7 @@ private String getAccessToken(String code) {
5073
body.add("redirect_uri", redirectUri);
5174
body.add("code", code);
5275

53-
HttpEntity<MultiValueMap<String, String>> request =
54-
new HttpEntity<>(body, headers);
76+
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
5577

5678
ResponseEntity<Map> response = restTemplate.exchange(
5779
tokenUri,
@@ -64,9 +86,10 @@ private String getAccessToken(String code) {
6486
throw new IllegalArgumentException("Failed to retrieve Google access token");
6587
}
6688

67-
return response.getBody().get("access_token").toString();
89+
return (Map<String, Object>) response.getBody();
6890
}
6991

92+
7093
private OAuthUserInfo fetchUserInfo(String accessToken) {
7194

7295
HttpHeaders headers = new HttpHeaders();

0 commit comments

Comments
 (0)