Skip to content

Commit 7013fd7

Browse files
authored
Merge pull request #78 from Geumpumta/feat/block-multi-device-login
feat: 중복 로그인 차단 정책 추가
2 parents 8a2f1fb + 0cc29d8 commit 7013fd7

File tree

4 files changed

+169
-10
lines changed

4 files changed

+169
-10
lines changed

src/main/java/com/gpt/geumpumtabackend/global/oauth/handler/OAuth2AuthenticationSuccessHandler.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package com.gpt.geumpumtabackend.global.oauth.handler;
22

3-
4-
5-
import com.gpt.geumpumtabackend.global.jwt.JwtHandler;
63
import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim;
4+
import com.gpt.geumpumtabackend.global.oauth.service.OAuthLoginPolicyService;
75
import com.gpt.geumpumtabackend.global.oauth.service.OAuth2UserPrincipal;
86
import com.gpt.geumpumtabackend.global.oauth.util.RedirectUrlValidator;
97
import com.gpt.geumpumtabackend.global.oauth.util.StateUtil;
@@ -20,13 +18,14 @@
2018
import org.springframework.web.util.UriComponentsBuilder;
2119

2220
import java.io.IOException;
21+
import java.util.Optional;
2322

2423
@Component
2524
@RequiredArgsConstructor
2625
@Slf4j
2726
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
2827

29-
private final JwtHandler jwtHandler;
28+
private final OAuthLoginPolicyService oAuthLoginPolicyService;
3029

3130
@Override
3231
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -45,15 +44,22 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
4544
Boolean isWithdrawn = principal.getUser().getDeletedAt() != null;
4645

4746
JwtUserClaim jwtUserClaim = new JwtUserClaim(userId, role, isWithdrawn);
48-
Token token = jwtHandler.createTokens(jwtUserClaim);
47+
Optional<Token> token = oAuthLoginPolicyService.issueTokenIfNoActiveSession(jwtUserClaim);
48+
if (token.isEmpty()) {
49+
String blockedUrl = UriComponentsBuilder.fromUriString(redirectUri)
50+
.queryParam("error", "already_logged_in")
51+
.build().toUriString();
52+
response.sendRedirect(blockedUrl);
53+
return;
54+
}
55+
Token issuedToken = token.get();
4956

5057
// 토큰 붙여서 리다이렉트
5158
String redirectUrl = UriComponentsBuilder.fromUriString(redirectUri)
52-
.queryParam("accessToken", token.getAccessToken())
53-
.queryParam("refreshToken", token.getRefreshToken())
59+
.queryParam("accessToken", issuedToken.getAccessToken())
60+
.queryParam("refreshToken", issuedToken.getRefreshToken())
5461
.build().toUriString();
5562

56-
System.out.println(redirectUrl);
5763
response.sendRedirect(redirectUrl);
5864
}
5965
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.gpt.geumpumtabackend.global.oauth.service;
2+
3+
import com.gpt.geumpumtabackend.global.jwt.JwtHandler;
4+
import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim;
5+
import com.gpt.geumpumtabackend.global.exception.BusinessException;
6+
import com.gpt.geumpumtabackend.global.exception.ExceptionType;
7+
import com.gpt.geumpumtabackend.token.domain.RefreshToken;
8+
import com.gpt.geumpumtabackend.token.domain.Token;
9+
import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository;
10+
import com.gpt.geumpumtabackend.user.repository.UserRepository;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.stereotype.Service;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
import java.time.LocalDateTime;
16+
import java.time.ZoneId;
17+
import java.util.Optional;
18+
19+
@Service
20+
@RequiredArgsConstructor
21+
public class OAuthLoginPolicyService {
22+
23+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
24+
25+
private final UserRepository userRepository;
26+
private final RefreshTokenRepository refreshTokenRepository;
27+
private final JwtHandler jwtHandler;
28+
29+
@Transactional
30+
public Optional<Token> issueTokenIfNoActiveSession(JwtUserClaim jwtUserClaim) {
31+
userRepository.findByIdForUpdate(jwtUserClaim.userId())
32+
.orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND));
33+
34+
Optional<RefreshToken> existingToken = refreshTokenRepository.findByUserId(jwtUserClaim.userId());
35+
if (existingToken.isPresent()) {
36+
LocalDateTime now = LocalDateTime.now(KST);
37+
if (existingToken.get().getExpiredAt().isAfter(now)) {
38+
return Optional.empty();
39+
}
40+
refreshTokenRepository.deleteByUserId(jwtUserClaim.userId());
41+
}
42+
43+
return Optional.of(jwtHandler.createTokens(jwtUserClaim));
44+
}
45+
}

src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider;
44
import com.gpt.geumpumtabackend.user.domain.User;
5-
import jakarta.validation.constraints.Pattern;
5+
import jakarta.persistence.LockModeType;
6+
import org.springframework.data.jpa.repository.Lock;
67
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
710

8-
import java.util.List;
911
import java.util.Optional;
1012

