Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 카카오 로그인 구현 #60

Merged
merged 19 commits into from
Aug 18, 2024
Merged

feat: 카카오 로그인 구현 #60

merged 19 commits into from
Aug 18, 2024

Conversation

yueunfive
Copy link
Contributor

@yueunfive yueunfive commented Aug 16, 2024

요약

카카오 로그인 구현

  • 프로필 생성 전 로그인시 oauthId 반환 -> 프로필 생성 API 요청시 사용
  • 프로필 생성 후 로그인시 JWT 토큰(access & refresh) 반환

작업 내용

  • 카카오 로그인 구현(Oauth2)
  • accessToken, refreshToken 발급(Refresh Token Rotation)
  • 토큰 관련 예외처리
  • refreshToken -> redis 저장

기타 (논의하고 싶은 부분)

프로필 생성 전 로그인 : 토큰 발급 X
스크린샷 2024-08-18 오전 1 36 52

프로필 생성 후 로그인 : 토큰 발급 O
스크린샷 2024-08-18 오전 1 31 55

유효하지 않은 인가코드 삽입 삽입시
스크린샷 2024-08-18 오전 1 31 37

타 직군 전달 사항

@froggy1014
로그인 기능은 구현됐으나 프로필 생성 api로 회원 정보가 DB에 등록되어야 JWT 토큰이 발급될 것 같습니다.
-> 08/17(토) 중으로 구현 예정
로컬 DB에서 임시로 유저 데이터 생성 후 테스트한 결과 JWT 토큰 발급 및 레디스 저장까지 확인했습니다.
스크린샷 2024-08-16 오후 11 55 37
스크린샷 2024-08-17 오전 12 13 01

close #53

@yueunfive yueunfive added the type: feat 새로운 기능 구현 label Aug 16, 2024
@yueunfive yueunfive self-assigned this Aug 16, 2024
Copy link

github-actions bot commented Aug 16, 2024

Unit Test Results

52 tests   52 ✔️  6s ⏱️
16 suites    0 💤
16 files      0

Results for commit 16150c3.

♻️ This comment has been updated with latest results.

Copy link
Member

@punchdrunkard punchdrunkard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!!!!!
서비스단 테스트는 아마 프라이빗 메서드 제외하고 로그인 부분만 테스트 코드 작성하심 될거에요!

return null;
}

private void sendErrorResponse(HttpServletResponse response, String message, String code) throws IOException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

파라미터로 하나씩 작성하는 것 보다 ErrorCode 자체를 받아서 생성하도록 만드시는게 좋을 것 같아요.

