Skip to content

Commit fd0e37c

Browse files
authored
feat: refresh token 재사용 방지 기능 구현 (#705)
* feat: refresh token에 device ID(JWT ID) claim 추가 * deviceID 생성 * deviceID 추출 * AuthService 랜덤 deviceID 생성 후 refresh_token 생성 * feat: 로그인 성공 시 인증 정보 저장 기능 구현 * feat: 테스트 시간 수정 기능 구현 * feat: refresh token 재전송 발급 방지 기능 구현 * fix: ServiceTest 설정 통일 * fix: cleaner에 auth 테이블 추가하여 테스트 격리성 보장 * fix: refresh 테스트 시 정당한 쿠키가 있도록 수정 * test: legacy refresh token 테스트 추가 * refactor: auth token 관리 역할 분리 * fix: 예측 불가능한 테스트 제거 * style: 문장을 자연스럽게 퇴고 * refactor: 불필요한 객체 제거 * refactor: 오류 문구가 명확하게 전달되도록 변경 * style: 주석 제거 * refactor: AuthToken 네이밍 명확하게 변경 * refactor: refresh 책임을 AuthTokenManager 로 이동 * refactor: 반복되는 토큰 생성 로직 리팩터링 * refactor: MemberEntity 반환 책임을 AuthTokenManager로 이동 * refactor: 불필요한 private 메서드 제거 * refactor: CookieProvider의 refreshTokenRepository 제거 * refactor: 사용하지 않은 public 메서드 접근 제어자 변경 * refactor: RefreshTokenEntity와 MemberEntity 연관관계 맵핑 * refactor: refreshTokenEntities 네이밍으로 변경 * fix: import 추가 * refactor: 사용하지 않은 객체 제거 * refactor: memberId가 아닌 MemberEntity로 조회하도록 변경 * refactor: 단방향 매핑 연쇄 삭제로 변경 * refactor: refresh token을 구분하는 변수 네이밍 명확화 * docs: 이전 테스트 내용 수정
1 parent f2fe520 commit fd0e37c

17 files changed

+420
-54
lines changed

backend/src/main/java/com/zzang/chongdae/auth/config/AuthWebMvcConfig.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.zzang.chongdae.auth.config;
22

33
import com.zzang.chongdae.auth.controller.CookieConsumer;
4-
import com.zzang.chongdae.auth.service.AuthService;
4+
import com.zzang.chongdae.auth.service.AuthTokenManager;
55
import com.zzang.chongdae.auth.service.JwtTokenProvider;
66
import java.util.List;
77
import lombok.RequiredArgsConstructor;
@@ -14,13 +14,13 @@
1414
@Configuration
1515
public class AuthWebMvcConfig implements WebMvcConfigurer {
1616

17-
private final AuthService authService;
17+
private final AuthTokenManager authTokenManager;
1818
private final CookieConsumer cookieConsumer;
1919
private final JwtTokenProvider jwtTokenProvider;
2020

2121
@Override
2222
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
23-
resolvers.add(new MemberArgumentResolver(authService, cookieConsumer));
23+
resolvers.add(new MemberArgumentResolver(authTokenManager, cookieConsumer));
2424
}
2525

2626
@Override

backend/src/main/java/com/zzang/chongdae/auth/config/MemberArgumentResolver.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.zzang.chongdae.auth.config;
22

33
import com.zzang.chongdae.auth.controller.CookieConsumer;
4-
import com.zzang.chongdae.auth.service.AuthService;
4+
import com.zzang.chongdae.auth.service.AuthTokenManager;
55
import com.zzang.chongdae.member.repository.entity.MemberEntity;
66
import jakarta.servlet.http.HttpServletRequest;
77
import lombok.AllArgsConstructor;
@@ -14,7 +14,7 @@
1414
@AllArgsConstructor
1515
public class MemberArgumentResolver implements HandlerMethodArgumentResolver {
1616

17-
private final AuthService authService;
17+
private final AuthTokenManager authTokenManager;
1818
private final CookieConsumer cookieConsumer;
1919

2020
@Override
@@ -29,6 +29,6 @@ public MemberEntity resolveArgument(MethodParameter parameter,
2929
WebDataBinderFactory binderFactory) {
3030
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
3131
String token = cookieConsumer.getAccessToken(request.getCookies());
32-
return authService.findMemberByAccessToken(token);
32+
return authTokenManager.findMemberByAccessToken(token);
3333
}
3434
}

backend/src/main/java/com/zzang/chongdae/auth/exception/AuthErrorCode.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public enum AuthErrorCode implements ErrorResponse {
1818
DUPLICATED_MEMBER(HttpStatus.CONFLICT, "이미 가입한 회원입니다."),
1919
CLIENT_TIME_OUT(HttpStatus.INTERNAL_SERVER_ERROR, "시간이 초과되어 로그인 요청에 실패했습니다."),
2020
KAKAO_LOGIN_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 로그인에 실패했습니다."),
21-
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다.");
21+
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
22+
REFRESH_REUSE_EXCEPTION(HttpStatus.UNAUTHORIZED, "이미 사용한 토큰입니다. 다시 로그인 해주세요.");
2223

2324
private final HttpStatus status;
2425
private final String message;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.zzang.chongdae.auth.repository;
2+
3+
import com.zzang.chongdae.auth.repository.entity.RefreshTokenEntity;
4+
import com.zzang.chongdae.member.repository.entity.MemberEntity;
5+
import java.util.Optional;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
8+
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
9+
10+
Optional<RefreshTokenEntity> findByMemberAndSessionId(MemberEntity member, String sessionId);
11+
12+
boolean existsByMemberAndSessionId(MemberEntity member, String sessionId);
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.zzang.chongdae.auth.repository.entity;
2+
3+
import com.zzang.chongdae.global.repository.entity.BaseTimeEntity;
4+
import com.zzang.chongdae.member.repository.entity.MemberEntity;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.FetchType;
7+
import jakarta.persistence.GeneratedValue;
8+
import jakarta.persistence.GenerationType;
9+
import jakarta.persistence.Id;
10+
import jakarta.persistence.ManyToOne;
11+
import jakarta.persistence.Table;
12+
import jakarta.validation.constraints.NotNull;
13+
import lombok.AccessLevel;
14+
import lombok.AllArgsConstructor;
15+
import lombok.EqualsAndHashCode;
16+
import lombok.Getter;
17+
import lombok.NoArgsConstructor;
18+
import org.hibernate.annotations.OnDelete;
19+
import org.hibernate.annotations.OnDeleteAction;
20+
21+
@Getter
22+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
23+
@AllArgsConstructor
24+
@EqualsAndHashCode(of = "id", callSuper = false)
25+
@Table(name = "auth")
26+
@Entity
27+
public class RefreshTokenEntity extends BaseTimeEntity {
28+
29+
@Id
30+
@GeneratedValue(strategy = GenerationType.IDENTITY)
31+
private Long id;
32+
33+
@ManyToOne(fetch = FetchType.LAZY)
34+
@OnDelete(action = OnDeleteAction.CASCADE)
35+
private MemberEntity member;
36+
37+
private String sessionId;
38+
39+
@NotNull
40+
private String refreshToken;
41+
42+
public RefreshTokenEntity(MemberEntity member, String sessionId, String refreshToken) {
43+
this(null, member, sessionId, refreshToken);
44+
}
45+
46+
public boolean isValid(String refreshToken) {
47+
return this.refreshToken.equals(refreshToken);
48+
}
49+
50+
public void refresh(String refreshToken) {
51+
this.refreshToken = refreshToken;
52+
}
53+
}

backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java

+12-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.zzang.chongdae.auth.service;
22

3+
import com.zzang.chongdae.auth.exception.AuthErrorCode;
34
import com.zzang.chongdae.auth.service.dto.AuthInfoDto;
45
import com.zzang.chongdae.auth.service.dto.AuthMemberDto;
56
import com.zzang.chongdae.auth.service.dto.AuthTokenDto;
@@ -8,7 +9,6 @@
89
import com.zzang.chongdae.global.config.WriterDatabase;
910
import com.zzang.chongdae.global.exception.MarketException;
1011
import com.zzang.chongdae.member.domain.AuthProvider;
11-
import com.zzang.chongdae.member.exception.MemberErrorCode;
1212
import com.zzang.chongdae.member.repository.MemberRepository;
1313
import com.zzang.chongdae.member.repository.entity.MemberEntity;
1414
import com.zzang.chongdae.member.service.NicknameGenerator;
@@ -27,9 +27,9 @@ public class AuthService {
2727
private final ApplicationEventPublisher eventPublisher;
2828
private final MemberRepository memberRepository;
2929
private final PasswordEncoder passwordEncoder;
30-
private final JwtTokenProvider jwtTokenProvider;
3130
private final NicknameGenerator nickNameGenerator;
3231
private final AuthClient authClient;
32+
private final AuthTokenManager authTokenManager;
3333

3434
@WriterDatabase
3535
@Transactional
@@ -56,12 +56,13 @@ private MemberEntity createMember(AuthProvider provider, String loginId, String
5656

5757
private AuthInfoDto login(MemberEntity member, String fcmToken) {
5858
AuthMemberDto authMember = new AuthMemberDto(member);
59-
AuthTokenDto authToken = jwtTokenProvider.createAuthToken(member.getId().toString());
59+
AuthTokenDto authToken = authTokenManager.createToken(member);
6060
checkFcmToken(member, fcmToken);
6161
eventPublisher.publishEvent(new LoginEvent(this, member));
6262
return new AuthInfoDto(authMember, authToken);
6363
}
6464

65+
6566
private void checkFcmToken(MemberEntity member, String fcmToken) {
6667
if (fcmToken == null || fcmToken.isEmpty()) {
6768
member.updateFcmTokenDefault();
@@ -73,14 +74,16 @@ private void checkFcmToken(MemberEntity member, String fcmToken) {
7374
}
7475
}
7576

77+
@WriterDatabase
78+
@Transactional
7679
public AuthTokenDto refresh(String refreshToken) {
77-
Long memberId = jwtTokenProvider.getMemberIdByRefreshToken(refreshToken);
78-
return jwtTokenProvider.createAuthToken(memberId.toString());
80+
validateRefreshToken(refreshToken);
81+
return authTokenManager.refresh(refreshToken);
7982
}
8083

81-
public MemberEntity findMemberByAccessToken(String token) {
82-
Long memberId = jwtTokenProvider.getMemberIdByAccessToken(token);
83-
return memberRepository.findById(memberId)
84-
.orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND));
84+
private void validateRefreshToken(String refreshToken) {
85+
if (!authTokenManager.isValid(refreshToken)) {
86+
throw new MarketException(AuthErrorCode.REFRESH_REUSE_EXCEPTION);
87+
}
8588
}
8689
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.zzang.chongdae.auth.service;
2+
3+
import com.zzang.chongdae.auth.exception.AuthErrorCode;
4+
import com.zzang.chongdae.auth.repository.RefreshTokenRepository;
5+
import com.zzang.chongdae.auth.repository.entity.RefreshTokenEntity;
6+
import com.zzang.chongdae.auth.service.dto.AuthTokenDto;
7+
import com.zzang.chongdae.global.config.WriterDatabase;
8+
import com.zzang.chongdae.global.exception.MarketException;
9+
import com.zzang.chongdae.member.exception.MemberErrorCode;
10+
import com.zzang.chongdae.member.repository.MemberRepository;
11+
import com.zzang.chongdae.member.repository.entity.MemberEntity;
12+
import java.util.UUID;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.transaction.annotation.Propagation;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
@RequiredArgsConstructor
19+
@Component
20+
public class AuthTokenManager {
21+
22+
private final RefreshTokenRepository refreshTokenRepository;
23+
private final MemberRepository memberRepository;
24+
private final JwtTokenProvider jwtTokenProvider;
25+
26+
@WriterDatabase
27+
@Transactional
28+
public AuthTokenDto createToken(MemberEntity member) {
29+
String sessionId = UUID.randomUUID().toString();
30+
AuthTokenDto authToken = jwtTokenProvider.createAuthToken(member.getId().toString(), sessionId);
31+
RefreshTokenEntity auth = new RefreshTokenEntity(member, sessionId, authToken.refreshToken());
32+
refreshTokenRepository.save(auth);
33+
return authToken;
34+
}
35+
36+
@WriterDatabase
37+
@Transactional(propagation = Propagation.REQUIRES_NEW)
38+
public boolean isValid(String refreshToken) {
39+
MemberEntity member = findMemberByRefreshToken(refreshToken);
40+
String sessionId = jwtTokenProvider.getSessionIdByRefreshToken(refreshToken);
41+
if (isValidLegacy(member, refreshToken)) {
42+
return true;
43+
}
44+
RefreshTokenEntity refreshTokenEntity = findRefreshTokenFromRepository(member, sessionId);
45+
if (!refreshTokenEntity.isValid(refreshToken)) {
46+
refreshTokenRepository.delete(refreshTokenEntity);
47+
return false;
48+
}
49+
return true;
50+
}
51+
52+
private MemberEntity findMemberByRefreshToken(String token) {
53+
Long memberId = jwtTokenProvider.getMemberIdByRefreshToken(token);
54+
return memberRepository.findById(memberId)
55+
.orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND));
56+
}
57+
58+
private boolean isValidLegacy(MemberEntity member, String refreshToken) {
59+
String sessionId = jwtTokenProvider.getSessionIdByRefreshToken(refreshToken);
60+
return sessionId == null && !refreshTokenRepository.existsByMemberAndSessionId(member, sessionId);
61+
}
62+
63+
private RefreshTokenEntity findRefreshTokenFromRepository(MemberEntity member, String sessionId) {
64+
return refreshTokenRepository.findByMemberAndSessionId(member, sessionId)
65+
.orElseThrow(() -> new MarketException(AuthErrorCode.EXPIRED_REFRESH_TOKEN));
66+
}
67+
68+
@WriterDatabase
69+
@Transactional
70+
public AuthTokenDto refresh(String refreshToken) {
71+
MemberEntity member = findMemberByRefreshToken(refreshToken);
72+
String sessionId = jwtTokenProvider.getSessionIdByRefreshToken(refreshToken);
73+
if (sessionId == null) {
74+
return refreshLegacyToken(member, sessionId, refreshToken);
75+
}
76+
RefreshTokenEntity refreshTokenEntity = findRefreshTokenFromRepository(member, sessionId);
77+
AuthTokenDto authTokenDto = jwtTokenProvider.createAuthToken(member.getId().toString(), sessionId);
78+
refreshTokenEntity.refresh(authTokenDto.refreshToken());
79+
return authTokenDto;
80+
}
81+
82+
private AuthTokenDto refreshLegacyToken(MemberEntity member, String sessionId, String refreshToken) {
83+
if (refreshTokenRepository.existsByMemberAndSessionId(member, sessionId)) {
84+
throw new MarketException(AuthErrorCode.REFRESH_REUSE_EXCEPTION);
85+
}
86+
RefreshTokenEntity legacyAuth = new RefreshTokenEntity(member, sessionId, refreshToken);
87+
refreshTokenRepository.save(legacyAuth);
88+
return createToken(member);
89+
}
90+
91+
public MemberEntity findMemberByAccessToken(String token) {
92+
Long memberId = jwtTokenProvider.getMemberIdByAccessToken(token);
93+
return memberRepository.findById(memberId)
94+
.orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND));
95+
}
96+
}