1113
public interface UserRepository extends JpaRepository<User, Long> {
@@ -26,4 +28,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
2628
boolean existsBySchoolEmail(String schoolEmail);
2729

2830
Optional<User> findByFcmToken(String fcmToken);
31+
32+
@Lock(LockModeType.PESSIMISTIC_WRITE)
33+
@Query("SELECT u FROM User u WHERE u.id = :userId")
34+
Optional<User> findByIdForUpdate(@Param("userId") Long userId);
2935
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.gpt.geumpumtabackend.unit.oauth.service;
2+
3+
import com.gpt.geumpumtabackend.global.exception.BusinessException;
4+
import com.gpt.geumpumtabackend.global.jwt.JwtHandler;
5+
import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim;
6+
import com.gpt.geumpumtabackend.global.oauth.service.OAuthLoginPolicyService;
7+
import com.gpt.geumpumtabackend.token.domain.RefreshToken;
8+
import com.gpt.geumpumtabackend.token.domain.Token;
9+
import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository;
10+
import com.gpt.geumpumtabackend.user.domain.User;
11+
import com.gpt.geumpumtabackend.user.domain.UserRole;
12+
import com.gpt.geumpumtabackend.user.repository.UserRepository;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.api.extension.ExtendWith;
16+
import org.mockito.Mock;
17+
import org.mockito.junit.jupiter.MockitoExtension;
18+
19+
import java.util.Optional;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
23+
import static org.mockito.Mockito.*;
24+
25+
@ExtendWith(MockitoExtension.class)
26+
class OAuthLoginPolicyServiceTest {
27+
28+
@Mock
29+
private UserRepository userRepository;
30+
31+
@Mock
32+
private RefreshTokenRepository refreshTokenRepository;
33+
34+
@Mock
35+
private JwtHandler jwtHandler;
36+
37+
private OAuthLoginPolicyService oAuthLoginPolicyService;
38+
39+
@BeforeEach
40+
void setUp() {
41+
oAuthLoginPolicyService = new OAuthLoginPolicyService(userRepository, refreshTokenRepository, jwtHandler);
42+
}
43+
44+
@Test
45+
void 활성_세션이_있으면_토큰_발급을_차단한다() {
46+
Long userId = 1L;
47+
JwtUserClaim claim = new JwtUserClaim(userId, UserRole.USER, false);
48+
RefreshToken activeToken = RefreshToken.builder()
49+
.userId(userId)
50+
.refreshToken("active-token")
51+
.times(3600L)
52+
.build();
53+
54+
when(userRepository.findByIdForUpdate(userId)).thenReturn(Optional.of(mock(User.class)));
55+
when(refreshTokenRepository.findByUserId(userId)).thenReturn(Optional.of(activeToken));
56+
57+
Optional<Token> result = oAuthLoginPolicyService.issueTokenIfNoActiveSession(claim);
58+
59+
assertThat(result).isEmpty();
60+
verify(jwtHandler, never()).createTokens(any());
61+
verify(refreshTokenRepository, never()).deleteByUserId(userId);
62+
}
63+
64+
@Test
65+
void 만료된_토큰만_있으면_삭제후_새_토큰을_발급한다() {
66+
Long userId = 1L;
67+
JwtUserClaim claim = new JwtUserClaim(userId, UserRole.USER, false);
68+
RefreshToken expiredToken = RefreshToken.builder()
69+
.userId(userId)
70+
.refreshToken("expired-token")
71+
.times(-1L)
72+
.build();
73+
Token issuedToken = Token.builder()
74+
.accessToken("new-access")
75+
.refreshToken("new-refresh")
76+
.build();
77+
78+
when(userRepository.findByIdForUpdate(userId)).thenReturn(Optional.of(mock(User.class)));
79+
when(refreshTokenRepository.findByUserId(userId)).thenReturn(Optional.of(expiredToken));
80+
when(jwtHandler.createTokens(claim)).thenReturn(issuedToken);
81+
82+
Optional<Token> result = oAuthLoginPolicyService.issueTokenIfNoActiveSession(claim);
83+
84+
assertThat(result).contains(issuedToken);
85+
verify(refreshTokenRepository).deleteByUserId(userId);
86+
verify(jwtHandler).createTokens(claim);
87+
}
88+
89+
@Test
90+
void 사용자_락_대상_없으면_예외를_던진다() {
91+
Long userId = 1L;
92+
JwtUserClaim claim = new JwtUserClaim(userId, UserRole.USER, false);
93+
94+
when(userRepository.findByIdForUpdate(userId)).thenReturn(Optional.empty());
95+
96+
assertThatThrownBy(() -> oAuthLoginPolicyService.issueTokenIfNoActiveSession(claim))
97+
.isInstanceOf(BusinessException.class);
98+
99+
verify(refreshTokenRepository, never()).findByUserId(anyLong());
100+
verify(jwtHandler, never()).createTokens(any());
101+
}
102+
}

0 commit comments

Comments
 (0)