Skip to content

Commit 2d2e14a

Browse files
authored
Merge pull request #167 from Project-BookLog/feat/135/detail
FIX: 소셜 로그인 토큰 문제 수정 / 스웨거 추가
2 parents ad8f924 + 6daff9b commit 2d2e14a

8 files changed

Lines changed: 226 additions & 12 deletions

File tree

booklog/src/main/java/com/example/booklog/global/auth/controller/AuthController.java

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
import com.example.booklog.global.auth.service.AuthQueryService;
99
import com.example.booklog.global.common.apiPayload.ApiResponse;
1010
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.media.Content;
12+
import io.swagger.v3.oas.annotations.media.ExampleObject;
13+
import io.swagger.v3.oas.annotations.media.Schema;
14+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1115
import io.swagger.v3.oas.annotations.tags.Tag;
1216
import jakarta.servlet.http.HttpServletResponse;
1317
import jakarta.validation.Valid;
@@ -66,10 +70,43 @@ public ApiResponse<AuthResDTO.LoginDTO> refreshToken(
6670
);
6771
}
6872

69-
// 카카오 소셜 로그인 (OAuth2 리다이렉트)
70-
@GetMapping("/kakao/login")
71-
@Operation(summary = "카카오 로그인", description = "카카오 OAuth2 로그인 페이지로 리다이렉트합니다.")
72-
public void kakaoLogin(HttpServletResponse response) throws IOException {
73+
// 카카오 소셜 로그인 (POST 방식 - Swagger 테스트용)
74+
@PostMapping("/kakao/login")
75+
@Operation(
76+
summary = "카카오 소셜 로그인",
77+
description = """
78+
카카오 액세스 토큰으로 로그인하여 JWT 토큰을 발급받습니다.
79+
80+
**[Request]**
81+
- kakaoAccessToken: 카카오에서 발급받은 액세스 토큰
82+
83+
**[Response]**
84+
- accessToken: JWT 액세스 토큰
85+
- refreshToken: JWT 리프레시 토큰
86+
- tokenType: Bearer
87+
- expiresIn: 만료 시간 (초)
88+
89+
**[카카오 액세스 토큰 발급 방법]**
90+
1. 카카오 개발자 도구: https://developers.kakao.com/tool/rest-api/open/get/v2-user-me
91+
2. 액세스 토큰 발급 후 아래 Request Body에 입력
92+
"""
93+
)
94+
public ApiResponse<AuthResDTO.LoginDTO> kakaoLogin(
95+
@RequestBody @Valid AuthReqDTO.KakaoLoginDTO dto
96+
) {
97+
return ApiResponse.onSuccess(
98+
AuthSuccessCode.LOGIN_SUCCESS,
99+
authQueryService.kakaoLogin(dto)
100+
);
101+
}
102+
103+
// 카카오 OAuth2 리다이렉트 (프론트엔드용)
104+
@GetMapping("/kakao/redirect")
105+
@Operation(
106+
summary = "카카오 OAuth2 리다이렉트 (프론트엔드용)",
107+
description = "카카오 로그인 페이지로 리다이렉트합니다. 프론트엔드에서 window.location.href로 호출하세요."
108+
)
109+
public void kakaoRedirect(HttpServletResponse response) throws IOException {
73110
String redirectUrl = serverDomain + "/oauth2/authorization/kakao";
74111
response.sendRedirect(redirectUrl);
75112
}

booklog/src/main/java/com/example/booklog/global/auth/dto/AuthReqDTO.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,12 @@ public record DeleteAccountDTO(
5656
@Schema(description = "비밀번호 (인앱 로그인 사용자만 필수, 소셜 로그인은 null 또는 빈 문자열 가능)", example = "Test1234!@")
5757
String password
5858
) {}
59+
60+
// 카카오 소셜 로그인 (테스트용)
61+
@Schema(name = "AuthKakaoLoginRequest", description = "카카오 소셜 로그인 테스트 요청")
62+
public record KakaoLoginDTO(
63+
@NotBlank(message = "카카오 액세스 토큰을 입력해주세요.")
64+
@Schema(description = "카카오 액세스 토큰", example = "kakao_access_token_here")
65+
String kakaoAccessToken
66+
) {}
5967
}