Comment on lines 86 to 88
errorResponse.put("statusCode", 401);
errorResponse.put("code", code);
errorResponse.put("message", message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

에러코드로 받는다면 ErrorCode.getStatusCode, ErrorCode.getCode, ErrorCode.getMessage 형식으로 쓸 수 있어서
뒤에 코드에서 하나씩 string 으로 작성하지 않으셔도 돼요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분에서 계속 예외처리가 안되더라구요.. 일단은 에러코드 형식에 맞게 저렇게 써놓은 상태입니다.
스크린샷 2024-08-17 오전 9 26 15
https://velog.io/@jsang_log/Security-Filter-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-JWT

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 저는 커스텀 에러를 던지라는게 아니라 저 블로그 방식처럼 파라미터로 ErrorCode 를 받아서 메서드로 처리하라는 의미였어요

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

블로그 코드보면


public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { // 인자로 ErrorCode를 받음
        response.setContentType("application/json;charset=UTF-8");


// 내용을 ErrorCode의 메서드 활용해서 작성        response.setStatus(errorCode.getHttpStatus().value());
        ObjectMapper objectMapper = new ObjectMapper();

        ErrorResponse errorResponse = new ErrorResponse
                (errorCode, errorCode.getMessage());

import java.util.Set;

@RequiredArgsConstructor
@Service
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4

@Component 로 등록해도 되지 않나요? @Service 를 사용하신 이유가 무엇일까요?

Comment on lines 46 to 47
System.out.println("Token issued at: " + now);
System.out.println("Token expires at: " + expiry);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2

표준 출력보다는 로그 출력을 사용하시는걸 권장드려요! (성능상으로 이슈가 큰 부분이에요)
Lombok의 @Slf4j import 하시고

log.warn()"Token issued at: {}", now);

형식으로 작성하시면 돼요!

Comment on lines 50 to 56
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 typ(타입) : JWT
.setIssuedAt(now) // 내용 iat(발급 일시) : 현재 시간
.setExpiration(expiry) // 내용 exp(만료일시) : expiry 멤버 변수값
.setSubject(String.valueOf(user.getId())) // 내용 sub(토큰 제목) : 회원 ID
.claim("id", user.getId()) // 클레임 id : 회원 ID
.claim("category", category) // access or refresh
.signWith(getSecretKey(), SignatureAlgorithm.HS256) // HS256 방식으로 암호화
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4

이곳에 있는 주석은 지워주셔도 될 것 같아요ㅎㅎ

*/
public boolean isExpired(String token) {
Claims claims = getClaims(token);
return claims.getExpiration().before(new Date());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4

현재 프로젝트에서 Clock 을 사용하고 있기 때문에

private final Clock clock;

컴포넌트 상단에서 Clock 을 의존성으로 받아주시고

new LocalDateTime(Clock) 으로 설정하셔야 테스트에서.... 문제가 안생깁니다 ㅎㅎ....

*/
public String getCategory(String token) {
Claims claims = getClaims(token);
return claims.get("category", String.class);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P5

카테고리는 어떤 의미인가요?

Comment on lines 8 to 10
boolean existsByProviderId(Long providerId);

OauthUser findByProviderId(Long providerId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4

Optional 로 감싸주시는게 좋을 것 같습니다!

Comment on lines +76 to +80
return UserResponse.loginDTO.builder()
.oauthId(oauthId)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

accessTokenrefreshToken 이 null 인 경우에 validation 이 필요해 보입니다!

try {
jsonNode = objectMapper.readTree(responseBody);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4

클라이언트에게 정의된 메시지가 갈 수 있게 CustomException 의 서버 에러로 던지는게 좋을 것 같습니다!

HttpServletResponse response = mock(HttpServletResponse.class);

// Mocking repository and token provider
when(oauthUserRepository.findByProviderId(anyLong())).thenThrow(new CustomException(ErrorCode.INVALID_CODE));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인가코드 유효성 검증은
oauthUserRespository 가 아니라 UserServicegetAccessToken 에서 일어나고 있습니다.
따라서 이 부분은 불필요한 stubbing 입니다.

또한 userSerivce 는 mock 객체가 아니므로, 등록해둔 부분을 제외하면 실제 클래스의 메서드로 동작하고 있습니다.
따라서 유효성 검증이 일어나는 부분을 mock 처리해주셔야 합니다.

현재 유효성 검증은 http request를 보내는 RestTemplate 에서 일어나고 있으므로
RestTemplate@Mock 으로 등록해주시고,

@BeforeEach 를 다음과 같이 설정한 후

@BeforeEach
	public void init() {
		mockServer = MockRestServiceServer.createServer(restTemplate);
	}
mockServer.expect(ExpectedCount.once(),
				requestTo(new URI("https://kauth.kakao.com/oauth/token")))
			.andExpect(method(HttpMethod.GET))
			.andRespond(withStatus(HttpStatus.BAD_REQUEST)
				.contentType(MediaType.APPLICATION_JSON)
			);

위와 같이 모킹해주시면 됩니다.

Comment on lines 81 to 84
when(oauthUserRepository.findByProviderId(anyLong())).thenReturn(null);
when(tokenProvider.generateToken(any(User.class), any(), any()))
.thenReturn(accessToken) // 첫 번째 호출 반환값
.thenReturn(refreshToken); // 두 번째 호출 반환값
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드를 확인해보면

Optional<OauthUser> optionalUser = oauthUserRepository.findByProviderId(oauthId);

        //DB에 회원정보가 있을때 토큰 발급
        if (optionalUser.isPresent()) {
            OauthUser user = optionalUser.get();

            //토큰 생성
            accessToken = tokenProvider.generateToken(user, Duration.ofHours(2), "access");
            refreshToken = tokenProvider.generateToken(user, Duration.ofDays(14), "refresh");
            log.info("access: " + accessToken);
            log.info("refresh : " + refreshToken);

            //Refresh 토큰 저장
            redisUtils.setData(user.getId().toString(), refreshToken, Duration.ofDays(14).toMillis());
        }

        //응답 설정
        return UserResponse.loginDTO.builder()
                .oauthId(oauthId)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();

신규 사용자인 경우 (findByProviderId 의 결과가 null 인 경우)
accessTokenrefreshToken 이 발급되고 있지 않습니다.
신규 사용자면 해당 토큰 값들에 null 이 들어가게 되어 로그인이 동작하지 않을 것 같아요.

Copy link
Member

@punchdrunkard punchdrunkard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선 유저 테스트는 제거해주시고,
build.gradle 에 jacoco 에서 user 도메인은 제외해주세요.

그리고 코멘트에 대한 답장 부탁드립니다.


public String generateToken(User user, Duration expiredAt, String category) {
LocalDateTime now = LocalDateTime.now(clock);
Date expiryDate = Date.from(now.plus(expiredAt).atZone(ZoneId.systemDefault()).toInstant());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

관련 자료

참고로 Date는 거의 모든 새로운 프로젝트에서 더 이상 권장되지 않습니다.
Jwt 라이브러리 때문에 Date 를 쓰신 것 같지만, 참고용으로 자료 올려드립니다.

.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분 로컬에서 토큰이 유효하지 않을 때, 에러가 발생하는지 확인해주세요.


@PostMapping("/login")
@Operation(summary = "카카오 로그인", description = "카카오 인가코드를 통해 로그인 후 JWT 토큰을 생성합니다.")
public ResponseEntity<BasicResponse<UserResponse.loginDTO>> kakaoLogin(@RequestHeader String code, HttpServletRequest request, HttpServletResponse response) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestresponse 는 사용되고 있지않습니다.
서비스에서도 마찬가지고요.

이미 헤더에 있는 인가 코드 @RequestHeader 로 넘기고 있습니다.

@PostMapping("/login")
@Operation(summary = "카카오 로그인", description = "카카오 인가코드를 통해 로그인 후 JWT 토큰을 생성합니다.")
public ResponseEntity<BasicResponse<UserResponse.loginDTO>> kakaoLogin(@RequestHeader String code, HttpServletRequest request, HttpServletResponse response) {
UserResponse.loginDTO loginResponse = userService.kakaoLogin(code, request, response);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용하지 않는 인수는 제거해주세요.


@PostMapping("/login")
@Operation(summary = "카카오 로그인", description = "카카오 인가코드를 통해 로그인 후 JWT 토큰을 생성합니다.")
public ResponseEntity<BasicResponse<UserResponse.loginDTO>> kakaoLogin(@RequestHeader String code, HttpServletRequest request, HttpServletResponse response) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컨트롤러 메서드이기 때문에 파라미터에 대한 검증이 이루어져야 합니다.
code 에 대해 @NotNull과 같은 validation 을 추가해주세요.

private String REDIRECT_URI;

@Transactional
public UserResponse.loginDTO kakaoLogin(String code, HttpServletRequest request, HttpServletResponse response) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpServletRequest request, HttpServletResponse response 는 사용하지 않는 파라미터 입니다.

Comment on lines +66 to +77
if (optionalUser.isPresent()) {
OauthUser user = optionalUser.get();

//토큰 생성
accessToken = tokenProvider.generateToken(user, Duration.ofHours(2), "access");
refreshToken = tokenProvider.generateToken(user, Duration.ofDays(14), "refresh");
log.info("access: " + accessToken);
log.info("refresh : " + refreshToken);

//Refresh 토큰 저장
redisUtils.setData(user.getId().toString(), refreshToken, Duration.ofDays(14).toMillis());
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

신규 생성된 회원일경우 (DB에 회원정보가 없을 때) 에 대한 처리가 없습니다.

// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 으로 대체할 수 있습니다.


// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 으로 대체할 수 있습니다.

private JsonNode getKakaoUserInfo(String accessToken) {
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers.setBearerAuth(accessToken);
으로 대체할 수 있습니다.

@punchdrunkard
Copy link
Member

이 부분 동작하는지 확인해주세요

컨트롤러

  • /login 요청 시, code 가 들어오지 않았을 때 예외처리

서비스

  • kakaoLogin 메서드에 DB에 회원정보가 없을 때 동작

@yueunfive
Copy link
Contributor Author

넵 피드백 주신 부분들 수정해서 커밋했습니다. Date 부분은 수정해도 jwt 토큰 생성할 때 계속 Date로 변경하라고 해서 일단은 그대로 둔 상태입니다. 그외 반영 못한 피드백들은 학습이 필요하거나 이해하는데 시간이 좀 걸리는 부분들이라 제 선에서는 넘어가는 점 양해 부탁드릴게요..

  • 스웨거로 테스트한 부분 이미지로 추가했습니다.

@punchdrunkard
Copy link
Member

punchdrunkard commented Aug 17, 2024

아 저렇게 되는군요!
프로필 생성 전 로그인 : 토큰 발급 X
의 경우는 프론트한테 어떻게 구분되는지 알려줘야할 것 같아요!
(저는 처음에 보고 에러가 던져질 줄 알았어요!)

@punchdrunkard
Copy link
Member

이거 문제가 유은님 문제가 아닌 것 같아서
잠시 좀 고쳐보겠습니다....

@yueunfive
Copy link
Contributor Author

프로필 생성 전 로그인 : 토큰 발급 X 해당 부분 노션 명세서에 반영해 놓은 상태입니다. 머지할게요!

@yueunfive yueunfive merged commit 3a5c17a into main Aug 18, 2024
@yueunfive yueunfive deleted the feat/#53 branch August 18, 2024 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: feat 새로운 기능 구현
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feat: 카카오 로그인 구현
2 participants