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

[#8] 카카오 로그인 OIDC -> 자체 JWT 발급 방식 변경 #11

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0c49b91
refactor: 카카오 auth 링크 api 제거
hynxp Jan 13, 2025
31c8a08
chore: jwt 관련 라이브러리 추가
hynxp Jan 14, 2025
3b844b8
chore: jwt secret key property 추가
hynxp Jan 14, 2025
b5b4ace
feat: 우동 자체 토큰 발급기 JwtTokenProvider 추가
hynxp Jan 14, 2025
fe01aa7
test: JwtTokenProvider 테스트
hynxp Jan 14, 2025
4bb6b3f
feat: idToken -> 자체 JWT 발급 방식으로 변경
hynxp Jan 14, 2025
6d3ad7d
test: 사용자 정보 저장, 재발급된 refresh_token 저장 테스트
hynxp Jan 14, 2025
03cd3f7
test: 미사용 코드 제거 및 테스트 코드 수정
hynxp Jan 14, 2025
b40fad9
feat: 카카오 api refresh_token 갱신 api 제거
hynxp Jan 14, 2025
6c59464
feat: 토큰 검증 시 만료될 토큰이면 예외 던지기
hynxp Jan 14, 2025
923feb6
test: 중복 테스트 제거, 만료된 토큰 예외 테스트
hynxp Jan 14, 2025
f946bec
refactor: 미사용 코드 제거
hynxp Jan 14, 2025
553e7f0
refactor: 액세스 토큰, 리프레쉬 토큰의 만료일시를 계산해서 반환
hynxp Jan 15, 2025
c4cdec1
test: 토큰을 만들었을 때 각 토큰의 만료일시가 제대로 계산됐는지 검증
hynxp Jan 15, 2025
74f68b7
refactor: 토큰 만료시간 설정파일로 분리
hynxp Jan 15, 2025
f9033ae
refactor: 토큰 만료시간 주석 제거...
hynxp Jan 15, 2025
fa7b4ea
refactor: 토큰 payload 내 subject 추출 메서드명 변경
hynxp Jan 15, 2025
db713ab
refactor: 잘못된 토큰 검증 시 예외 로깅 추가
hynxp Jan 15, 2025
a895aec
refactor: 멤버 저장, 소셜 정보로 조회 로직 분리
hynxp Jan 15, 2025
23b806f
refactor: 재발급 리프레쉬 토큰 저장 로직 이동 MemberService->AuthService
hynxp Jan 15, 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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind'

runtimeOnly 'com.h2database:h2'

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,63 @@
package com.hyun.udong.auth.application.service;

import com.hyun.udong.auth.infrastructure.client.KakaoOAuthClient;
import com.hyun.udong.auth.presentation.dto.AccessTokenResponse;
import com.hyun.udong.auth.presentation.dto.AuthTokens;
import com.hyun.udong.auth.presentation.dto.KakaoProfileResponse;
import com.hyun.udong.auth.presentation.dto.KakaoTokenResponse;
import com.hyun.udong.auth.presentation.dto.LoginResponse;
import com.hyun.udong.auth.util.JwtTokenProvider;
import com.hyun.udong.member.application.service.MemberService;
import com.hyun.udong.member.domain.Member;
import com.hyun.udong.member.domain.SocialType;
import com.hyun.udong.member.exception.MemberNotFoundException;
import com.hyun.udong.member.infrastructure.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {

private final KakaoOAuthClient kakaoOAuthClient;
private final MemberService memberService;
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;

public String getOAuthUrl() {
return kakaoOAuthClient.getOAuthUrl();
}

public AccessTokenResponse kakaoLogin(String code) {
@Transactional
public LoginResponse kakaoLogin(String code) {
KakaoTokenResponse kakaoTokenResponse = kakaoOAuthClient.getToken(code);
KakaoProfileResponse profile = kakaoOAuthClient.getUserProfile(kakaoTokenResponse.getAccessToken());
Member member = profile.toMember();
member.updateRefreshToken(kakaoTokenResponse.getRefreshToken());
memberService.save(member);
return new AccessTokenResponse(kakaoTokenResponse.getIdToken(), kakaoTokenResponse.getExpiresIn(), kakaoTokenResponse.getRefreshToken());

Member member = memberService.findBySocialIdAndSocialType(profile.getId(), SocialType.KAKAO)
.orElseGet(() -> memberService.save(profile.toMember()));

String accessToken = jwtTokenProvider.generateAccessToken(member.getId());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getId());
updateRefreshToken(member.getId(), refreshToken);

AuthTokens authTokens = new AuthTokens(accessToken, jwtTokenProvider.getTokenExpireTime(accessToken), refreshToken, jwtTokenProvider.getTokenExpireTime(refreshToken));
return new LoginResponse(member.getId(), member.getNickname(), authTokens);
}

@Transactional
public LoginResponse refreshTokens(String refreshToken) {
Long memberId = Long.parseLong(jwtTokenProvider.getSubjectFromToken(refreshToken));
String newAccessToken = jwtTokenProvider.generateAccessToken(memberId);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(memberId);

Member member = updateRefreshToken(memberId, newRefreshToken);

AuthTokens authTokens = new AuthTokens(newAccessToken, jwtTokenProvider.getTokenExpireTime(newAccessToken), newRefreshToken, jwtTokenProvider.getTokenExpireTime(newRefreshToken));
return new LoginResponse(member.getId(), member.getNickname(), authTokens);

}

public AccessTokenResponse refreshTokens(String refreshToken) {
KakaoTokenResponse kakaoTokenResponse = kakaoOAuthClient.refreshTokens(refreshToken);
if (kakaoTokenResponse.getRefreshToken() != null) {
Member member = memberService.findByRefreshToken(refreshToken);
member.updateRefreshToken(kakaoTokenResponse.getRefreshToken());
}
return new AccessTokenResponse(kakaoTokenResponse.getIdToken(), kakaoTokenResponse.getExpiresIn(), refreshToken);
private Member updateRefreshToken(Long memberId, String refreshToken) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> MemberNotFoundException.EXCEPTION);
member.updateRefreshToken(refreshToken);
return memberRepository.save(member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.hyun.udong.auth.exception;

import com.hyun.udong.common.exception.ErrorCode;
import com.hyun.udong.common.exception.UdongException;

public class ExpiredTokenException extends UdongException {
public static final ExpiredTokenException EXCEPTION = new ExpiredTokenException();

private ExpiredTokenException() {
super(ErrorCode.TOKEN_EXPIRED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.hyun.udong.auth.exception;

import com.hyun.udong.common.exception.ErrorCode;
import com.hyun.udong.common.exception.UdongException;

public class InvalidTokenException extends UdongException {
public static final InvalidTokenException EXCEPTION = new InvalidTokenException();

private InvalidTokenException() {
super(ErrorCode.INVALID_TOKEN);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
@Component
public class KakaoOAuthClient {

private static final String AUTHORIZE_URL = "https://kauth.kakao.com/oauth/authorize?client_id=";
private static final String ACCESS_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
private static final String USER_PROFILE_URL = "https://kapi.kakao.com/v2/user/me";

Expand All @@ -22,10 +21,6 @@ public class KakaoOAuthClient {
@Value("${social.kakao.redirect-uri}")
private String redirectUri;

public String getOAuthUrl() {
return AUTHORIZE_URL + clientId + "&redirect_uri=" + redirectUri + "&response_type=code";
}

public KakaoProfileResponse getUserProfile(String accessToken) {
RestTemplate restTemplate = new RestTemplate();

Expand Down Expand Up @@ -61,24 +56,4 @@ public KakaoTokenResponse getToken(String code) {

return response.getBody();
}

public KakaoTokenResponse refreshTokens(String refreshToken) {
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "refresh_token");
params.add("client_id", clientId);
params.add("refresh_token", refreshToken);

HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
ResponseEntity<KakaoTokenResponse> response = restTemplate.exchange(
ACCESS_TOKEN_URL, HttpMethod.POST, request, KakaoTokenResponse.class
);

return response.getBody();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.hyun.udong.auth.presentation.controller;

import com.hyun.udong.auth.application.service.AuthService;
import com.hyun.udong.auth.presentation.dto.AccessTokenResponse;
import com.hyun.udong.auth.presentation.dto.LoginResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -16,21 +16,15 @@ public class AuthController {

private final AuthService authService;

@GetMapping("/oauth/kakao/link")
public ResponseEntity<String> getOAuthUrl() {
String oAuthUrl = authService.getOAuthUrl();
return ResponseEntity.ok().body(oAuthUrl);
}

@GetMapping("/oauth/kakao")
public ResponseEntity<AccessTokenResponse> kakaoLogin(@RequestParam("code") final String code) {
AccessTokenResponse accessToken = authService.kakaoLogin(code);
return ResponseEntity.ok().body(accessToken);
public ResponseEntity<LoginResponse> kakaoLogin(@RequestParam("code") final String code) {
LoginResponse loginResponse = authService.kakaoLogin(code);
return ResponseEntity.ok().body(loginResponse);
}

@GetMapping("/oauth/kakao/refresh")
public ResponseEntity<AccessTokenResponse> refreshIdToken(@RequestParam("refreshToken") final String refreshToken) {
AccessTokenResponse accessToken = authService.refreshTokens(refreshToken);
return ResponseEntity.ok().body(accessToken);
@GetMapping("/token/refresh")
public ResponseEntity<LoginResponse> refreshIdToken(@RequestParam("refreshToken") final String refreshToken) {
LoginResponse loginResponse = authService.refreshTokens(refreshToken);
return ResponseEntity.ok().body(loginResponse);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.hyun.udong.auth.presentation.dto;

import java.time.LocalDateTime;

public record AuthTokens(String accessToken, LocalDateTime accessTokenExpDate, String refreshToken,
LocalDateTime refreshTokenExpDate) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ public class KakaoProfileResponse {

private KakaoAccount kakaoAccount;

public KakaoProfileResponse(Long id, String nickname, String profileImage) {
this.id = id;
this.kakaoAccount = new KakaoAccount();
this.kakaoAccount.profile = new KakaoAccount.Profile();
this.kakaoAccount.profile.nickname = nickname;
this.kakaoAccount.profile.profileImageUrl = profileImage;
}

@Getter
public static class KakaoAccount {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ public class KakaoTokenResponse {
private Integer refreshTokenExpiresIn;

private String scope;

public KakaoTokenResponse(String accessToken) {
this.accessToken = accessToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.hyun.udong.auth.presentation.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class LoginResponse {
private Long id;
private String nickname;
private AuthTokens token;

public LoginResponse(Long id, String nickname, AuthTokens token) {
this.id = id;
this.nickname = nickname;
this.token = token;
}
}
77 changes: 77 additions & 0 deletions src/main/java/com/hyun/udong/auth/util/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.hyun.udong.auth.util;

import com.hyun.udong.auth.exception.ExpiredTokenException;
import com.hyun.udong.auth.exception.InvalidTokenException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;

import static io.jsonwebtoken.Jwts.builder;
import static io.jsonwebtoken.Jwts.parserBuilder;

@Component
@Slf4j
public class JwtTokenProvider {

@Value("${ACCESS_TOKEN_EXPIRE_TIME}")
private long accessTokenExpireTime;

@Value("${REFRESH_TOKEN_EXPIRE_TIME}")
private long refreshTokenExpireTime;

@Value("${jwt.secret}")
private String secret;

private Key getSecretKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}

public String generateAccessToken(Long id) {
return builder()
.setSubject(id.toString())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpireTime))
.signWith(getSecretKey())
.compact();
}

public String generateRefreshToken(Long id) {
return builder()
.setSubject(id.toString())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpireTime))
.signWith(getSecretKey())
.compact();
}

public LocalDateTime getTokenExpireTime(String token) {
Date expiration = parseToken(token).getBody().getExpiration();
return LocalDateTime.ofInstant(expiration.toInstant(), ZoneId.systemDefault());
}

private Jws<Claims> parseToken(String token) {
try {
return parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw ExpiredTokenException.EXCEPTION;
} catch (Exception e) {
log.error("검증 실패 토큰: {}", token, e);
throw InvalidTokenException.EXCEPTION;
}
}

public String getSubjectFromToken(String token) {
return parseToken(token).getBody().getSubject();
}
}

5 changes: 4 additions & 1 deletion src/main/java/com/hyun/udong/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
import org.springframework.http.HttpStatus;

import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;

@Getter
public enum ErrorCode {
MEMBER_NOT_FOUND(BAD_REQUEST, "해당하는 회원이 없습니다.");
MEMBER_NOT_FOUND(BAD_REQUEST, "해당하는 회원이 없습니다."),
INVALID_TOKEN(UNAUTHORIZED, "유효하지 않은 토큰입니다."),
TOKEN_EXPIRED(UNAUTHORIZED, "만료된 토큰입니다.");

private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.hyun.udong.member.application.service;

import com.hyun.udong.member.domain.Member;
import com.hyun.udong.member.domain.SocialType;
import com.hyun.udong.member.exception.MemberNotFoundException;
import com.hyun.udong.member.infrastructure.repository.MemberRepository;
import com.hyun.udong.member.presentation.dto.MemberResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -18,25 +18,17 @@ public class MemberService {
private final MemberRepository memberRepository;

@Transactional
public MemberResponse save(Member member) {
Optional<Member> foundMember = memberRepository.findBySocialIdAndSocialType(member.getSocialId(), member.getSocialType());

if (foundMember.isPresent()) {
return MemberResponse.from(foundMember.get());
}

return MemberResponse.from(memberRepository.save(member));
public Member save(Member member) {

Choose a reason for hiding this comment

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

save인데 없으면 기존 회원을 반환하지말고 에러를 던지면 되지 않을까요?
있으면 update 없으면 create 이 과정을 같이 하고 싶으면 upsert 표현을 많이 씁니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

AuthService.kakaoLogin()에서 기존회원일 시 member의 id를 받아와야 해서요..! (토큰 발급 시 필요함)

Member member = memberService.findBySocialIdAndSocialType(profile.getId(), SocialType.KAKAO)
                .orElseGet(() -> memberService.save(profile.toMember()));

로 바꿔봤는데 괜찮을까요?

return memberRepository.save(member);
}

public MemberResponse getMemberById(Long id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> MemberNotFoundException.EXCEPTION);

return MemberResponse.from(member);
public Optional<Member> findBySocialIdAndSocialType(Long socialId, SocialType socialType) {
return memberRepository.findBySocialIdAndSocialType(socialId, socialType);
}

public Member findByRefreshToken(String refreshToken) {
return memberRepository.findByRefreshToken(refreshToken)
public Member findById(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> MemberNotFoundException.EXCEPTION);
}

}
Loading
Loading