backend/src/main/java/com/zzang/chongdae/auth/service/JwtTokenProvider.java

+17-4
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,28 @@ public JwtTokenProvider(@Value("${security.jwt.token.access-secret-key}") String
3636
this.clock = clock;
3737
}
3838

39-
public AuthTokenDto createAuthToken(String payload) {
40-
return new AuthTokenDto(createToken(payload, accessSecretKey, accessTokenExpired),
41-
createToken(payload, refreshSecretKey, refreshTokenExpired));
39+
public AuthTokenDto createAuthToken(String payload, String sessionId) {
40+
return new AuthTokenDto(createAccessToken(payload, accessSecretKey, accessTokenExpired),
41+
createRefreshToken(payload, refreshSecretKey, refreshTokenExpired, sessionId));
4242
}
4343

44-
private String createToken(String payload, String secretKey, Duration expired) {
44+
private String createAccessToken(String payload, String secretKey, Duration expired) {
4545
return Jwts.builder()
4646
.setSubject(payload)
4747
.setExpiration(calculateExpiredAt(expired))
4848
.signWith(SignatureAlgorithm.HS256, secretKey)
4949
.compact();
5050
}
5151

52+
private String createRefreshToken(String payload, String secretKey, Duration expired, String sessionId) {
53+
return Jwts.builder()
54+
.setSubject(payload)
55+
.setId(sessionId)
56+
.setExpiration(calculateExpiredAt(expired))
57+
.signWith(SignatureAlgorithm.HS256, secretKey)
58+
.compact();
59+
}
60+
5261
private Date calculateExpiredAt(Duration expired) {
5362
Date now = Date.from(clock.instant());
5463
return new Date(now.getTime() + expired.toMillis());
@@ -86,6 +95,10 @@ public Long getMemberIdByRefreshToken(String token) {
8695
return Long.valueOf(memberId);
8796
}
8897

98+
public String getSessionIdByRefreshToken(String token) {
99+
return getClaimsRefreshToken(token, refreshSecretKey).getId();
100+
}
101+
89102
private Claims getClaimsRefreshToken(String token, String refreshSecretKey) {
90103
try {
91104
return getClaims(token, refreshSecretKey);

backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java

+14-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.jsonwebtoken.Jwts;
1919
import io.jsonwebtoken.SignatureAlgorithm;
2020
import io.restassured.http.ContentType;
21+
import io.restassured.http.Cookies;
2122
import java.time.Duration;
2223
import java.util.Base64;
2324
import java.util.Date;
@@ -129,6 +130,8 @@ class ManageToken {
129130
@BeforeEach
130131
void setUp() {
131132
member = memberFixture.createMember("dora");
133+
BDDMockito.given(authClient.getUserInfo(any()))
134+
.willReturn(member.getLoginId());
132135
now = Date.from(clock.instant());
133136
}
134137

@@ -164,10 +167,20 @@ void should_throwException_when_givenInvalidAccessToken() {
164167
@DisplayName("refreshToken으로 accessToken과 refreshToken을 재발급 한다.")
165168
@Test
166169
void should_refreshSuccess_when_givenRefreshToken() {
170+
KakaoLoginRequest request = new KakaoLoginRequest(
171+
"whateverAccessToken",
172+
"whateverFcmToken"
173+
);
174+
175+
Cookies cookies = given().log().all()
176+
.contentType(ContentType.JSON)
177+
.body(request)
178+
.when().post("/auth/login/kakao")
179+
.getDetailedCookies();
167180

168181
given(spec).log().all()
169182
.filter(document("refresh-success", resource(successSnippets)))
170-
.cookies(cookieProvider.createCookiesWithMember(member))
183+
.cookies(cookies)
171184
.when().post("/auth/refresh")
172185
.then().log().all()
173186
.statusCode(200);

0 commit comments

Comments
 (0)