From 26cba54bece8debadba7c17fce15ff0b1a21ad80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=EB=8F=99=ED=98=84?= Date: Thu, 18 Jan 2024 14:25:20 +0900 Subject: [PATCH] Feat/#87 duplicate login (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: redis 의존성 추가 * feat: 중복 로그인 예외 처리 --- build.gradle | 3 ++ .../server/config/LoginInterceptor.java | 22 ++++++++++++++ .../yonseigolf/server/config/RedisConfig.java | 15 ++++++++++ .../yonseigolf/server/config/WebConfig.java | 1 + .../user/controller/UserController.java | 17 ++++++++++- .../controller/UserExceptionController.java | 12 ++++++++ .../exception/DuplicatedLoginException.java | 8 +++++ .../service/PreventDuplicateLoginService.java | 30 +++++++++++++++++++ src/main/resources/application.yml | 6 ++++ .../user/controller/UserControllerTest.java | 9 ++++-- 10 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/main/java/yonseigolf/server/config/RedisConfig.java create mode 100644 src/main/java/yonseigolf/server/user/exception/DuplicatedLoginException.java create mode 100644 src/main/java/yonseigolf/server/user/service/PreventDuplicateLoginService.java diff --git a/build.gradle b/build.gradle index 244eb23..fd290b3 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,9 @@ dependencies { // jwt 추가 implementation 'io.jsonwebtoken:jjwt:0.9.1' + + // redis 추가 + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } ext { diff --git a/src/main/java/yonseigolf/server/config/LoginInterceptor.java b/src/main/java/yonseigolf/server/config/LoginInterceptor.java index 02ac090..9b3548c 100644 --- a/src/main/java/yonseigolf/server/config/LoginInterceptor.java +++ b/src/main/java/yonseigolf/server/config/LoginInterceptor.java @@ -5,8 +5,11 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import yonseigolf.server.user.dto.response.LoggedInUser; +import yonseigolf.server.user.exception.DuplicatedLoginException; import yonseigolf.server.user.service.JwtService; +import yonseigolf.server.user.service.PreventDuplicateLoginService; +import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -16,6 +19,7 @@ public class LoginInterceptor implements HandlerInterceptor { private final JwtService jwtUtil; + private final PreventDuplicateLoginService preventDuplicateLoginService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { @@ -28,8 +32,26 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } String token = request.getHeader("Authorization").split(" ")[1]; LoggedInUser loggedInUser = jwtUtil.extractedUserFromToken(token, LoggedInUser.class); + + boolean duplicated = preventDuplicateLoginService.checkDuplicatedLogin(loggedInUser.getId(), token); + + if (!duplicated) { + + invalidateRefreshToken(response); + throw new DuplicatedLoginException("중복 로그인이 발생했습니다."); + } + request.setAttribute("userId", loggedInUser.getId()); return true; } + + private void invalidateRefreshToken(HttpServletResponse response) { + Cookie cookie = new Cookie("refreshToken", null); // 쿠키 이름을 Refresh Token 쿠키 이름과 동일하게 설정 + cookie.setHttpOnly(true); + cookie.setSecure(true); // 프로덕션 환경에서는 true로 설정 + cookie.setPath("/"); // Refresh Token 쿠키와 동일한 경로 설정 + cookie.setMaxAge(0); // 쿠키의 만료 시간을 0으로 설정하여 즉시 만료 + response.addCookie(cookie); + } } diff --git a/src/main/java/yonseigolf/server/config/RedisConfig.java b/src/main/java/yonseigolf/server/config/RedisConfig.java new file mode 100644 index 0000000..bdddb0d --- /dev/null +++ b/src/main/java/yonseigolf/server/config/RedisConfig.java @@ -0,0 +1,15 @@ +package yonseigolf.server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} \ No newline at end of file diff --git a/src/main/java/yonseigolf/server/config/WebConfig.java b/src/main/java/yonseigolf/server/config/WebConfig.java index 5d4bf55..fdda14a 100644 --- a/src/main/java/yonseigolf/server/config/WebConfig.java +++ b/src/main/java/yonseigolf/server/config/WebConfig.java @@ -45,6 +45,7 @@ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/boards/**") .addPathPatterns("/users/logout") + .addPathPatterns("/users/loggedIn") .addPathPatterns("/replies/**") .excludePathPatterns("/oauth/kakao"); } diff --git a/src/main/java/yonseigolf/server/user/controller/UserController.java b/src/main/java/yonseigolf/server/user/controller/UserController.java index 04d6ba6..d2fbb1c 100644 --- a/src/main/java/yonseigolf/server/user/controller/UserController.java +++ b/src/main/java/yonseigolf/server/user/controller/UserController.java @@ -18,6 +18,7 @@ import yonseigolf.server.user.exception.RefreshTokenExpiredException; import yonseigolf.server.user.service.JwtService; import yonseigolf.server.user.service.OauthLoginService; +import yonseigolf.server.user.service.PreventDuplicateLoginService; import yonseigolf.server.user.service.UserService; import yonseigolf.server.util.CustomResponse; @@ -34,14 +35,16 @@ public class UserController { private final OauthLoginService oauthLoginService; private final KakaoOauthInfo kakaoOauthInfo; private final JwtService jwtUtil; + private final PreventDuplicateLoginService preventDuplicateLoginService; @Autowired - public UserController(UserService userService, OauthLoginService oauthLoginService, KakaoOauthInfo kakaoOauthInfo, JwtService jwtUtil) { + public UserController(UserService userService, OauthLoginService oauthLoginService, KakaoOauthInfo kakaoOauthInfo, JwtService jwtUtil, PreventDuplicateLoginService preventDuplicateLoginService) { this.userService = userService; this.oauthLoginService = oauthLoginService; this.kakaoOauthInfo = kakaoOauthInfo; this.jwtUtil = jwtUtil; + this.preventDuplicateLoginService = preventDuplicateLoginService; } @PostMapping("/oauth/kakao") @@ -76,6 +79,9 @@ public ResponseEntity> signIn(@RequestAttribute // signIn 할 경우 로그인 진행 makeRefreshToken(response, loggedInUser); + // redis에 중복 로그인 방지를 위한 access token 저장 + preventDuplicateLoginService.registerLogin(loggedInUser.getId(), tokenReponse); + return ResponseEntity .ok() .body(CustomResponse.successResponse("로그인 성공", @@ -102,6 +108,14 @@ private void createRefreshToken(HttpServletResponse response, String refreshToke response.addCookie(cookie); // 응답에 쿠키 추가 } + @PostMapping("/users/loggedIn") + public ResponseEntity> loggedIn() { + + return ResponseEntity + .ok() + .body(CustomResponse.successResponse("로그인 상태입니다.")); + } + @PostMapping("/users/signIn/refresh") public ResponseEntity> refreshAccessToken(HttpServletRequest request) { @@ -115,6 +129,7 @@ public ResponseEntity> refreshAccessToken(HttpS // access token 재발급 String accessToken = userService.generateAccessToken(jwtTokenUser.getId(), jwtUtil, new Date(new Date().getTime() + 3600000)); + preventDuplicateLoginService.registerLogin(jwtTokenUser.getId(), accessToken); return ResponseEntity .ok() diff --git a/src/main/java/yonseigolf/server/user/controller/UserExceptionController.java b/src/main/java/yonseigolf/server/user/controller/UserExceptionController.java index 9840025..8f85a5f 100644 --- a/src/main/java/yonseigolf/server/user/controller/UserExceptionController.java +++ b/src/main/java/yonseigolf/server/user/controller/UserExceptionController.java @@ -15,6 +15,7 @@ public class UserExceptionController { /* 40101 access token 이 없거나 만료됨, refresh token을 통한 access 토큰 재발급 40102 refresh token이 없거나 만료됨, 로그인 페이지로 이동 + 409 중복 로그인 */ @ExceptionHandler(IllegalArgumentException.class) @@ -80,4 +81,15 @@ public ResponseEntity refreshTokenExpired(RefreshTokenExpir )); } + @ExceptionHandler(DuplicatedLoginException.class) + public ResponseEntity duplicatedLogin(DuplicatedLoginException ex) { + + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(new CustomErrorResponse( + "fail", + 409, + ex.getMessage() + )); + } } diff --git a/src/main/java/yonseigolf/server/user/exception/DuplicatedLoginException.java b/src/main/java/yonseigolf/server/user/exception/DuplicatedLoginException.java new file mode 100644 index 0000000..92ebc3f --- /dev/null +++ b/src/main/java/yonseigolf/server/user/exception/DuplicatedLoginException.java @@ -0,0 +1,8 @@ +package yonseigolf.server.user.exception; + +public class DuplicatedLoginException extends RuntimeException { + + public DuplicatedLoginException(String message) { + super(message); + } +} diff --git a/src/main/java/yonseigolf/server/user/service/PreventDuplicateLoginService.java b/src/main/java/yonseigolf/server/user/service/PreventDuplicateLoginService.java new file mode 100644 index 0000000..5bee675 --- /dev/null +++ b/src/main/java/yonseigolf/server/user/service/PreventDuplicateLoginService.java @@ -0,0 +1,30 @@ +package yonseigolf.server.user.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +public class PreventDuplicateLoginService { + + private final RedisTemplate redisTemplate; + + @Autowired + public PreventDuplicateLoginService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void registerLogin(long userId, String token) { + + redisTemplate.opsForValue().getAndDelete(userId); + redisTemplate.opsForValue().set(userId, token, 30, TimeUnit.MINUTES); + } + + // 같으면 true, 다르면 false + public boolean checkDuplicatedLogin(long userId, String token) { + + return redisTemplate.opsForValue().get(userId).equals(token); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 678cc7b..e1f278c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,12 @@ spring: username: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + jpa: properties: hibernate: diff --git a/src/test/java/yonseigolf/server/user/controller/UserControllerTest.java b/src/test/java/yonseigolf/server/user/controller/UserControllerTest.java index 7d85ea6..6b78709 100644 --- a/src/test/java/yonseigolf/server/user/controller/UserControllerTest.java +++ b/src/test/java/yonseigolf/server/user/controller/UserControllerTest.java @@ -13,7 +13,6 @@ import org.springframework.restdocs.payload.JsonFieldType; import yonseigolf.server.docs.utils.RestDocsSupport; import yonseigolf.server.user.dto.request.KakaoCode; -import yonseigolf.server.user.dto.request.SignUpUserRequest; import yonseigolf.server.user.dto.request.UserClassRequest; import yonseigolf.server.user.dto.response.*; import yonseigolf.server.user.dto.token.KakaoOauthInfo; @@ -22,13 +21,14 @@ import yonseigolf.server.user.entity.UserRole; import yonseigolf.server.user.service.JwtService; import yonseigolf.server.user.service.OauthLoginService; +import yonseigolf.server.user.service.PreventDuplicateLoginService; import yonseigolf.server.user.service.UserService; import javax.servlet.http.Cookie; import java.util.Date; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -54,6 +54,9 @@ class UserControllerTest extends RestDocsSupport { private KakaoOauthInfo kakaoOauthInfo; @Mock private JwtService jwtUtil; + @Mock + private PreventDuplicateLoginService preventDuplicateLoginService; + @Test @DisplayName("카카오톡 로그인을 할 수 있다.") @@ -349,6 +352,6 @@ void healthCheck() throws Exception { @Override protected Object initController() { - return new UserController(userService, oauthLoginService, kakaoOauthInfo, jwtUtil); + return new UserController(userService, oauthLoginService, kakaoOauthInfo, jwtUtil, preventDuplicateLoginService); } }