booklog/src/main/java/com/example/booklog/global/auth/exception/AuthErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ public enum AuthErrorCode {
1515
INVALID_TOKEN("AUTH_006", "유효하지 않은 토큰입니다."),
1616
DUPLICATE_EMAIL("AUTH_007", "이미 존재하는 이메일입니다."),
1717
INVALID_EMAIL_FORMAT("AUTH_008", "올바르지 않은 이메일 형식입니다."),
18-
INVALID_PASSWORD("AUTH_009", "비밀번호가 올바르지 않습니다. 회원탈퇴를 진행할 수 없습니다.");
18+
INVALID_PASSWORD("AUTH_009", "비밀번호가 올바르지 않습니다. 회원탈퇴를 진행할 수 없습니다."),
19+
KAKAO_API_ERROR("AUTH_010", "카카오 로그인 처리 중 오류가 발생했습니다. 관리자에게 문의해주세요."),
20+
KAKAO_TOKEN_INVALID("AUTH_011", "카카오 액세스 토큰이 유효하지 않거나 만료되었습니다. 카카오 개발자 도구에서 새 토큰을 발급받아주세요.");
1921

2022
private final String code;
2123
private final String message;

booklog/src/main/java/com/example/booklog/global/auth/security/JwtUtil.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ public JwtUtil(
3535

3636
// AccessToken 생성
3737
public String createAccessToken(CustomUserDetails user) {
38-
return createToken(user, accessExpiration);
38+
return createToken(user, accessExpiration, "access");
3939
}
4040

4141
// RefreshToken 생성
4242
public String createRefreshToken(CustomUserDetails user) {
43-
return createToken(user, refreshExpiration);
43+
return createToken(user, refreshExpiration, "refresh");
4444
}
4545

4646
// OAuth2 로그인용 AccessToken 생성 (이메일로)
@@ -105,7 +105,7 @@ public boolean isValid(String token) {
105105
}
106106

107107
// 토큰 생성
108-
private String createToken(CustomUserDetails user, Duration expiration) {
108+
private String createToken(CustomUserDetails user, Duration expiration, String tokenType) {
109109
Instant now = Instant.now();
110110

111111
// 인가 정보
@@ -115,6 +115,7 @@ private String createToken(CustomUserDetails user, Duration expiration) {
115115

116116
return Jwts.builder()
117117
.subject(user.getUsername()) // User 이메일을 Subject로
118+
.claim("type", tokenType) // 토큰 타입 (access/refresh)
118119
.claim("role", authorities)
119120
.claim("email", user.getUsername())
120121
.issuedAt(Date.from(now)) // 언제 발급한지

booklog/src/main/java/com/example/booklog/global/auth/security/OAuth2AuthenticationSuccessHandler.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,20 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
4040

4141
log.info("OAuth2 로그인 성공: userId={}, email={}", userId, email);
4242

43-
// JWT 토큰 생성
44-
String accessToken = jwtUtil.generateAccessToken(email);
45-
String refreshToken = jwtUtil.generateRefreshToken(email);
43+
// CustomUserDetails 생성 (role 정보 포함)
44+
CustomUserDetails userDetails = new CustomUserDetails(oAuth2User.getAccount());
45+
46+
// JWT 토큰 생성 (issuedAt 포함, 매번 다른 토큰 생성)
47+
String accessToken = jwtUtil.createAccessToken(userDetails);
48+
String refreshToken = jwtUtil.createRefreshToken(userDetails);
49+
50+
log.info("생성된 액세스 토큰 (앞 30자): {}", accessToken.substring(0, Math.min(30, accessToken.length())));
51+
log.info("생성된 리프레시 토큰 (앞 30자): {}", refreshToken.substring(0, Math.min(30, refreshToken.length())));
4652

4753
// Refresh Token DB에 저장
54+
refreshTokenRepository.findByEmail(email)
55+
.ifPresent(refreshTokenRepository::delete);
56+
4857
RefreshToken refreshTokenEntity = RefreshToken.builder()
4958
.token(refreshToken)
5059
.email(email)

booklog/src/main/java/com/example/booklog/global/auth/service/AuthQueryService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ public interface AuthQueryService {
99
AuthResDTO.LoginDTO login(AuthReqDTO.@Valid LoginDTO dto);
1010

1111
AuthResDTO.LoginDTO refreshToken(AuthReqDTO.@Valid RefreshTokenDTO dto);
12+
13+
AuthResDTO.LoginDTO kakaoLogin(AuthReqDTO.@Valid KakaoLoginDTO dto);
1214
}

booklog/src/main/java/com/example/booklog/global/auth/service/AuthQueryServiceImpl.java

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,45 @@
22

33
import com.example.booklog.domain.users.entity.AuthAccounts;
44
import com.example.booklog.domain.users.entity.AuthProvider;
5+
import com.example.booklog.domain.users.entity.UserSettings;
6+
import com.example.booklog.domain.users.entity.UserStatus;
7+
import com.example.booklog.domain.users.entity.Users;
8+
import com.example.booklog.domain.users.repository.UserSettingsRepository;
9+
import com.example.booklog.domain.users.repository.UsersRepository;
510
import com.example.booklog.global.auth.entity.RefreshToken;
611
import com.example.booklog.global.auth.repository.AuthAccountsRepository;
712
import com.example.booklog.global.auth.repository.RefreshTokenRepository;
813
import com.example.booklog.global.auth.converter.AuthConverter;
914
import com.example.booklog.global.auth.dto.AuthReqDTO;
1015
import com.example.booklog.global.auth.dto.AuthResDTO;
16+
import com.example.booklog.global.auth.enums.Role;
1117
import com.example.booklog.global.auth.exception.AuthErrorCode;
1218
import com.example.booklog.global.auth.exception.AuthException;
1319
import com.example.booklog.global.auth.security.CustomUserDetails;
1420
import com.example.booklog.global.auth.security.JwtUtil;
1521
import jakarta.validation.Valid;
1622
import lombok.RequiredArgsConstructor;
23+
import lombok.extern.slf4j.Slf4j;
24+
import org.springframework.http.*;
1725
import org.springframework.security.crypto.password.PasswordEncoder;
1826
import org.springframework.stereotype.Service;
1927
import org.springframework.transaction.annotation.Transactional;
28+
import org.springframework.web.client.RestTemplate;
2029

30+
import java.util.Map;
31+
32+
@Slf4j
2133
@Service
2234
@RequiredArgsConstructor
2335
public class AuthQueryServiceImpl implements AuthQueryService {
2436

2537
private final AuthAccountsRepository authAccountsRepository;
2638
private final RefreshTokenRepository refreshTokenRepository;
39+
private final UsersRepository usersRepository;
40+
private final UserSettingsRepository userSettingsRepository;
2741
private final JwtUtil jwtUtil;
2842
private final PasswordEncoder encoder;
43+
private final RestTemplate restTemplate = new RestTemplate();
2944

3045
@Override
3146
@Transactional
@@ -107,4 +122,143 @@ public AuthResDTO.LoginDTO refreshToken(AuthReqDTO.@Valid RefreshTokenDTO dto) {
107122
// 9. 응답 DTO 반환
108123
return AuthConverter.toLoginDTO(account, newAccessToken, newRefreshToken, jwtUtil.getAccessExpirationMillis() / 1000);
109124
}
125+
126+
@Override
127+
@Transactional
128+
public AuthResDTO.LoginDTO kakaoLogin(AuthReqDTO.@Valid KakaoLoginDTO dto) {
129+
log.info("=== 카카오 로그인 API 호출 시작 ===");
130+
log.info("받은 카카오 토큰 (앞 20자): {}", dto.kakaoAccessToken().substring(0, Math.min(20, dto.kakaoAccessToken().length())));
131+
132+
// 1. 카카오 API로 사용자 정보 조회
133+
String kakaoUserInfoUrl = "https://kapi.kakao.com/v2/user/me";
134+
135+
HttpHeaders headers = new HttpHeaders();
136+
headers.setBearerAuth(dto.kakaoAccessToken());
137+
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
138+
139+
HttpEntity<String> entity = new HttpEntity<>(headers);
140+
141+
ResponseEntity<Map<String, Object>> response;
142+
try {
143+
log.info("카카오 API 호출 시도: {}", kakaoUserInfoUrl);
144+
@SuppressWarnings("unchecked")
145+
ResponseEntity<Map<String, Object>> temp = (ResponseEntity<Map<String, Object>>) (ResponseEntity<?>) restTemplate.exchange(kakaoUserInfoUrl, HttpMethod.GET, entity, Map.class);
146+
response = temp;
147+
148+
log.info("카카오 API 응답 상태: {}", response.getStatusCode());
149+
150+
// 응답 상태 코드 확인
151+
if (!response.getStatusCode().is2xxSuccessful()) {
152+
log.error("카카오 API 호출 실패: HTTP Status {}, Body: {}", response.getStatusCode(), response.getBody());
153+
throw new AuthException(AuthErrorCode.KAKAO_API_ERROR);
154+
}
155+
} catch (AuthException e) {
156+
throw e;
157+
} catch (org.springframework.web.client.HttpClientErrorException e) {
158+
log.error("카카오 API 호출 실패 (HTTP 에러): Status={}, Message={}, Body={}",
159+
e.getStatusCode(), e.getMessage(), e.getResponseBodyAsString());
160+
throw new AuthException(AuthErrorCode.KAKAO_TOKEN_INVALID);
161+
} catch (Exception e) {
162+
log.error("카카오 API 호출 실패 (예외): Type={}, Message={}", e.getClass().getSimpleName(), e.getMessage(), e);
163+
throw new AuthException(AuthErrorCode.KAKAO_TOKEN_INVALID);
164+
}
165+
166+
Map<String, Object> attributes = response.getBody();
167+
if (attributes == null || attributes.isEmpty()) {
168+
log.error("카카오 API 응답이 비어있습니다.");
169+
throw new AuthException(AuthErrorCode.KAKAO_API_ERROR);
170+
}
171+
172+
log.info("카카오 API 응답 데이터: {}", attributes);
173+
174+
// 2. 카카오 사용자 정보 파싱
175+
String providerId = String.valueOf(attributes.get("id"));
176+
@SuppressWarnings("unchecked")
177+
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
178+
@SuppressWarnings("unchecked")
179+
Map<String, Object> profile = kakaoAccount != null ? (Map<String, Object>) kakaoAccount.get("profile") : null;
180+
181+
String email = kakaoAccount != null ? (String) kakaoAccount.get("email") : null;
182+
String nickname = profile != null ? (String) profile.get("nickname") : null;
183+
String profileImageUrl = profile != null ? (String) profile.get("profile_image_url") : null;
184+
185+
log.info("카카오 로그인 시도: providerId={}, email={}, nickname={}", providerId, email, nickname);
186+
187+
if (email == null || email.isEmpty()) {
188+
log.error("카카오 계정에 이메일 정보가 없습니다. providerId={}", providerId);
189+
throw new AuthException(AuthErrorCode.KAKAO_API_ERROR);
190+
}
191+
192+
// ...existing code...
193+
194+
// 3. 기존 계정 조회 또는 신규 생성
195+
AuthAccounts authAccount = authAccountsRepository
196+
.findByProviderIdAndProvider(providerId, AuthProvider.KAKAO)
197+
.orElseGet(() -> createKakaoAccount(providerId, email, nickname, profileImageUrl));
198+
199+
// 4. 기존 계정이면 마지막 로그인 시간 및 프로필 업데이트
200+
authAccount.updateLastLogin();
201+
authAccount.updateProfile(email, nickname, profileImageUrl);
202+
authAccountsRepository.save(authAccount);
203+
204+
// 5. CustomUserDetails 생성
205+
CustomUserDetails userDetails = new CustomUserDetails(authAccount);
206+
207+
// 6. JWT 액세스 토큰 발급
208+
String accessToken = jwtUtil.createAccessToken(userDetails);
209+
210+
// 7. JWT 리프레시 토큰 발급
211+
String refreshToken = jwtUtil.createRefreshToken(userDetails);
212+
213+
// 8. 기존 RefreshToken 삭제 후 새로 저장
214+
refreshTokenRepository.findByEmail(email)
215+
.ifPresent(refreshTokenRepository::delete);
216+
217+
RefreshToken newRefreshToken = RefreshToken.builder()
218+
.token(refreshToken)
219+
.email(email)
220+
.expiryDate(java.time.LocalDateTime.now().plus(jwtUtil.getRefreshExpiration()))
221+
.build();
222+
refreshTokenRepository.save(newRefreshToken);
223+
224+
// 9. 응답 DTO 반환
225+
return AuthConverter.toLoginDTO(authAccount, accessToken, refreshToken, jwtUtil.getAccessExpirationMillis() / 1000);
226+
}
227+
228+
/**
229+
* 카카오 신규 계정 생성
230+
*/
231+
private AuthAccounts createKakaoAccount(String providerId, String email, String nickname, String profileImageUrl) {
232+
log.info("신규 카카오 사용자 등록: providerId={}", providerId);
233+
234+
// Users 엔티티 생성
235+
Users newUser = Users.builder()
236+
.nickname(nickname)
237+
.profileImageUrl(profileImageUrl)
238+
.status(UserStatus.ACTIVE)
239+
.build();
240+
usersRepository.save(newUser);
241+
242+
// AuthAccounts 생성
243+
AuthAccounts newAuthAccount = AuthAccounts.builder()
244+
.user(newUser)
245+
.provider(AuthProvider.KAKAO)
246+
.providerId(providerId)
247+
.email(email)
248+
.displayName(nickname)
249+
.profileImageUrl(profileImageUrl)
250+
.role(Role.ROLE_USER)
251+
.build();
252+
authAccountsRepository.save(newAuthAccount);
253+
254+
// UserSettings 생성 (서재/북로그 공개 설정 기본값: true)
255+
UserSettings userSettings = UserSettings.builder()
256+
.user(newUser)
257+
.isShelfPublic(true)
258+
.isPostPublic(true)
259+
.build();
260+
userSettingsRepository.save(userSettings);
261+
262+
return newAuthAccount;
263+
}
110264
}

booklog/src/main/java/com/example/booklog/global/common/apiPayload/handler/GlobalExceptionHandler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ private HttpStatus mapAuthErrorToHttpStatus(AuthErrorCode errorCode) {
117117
case NOT_FOUND -> HttpStatus.NOT_FOUND;
118118
case UNAUTHORIZED, TOKEN_EXPIRED, INVALID_TOKEN -> HttpStatus.UNAUTHORIZED;
119119
case FORBIDDEN -> HttpStatus.FORBIDDEN;
120-
case DUPLICATE_EMAIL, INVALID_EMAIL_FORMAT, INVALID, INVALID_PASSWORD -> HttpStatus.BAD_REQUEST;
120+
case DUPLICATE_EMAIL, INVALID_EMAIL_FORMAT, INVALID, INVALID_PASSWORD,
121+
KAKAO_API_ERROR, KAKAO_TOKEN_INVALID -> HttpStatus.BAD_REQUEST;
121122
};
122123
}
123124

0 commit comments

Comments
 (0)