

250603 김혜민 다중로그인설정 수정
@e291fe083780a3b68d7153f7140184ff00628705
--- src/main/java/com/takensoft/cms/token/service/impl/RefreshTokenServiceImpl.java
+++ src/main/java/com/takensoft/cms/token/service/impl/RefreshTokenServiceImpl.java
... | ... | @@ -211,7 +211,8 @@ |
211 | 211 |
result += saveRefreshToken(req, res, refresh, JWT_REFRESHTIME); |
212 | 212 |
|
213 | 213 |
// 응답설정 RefreshToken |
214 |
- // res.setHeader("refresh", newRefreshToken); |
|
214 |
+ // res.setHeader("refresh", newRefreshToken); |
|
215 |
+ |
|
215 | 216 |
// 쿠키 방식 |
216 | 217 |
res.addCookie(jwtUtil.createCookie("refresh", newRefreshToken, COOKIE_TIME)); |
217 | 218 |
} |
--- src/main/java/com/takensoft/cms/token/web/RefreshTokenController.java
+++ src/main/java/com/takensoft/cms/token/web/RefreshTokenController.java
... | ... | @@ -1,6 +1,5 @@ |
1 | 1 |
package com.takensoft.cms.token.web; |
2 | 2 |
|
3 |
- |
|
4 | 3 |
import com.takensoft.cms.loginPolicy.service.LoginModeService; |
5 | 4 |
import com.takensoft.cms.loginPolicy.service.LoginPolicyService; |
6 | 5 |
import com.takensoft.cms.mber.vo.MberVO; |
... | ... | @@ -27,12 +26,14 @@ |
27 | 26 |
import jakarta.servlet.http.HttpServletRequest; |
28 | 27 |
import jakarta.servlet.http.HttpServletResponse; |
29 | 28 |
import java.nio.charset.Charset; |
29 |
+ |
|
30 | 30 |
/** |
31 | 31 |
* @author takensoft |
32 | 32 |
* @since 2024.04.01 |
33 | 33 |
* @modification |
34 | 34 |
* since | author | description |
35 | 35 |
* 2024.04.01 | takensoft | 최초 등록 |
36 |
+ * 2025.06.02 | takensoft | 세션 모드 Redis 정보 삭제 추가 |
|
36 | 37 |
* |
37 | 38 |
* RefreshToken 정보 관련 컨트롤러 |
38 | 39 |
*/ |
... | ... | @@ -47,12 +48,13 @@ |
47 | 48 |
private final LoginModeService loginModeService; |
48 | 49 |
private final SessionUtil sessionUtil; |
49 | 50 |
private final RedisTemplate<String, String> redisTemplate; |
51 |
+ |
|
50 | 52 |
/** |
51 | 53 |
* @param req - HTTP 요청 객체 |
52 | 54 |
* @param res - HTTP 응답 객체 |
53 | 55 |
* @return ResponseEntity - 로그아웃 응답 결과 |
54 | 56 |
* |
55 |
- * 로그아웃 |
|
57 |
+ * 로그아웃 - 세션/JWT 모드 통합 처리 |
|
56 | 58 |
*/ |
57 | 59 |
@PostMapping(value = "/mbr/logout.json") |
58 | 60 |
public ResponseEntity<?> logout(HttpServletRequest req, HttpServletResponse res){ |
... | ... | @@ -61,29 +63,45 @@ |
61 | 63 |
if (auth != null && auth.getPrincipal() instanceof MberVO) { |
62 | 64 |
MberVO mber = (MberVO) auth.getPrincipal(); |
63 | 65 |
String mbrId = mber.getMbrId(); |
64 |
- String loginType = loginModeService.getLoginMode(); // J or S |
|
66 |
+ String loginMode = loginModeService.getLoginMode(); // J or S |
|
65 | 67 |
|
66 | 68 |
// Refresh 토큰 삭제 (DB) |
67 | 69 |
RefreshTknVO refresh = new RefreshTknVO(); |
68 | 70 |
refresh.setMbrId(mbrId); |
69 |
- int result = refreshTokenService.delete(req, refresh); |
|
71 |
+ int result = refreshTokenService.delete(req, refresh); |
|
70 | 72 |
|
71 |
- if (loginType.equals("S")) { |
|
72 |
- // 세션 방식: 세션 만료 + SessionMap에서 제거 |
|
73 |
+ if (loginMode.equals("S")) { |
|
73 | 74 |
HttpSession session = req.getSession(false); |
74 |
- if (session != null) session.invalidate(); |
|
75 |
+ if (session != null) { |
|
76 |
+ session.invalidate(); |
|
77 |
+ } |
|
78 |
+ |
|
79 |
+ // SessionUtil에서 제거 |
|
75 | 80 |
sessionUtil.removeSession(mbrId); |
76 | 81 |
|
82 |
+ // Redis에서 세션 정보 삭제 (중복로그인 관리용) |
|
83 |
+ String sessionKey = "session:" + mbrId; |
|
84 |
+ try { |
|
85 |
+ redisTemplate.delete(sessionKey); |
|
86 |
+ } catch (Exception e) { |
|
87 |
+ } |
|
88 |
+ |
|
89 |
+ // JSESSIONID 쿠키 제거 |
|
77 | 90 |
Cookie cookie = new Cookie("JSESSIONID", null); |
78 | 91 |
cookie.setMaxAge(0); // 삭제 |
79 | 92 |
cookie.setPath("/"); |
80 | 93 |
res.addCookie(cookie); |
94 |
+ |
|
81 | 95 |
} else { |
82 | 96 |
// JWT 방식: Redis에서 삭제 |
83 | 97 |
if (!loginPolicyService.getPolicy()) { |
84 |
- redisTemplate.delete("jwt:" + mbrId); |
|
98 |
+ try { |
|
99 |
+ redisTemplate.delete("jwt:" + mbrId); |
|
100 |
+ } catch (Exception e) { |
|
101 |
+ } |
|
85 | 102 |
} |
86 |
- // 쿠키 제거 |
|
103 |
+ |
|
104 |
+ // refresh 쿠키 제거 |
|
87 | 105 |
Cookie cookie = new Cookie("refresh", null); |
88 | 106 |
cookie.setMaxAge(0); |
89 | 107 |
cookie.setHttpOnly(true); |
... | ... | @@ -91,11 +109,12 @@ |
91 | 109 |
res.addCookie(cookie); |
92 | 110 |
} |
93 | 111 |
|
94 |
- // SecurityContext 제거 |
|
112 |
+ // SecurityContext 제거 |
|
95 | 113 |
SecurityContextHolder.clearContext(); |
114 |
+ |
|
96 | 115 |
return resUtil.successRes(result, MessageCode.LOGOUT_SUCCESS); |
97 | 116 |
} |
98 |
- return resUtil.errorRes(MessageCode.COMMON_UNKNOWN_ERROR); |
|
117 |
+ return resUtil.errorRes(MessageCode.COMMON_UNKNOWN_ERROR); |
|
99 | 118 |
} |
100 | 119 |
|
101 | 120 |
/** |
... | ... | @@ -122,4 +141,4 @@ |
122 | 141 |
return new ResponseEntity<>(responseData, headers, HttpStatus.INTERNAL_SERVER_ERROR); |
123 | 142 |
} |
124 | 143 |
} |
125 |
-} |
|
144 |
+}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/common/config/SecurityConfig.java
+++ src/main/java/com/takensoft/common/config/SecurityConfig.java
... | ... | @@ -35,6 +35,7 @@ |
35 | 35 |
|
36 | 36 |
import jakarta.servlet.http.HttpServletRequest; |
37 | 37 |
import java.util.Collections; |
38 |
+import java.util.List; |
|
38 | 39 |
|
39 | 40 |
/** |
40 | 41 |
* @author takensoft |
... | ... | @@ -145,7 +146,8 @@ |
145 | 146 |
configuration.setAllowedHeaders(Collections.singletonList("*")); // 허용할 헤더 |
146 | 147 |
configuration.setAllowCredentials(true); // 프론트에서 credentials 설정하면 true |
147 | 148 |
configuration.setMaxAge(3600L); // 허용을 물고 있을 시간 |
148 |
- configuration.setExposedHeaders(Collections.singletonList("Authorization")); // 서버에서 JWT를 Authorization에 담아 보내기 위해 허용을 함 |
|
149 |
+ // configuration.setExposedHeaders(Collections.singletonList("Authorization")); // 서버에서 JWT를 Authorization에 담아 보내기 위해 허용을 함 |
|
150 |
+ configuration.setExposedHeaders(List.of("Authorization", "loginMode")); |
|
149 | 151 |
return configuration; |
150 | 152 |
} |
151 | 153 |
}) |
... | ... | @@ -189,15 +191,15 @@ |
189 | 191 |
// Context Path 검증 필터 |
190 | 192 |
http.addFilterBefore(new ContextPathFilter(cntxtPthService), SecurityContextPersistenceFilter.class); |
191 | 193 |
|
192 |
- // JWT 토큰 검증 필터 |
|
193 |
- http.addFilterBefore(new JWTFilter(jwtUtil, appConfig, loginModeService, loginPolicyService, redisTemplate), LoginFilter.class); |
|
194 |
- |
|
195 | 194 |
// 접근(아이피) 검증 필터 |
196 |
- http.addFilterBefore(new AccesFilter(accesCtrlService, httpRequestUtil, appConfig), JWTFilter.class); |
|
195 |
+ http.addFilterBefore(new AccesFilter(accesCtrlService, httpRequestUtil, appConfig), UsernamePasswordAuthenticationFilter.class); |
|
197 | 196 |
|
198 | 197 |
// 로그인 필터 |
199 | 198 |
http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), emailServiceImpl, loginUtil, email2ndAuth, unifiedLoginService), UsernamePasswordAuthenticationFilter.class); |
200 | 199 |
|
200 |
+ // JWT 토큰 검증 필터 |
|
201 |
+ http.addFilterAfter(new JWTFilter(jwtUtil, appConfig, loginModeService, loginPolicyService, redisTemplate), UsernamePasswordAuthenticationFilter.class); |
|
202 |
+ |
|
201 | 203 |
return http.build(); |
202 | 204 |
} |
203 | 205 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/common/filter/AccesFilter.java
+++ src/main/java/com/takensoft/common/filter/AccesFilter.java
... | ... | @@ -118,7 +118,8 @@ |
118 | 118 |
private boolean isOAuth2Request(String requestURI) { |
119 | 119 |
return requestURI.startsWith("/oauth2/") || |
120 | 120 |
requestURI.startsWith("/login/oauth2/") || |
121 |
- requestURI.startsWith("/oauth/"); |
|
121 |
+ requestURI.startsWith("/oauth/") || |
|
122 |
+ requestURI.contains("oauth_success=true"); |
|
122 | 123 |
} |
123 | 124 |
|
124 | 125 |
/** |
--- src/main/java/com/takensoft/common/filter/JWTFilter.java
+++ src/main/java/com/takensoft/common/filter/JWTFilter.java
... | ... | @@ -11,6 +11,8 @@ |
11 | 11 |
import io.jsonwebtoken.ExpiredJwtException; |
12 | 12 |
import io.jsonwebtoken.JwtException; |
13 | 13 |
import jakarta.servlet.http.HttpSession; |
14 |
+import jakarta.servlet.http.HttpServletRequestWrapper; |
|
15 |
+import lombok.extern.slf4j.Slf4j; |
|
14 | 16 |
import org.springframework.data.redis.core.RedisTemplate; |
15 | 17 |
import org.springframework.http.HttpStatus; |
16 | 18 |
import org.springframework.http.MediaType; |
... | ... | @@ -25,6 +27,7 @@ |
25 | 27 |
import jakarta.servlet.http.HttpServletRequest; |
26 | 28 |
import jakarta.servlet.http.HttpServletResponse; |
27 | 29 |
import java.io.IOException; |
30 |
+import java.time.Duration; |
|
28 | 31 |
import java.time.LocalDateTime; |
29 | 32 |
import java.util.List; |
30 | 33 |
|
... | ... | @@ -35,9 +38,12 @@ |
35 | 38 |
* since | author | description |
36 | 39 |
* 2024.04.01 | takensoft | 최초 등록 |
37 | 40 |
* 2025.05.30 | takensoft | 모드별 명확한 분기 처리, Redis 통합 |
41 |
+ * 2025.06.02 | takensoft | 세션 모드 중복로그인 검증 개선, 401 에러 통일 |
|
42 |
+ * 2025.06.04 | takensoft | 세션에서 JWT 토큰 추출하여 통합 처리 |
|
38 | 43 |
* |
39 |
- * JWT 토큰 검증 Filter - 모드별 명확한 분기 처리 |
|
44 |
+ * JWT 토큰 검증 Filter |
|
40 | 45 |
*/ |
46 |
+@Slf4j |
|
41 | 47 |
public class JWTFilter extends OncePerRequestFilter { |
42 | 48 |
|
43 | 49 |
private static final String AUTHORIZATION_HEADER = "Authorization"; |
... | ... | @@ -61,8 +67,8 @@ |
61 | 67 |
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { |
62 | 68 |
String requestURI = request.getRequestURI(); |
63 | 69 |
|
64 |
- // OAuth2 관련 경로는 검증 제외 |
|
65 |
- if (isOAuth2Request(requestURI)) { |
|
70 |
+ // OAuth2 관련 경로 및 로그인 요청은 검증 제외 |
|
71 |
+ if (isLoginRequest(requestURI) || isOAuth2Request(requestURI)) { |
|
66 | 72 |
filterChain.doFilter(request, response); |
67 | 73 |
return; |
68 | 74 |
} |
... | ... | @@ -71,10 +77,8 @@ |
71 | 77 |
String loginMode = loginModeService.getLoginMode(); |
72 | 78 |
|
73 | 79 |
if ("S".equals(loginMode)) { |
74 |
- // 세션 모드: 세션 기반 검증 및 Redis 중복로그인 체크 |
|
75 | 80 |
handleSessionMode(request, response, filterChain); |
76 | 81 |
} else { |
77 |
- // JWT 모드: 토큰 기반 검증 |
|
78 | 82 |
handleJwtMode(request, response, filterChain); |
79 | 83 |
} |
80 | 84 |
|
... | ... | @@ -90,7 +94,7 @@ |
90 | 94 |
} |
91 | 95 |
|
92 | 96 |
/** |
93 |
- * 세션 모드 처리 - Redis 기반 중복로그인 체크 |
|
97 |
+ * 세션 모드 처리 - 세션에서 JWT 토큰을 꺼내서 JWT 검증 로직 재사용 |
|
94 | 98 |
*/ |
95 | 99 |
private void handleSessionMode(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { |
96 | 100 |
HttpSession session = request.getSession(false); |
... | ... | @@ -106,43 +110,53 @@ |
106 | 110 |
return; |
107 | 111 |
} |
108 | 112 |
|
109 |
- // Redis 기반 중복로그인 체크 (세션 모드) |
|
110 |
- if (!loginPolicyService.getPolicy()) { |
|
111 |
- String sessionKey = "session:" + mbrId; |
|
112 |
- String storedSessionId = redisTemplate.opsForValue().get(sessionKey); |
|
113 |
+ // 세션에서 JWT 토큰 꺼내기 |
|
114 |
+ String sessionToken = (String) session.getAttribute("JWT_TOKEN"); |
|
115 |
+ if (sessionToken == null) { |
|
116 |
+ sendSessionExpiredResponse(response, request); |
|
117 |
+ return; |
|
118 |
+ } |
|
113 | 119 |
|
114 |
- if (storedSessionId == null || !storedSessionId.equals(session.getId())) { |
|
115 |
- // 다른 곳에서 로그인했거나 세션이 만료됨 |
|
120 |
+ |
|
121 |
+ if (!loginPolicyService.getPolicy()) { |
|
122 |
+ String tokenKey = "session_token:" + mbrId; |
|
123 |
+ String storedToken = redisTemplate.opsForValue().get(tokenKey); |
|
124 |
+ |
|
125 |
+ if (storedToken != null && !storedToken.equals(sessionToken)) { |
|
126 |
+ // 토큰이 다르면 바로 차단 |
|
116 | 127 |
try { |
117 | 128 |
session.invalidate(); |
118 | 129 |
} catch (IllegalStateException e) { |
119 |
- // 이미 무효화된 세션 |
|
130 |
+ // 이미 무효화됨 |
|
120 | 131 |
} |
121 | 132 |
sendSessionExpiredResponse(response, request); |
122 | 133 |
return; |
123 | 134 |
} |
124 | 135 |
} |
125 | 136 |
|
126 |
- // 세션 정보로 Authentication 설정 |
|
137 |
+ // 세션에서 꺼낸 JWT 토큰을 헤더에 설정하고 JWT 검증 로직 재사용 |
|
127 | 138 |
try { |
128 |
- List<MberAuthorVO> roles = (List<MberAuthorVO>) session.getAttribute("roles"); |
|
129 |
- String lgnId = (String) session.getAttribute("lgnId"); |
|
130 |
- String mbrNm = (String) session.getAttribute("mbrNm"); |
|
139 |
+ // Authorization 헤더 설정 |
|
140 |
+ HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(request) { |
|
141 |
+ @Override |
|
142 |
+ public String getHeader(String name) { |
|
143 |
+ if (AUTHORIZATION_HEADER.equals(name)) { |
|
144 |
+ return "Bearer " + sessionToken; |
|
145 |
+ } |
|
146 |
+ return super.getHeader(name); |
|
147 |
+ } |
|
148 |
+ }; |
|
131 | 149 |
|
132 |
- MberVO mber = new MberVO(mbrId, lgnId, roles); |
|
133 |
- mber.setMbrNm(mbrNm); |
|
150 |
+ // 기존 JWT 검증 로직 재사용 |
|
151 |
+ handleJwtMode(wrappedRequest, response, filterChain); |
|
134 | 152 |
|
135 |
- Authentication authToken = new UsernamePasswordAuthenticationToken(mber, null, mber.getAuthorities()); |
|
136 |
- SecurityContextHolder.getContext().setAuthentication(authToken); |
|
137 |
- |
|
138 |
- filterChain.doFilter(request, response); |
|
139 | 153 |
} catch (Exception e) { |
140 | 154 |
sendSessionExpiredResponse(response, request); |
141 | 155 |
} |
142 | 156 |
} |
143 | 157 |
|
144 | 158 |
/** |
145 |
- * JWT 모드 처리 - 기존 로직 유지 |
|
159 |
+ * JWT 모드 처리 |
|
146 | 160 |
*/ |
147 | 161 |
private void handleJwtMode(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { |
148 | 162 |
String accessToken = request.getHeader(AUTHORIZATION_HEADER); |
... | ... | @@ -152,27 +166,42 @@ |
152 | 166 |
return; |
153 | 167 |
} |
154 | 168 |
|
169 |
+ // JWT 토큰 만료 체크 |
|
155 | 170 |
if ((Boolean) jwtUtil.getClaim(accessToken, "isExpired")) { |
156 | 171 |
sendTokenExpiredResponse(response, request); |
157 | 172 |
return; |
158 | 173 |
} |
159 | 174 |
|
175 |
+ // 토큰에서 사용자 정보 추출 |
|
160 | 176 |
MberVO mber = new MberVO( |
161 | 177 |
(String) jwtUtil.getClaim(accessToken, "mbrId"), |
162 | 178 |
(String) jwtUtil.getClaim(accessToken, "lgnId"), |
163 | 179 |
(List<MberAuthorVO>) jwtUtil.getClaim(accessToken, "roles") |
164 | 180 |
); |
165 | 181 |
|
166 |
- // JWT 모드에서 중복로그인 체크 |
|
167 |
- if (!loginPolicyService.getPolicy() && !isTokenValid(mber.getMbrId(), accessToken)) { |
|
182 |
+ // 중복로그인 체크 |
|
183 |
+ String loginMode = loginModeService.getLoginMode(); |
|
184 |
+ if ("J".equals(loginMode) && !loginPolicyService.getPolicy() && !isTokenValid(mber.getMbrId(), accessToken)) { |
|
168 | 185 |
sendTokenExpiredResponse(response, request); |
169 | 186 |
return; |
170 | 187 |
} |
171 | 188 |
|
189 |
+ // Authentication 설정 |
|
172 | 190 |
Authentication authToken = new UsernamePasswordAuthenticationToken(mber, null, mber.getAuthorities()); |
173 | 191 |
SecurityContextHolder.getContext().setAuthentication(authToken); |
174 | 192 |
|
175 | 193 |
filterChain.doFilter(request, response); |
194 |
+ } |
|
195 |
+ |
|
196 |
+ /** |
|
197 |
+ * 로그인 요청인지 확인 |
|
198 |
+ */ |
|
199 |
+ private boolean isLoginRequest(String requestURI) { |
|
200 |
+ return requestURI.equals("/mbr/loginProc.json") || |
|
201 |
+ requestURI.startsWith("/mbr/login") || |
|
202 |
+ requestURI.startsWith("/refresh/") || |
|
203 |
+ requestURI.startsWith("/sys/") || |
|
204 |
+ requestURI.contains("loginProc"); |
|
176 | 205 |
} |
177 | 206 |
|
178 | 207 |
/** |
... | ... | @@ -181,7 +210,8 @@ |
181 | 210 |
private boolean isOAuth2Request(String requestURI) { |
182 | 211 |
return requestURI.startsWith("/oauth2/") || |
183 | 212 |
requestURI.startsWith("/login/oauth2/") || |
184 |
- requestURI.startsWith("/oauth/"); |
|
213 |
+ requestURI.startsWith("/oauth/") || |
|
214 |
+ requestURI.contains("oauth_success=true"); |
|
185 | 215 |
} |
186 | 216 |
|
187 | 217 |
/** |
... | ... | @@ -209,11 +239,11 @@ |
209 | 239 |
} |
210 | 240 |
|
211 | 241 |
/** |
212 |
- * 세션 만료 응답 |
|
242 |
+ * 세션 만료 응답 - JWT와 동일한 메시지로 통일 |
|
213 | 243 |
*/ |
214 | 244 |
private void sendSessionExpiredResponse(HttpServletResponse response, HttpServletRequest request) throws IOException { |
215 | 245 |
ErrorResponse errorResponse = new ErrorResponse(); |
216 |
- errorResponse.setMessage("Session expired or duplicate login detected"); |
|
246 |
+ errorResponse.setMessage("Token expired"); // JWT와 동일한 메시지 |
|
217 | 247 |
errorResponse.setPath(request.getRequestURI()); |
218 | 248 |
errorResponse.setError(HttpStatus.UNAUTHORIZED.getReasonPhrase()); |
219 | 249 |
errorResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); |
--- src/main/java/com/takensoft/common/filter/LoginFilter.java
+++ src/main/java/com/takensoft/common/filter/LoginFilter.java
... | ... | @@ -10,7 +10,6 @@ |
10 | 10 |
import com.takensoft.common.verify.service.Impl.EmailServiceImpl; |
11 | 11 |
import com.takensoft.common.verify.vo.EmailVO; |
12 | 12 |
import lombok.SneakyThrows; |
13 |
-import lombok.extern.slf4j.Slf4j; |
|
14 | 13 |
import org.springframework.http.HttpStatus; |
15 | 14 |
import org.springframework.security.authentication.AuthenticationManager; |
16 | 15 |
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
... | ... | @@ -38,7 +37,6 @@ |
38 | 37 |
* |
39 | 38 |
* 사용자 로그인 요청을 처리하는 Filter - 통합 로그인 시스템 적용 |
40 | 39 |
*/ |
41 |
-@Slf4j |
|
42 | 40 |
public class LoginFilter extends UsernamePasswordAuthenticationFilter { |
43 | 41 |
|
44 | 42 |
private final AuthenticationManager authenticationManager; |
... | ... | @@ -67,7 +65,7 @@ |
67 | 65 |
String lgnId = login.getLgnId(); |
68 | 66 |
String pswd = login.getPswd(); |
69 | 67 |
req.setAttribute("lgnReqPage", login.getLgnReqPage()); |
70 |
- req.setAttribute("loginType", "S"); // 시스템 로그인 표시 |
|
68 |
+ req.setAttribute("loginMode", "S"); // 시스템 로그인 표시 |
|
71 | 69 |
|
72 | 70 |
// 통합 로그인 시스템을 통한 사용자 검증 |
73 | 71 |
try { |
... | ... | @@ -118,7 +116,22 @@ |
118 | 116 |
return; |
119 | 117 |
} |
120 | 118 |
|
121 |
- loginUtil.successLogin(mber, req, res); |
|
119 |
+ try { |
|
120 |
+ // 응답이 이미 커밋되었는지 확인 |
|
121 |
+ if (res.isCommitted()) { |
|
122 |
+ return; |
|
123 |
+ } |
|
124 |
+ loginUtil.successLogin(mber, req, res); |
|
125 |
+ |
|
126 |
+ } catch (Exception e) { |
|
127 |
+ // 예외 발생 시 |
|
128 |
+ if (!res.isCommitted()) { |
|
129 |
+ res.setContentType("application/json;charset=UTF-8"); |
|
130 |
+ res.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); |
|
131 |
+ result.put("message", "로그인 처리 중 오류가 발생했습니다."); |
|
132 |
+ new ObjectMapper().writeValue(res.getOutputStream(), result); |
|
133 |
+ } |
|
134 |
+ } |
|
122 | 135 |
} |
123 | 136 |
|
124 | 137 |
@Override |
--- src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
... | ... | @@ -29,8 +29,9 @@ |
29 | 29 |
* 2025.05.22 | takensoft | 최초 등록 |
30 | 30 |
* 2025.05.28 | takensoft | 통합 로그인 적용 |
31 | 31 |
* 2025.05.29 | takensoft | OAuth2 통합 문제 해결 |
32 |
+ * 2025.06.02 | takensoft | 세션 모드 중복로그인 처리 개선 |
|
32 | 33 |
* |
33 |
- * OAuth2 로그인 성공 핸들러 - 통합 로그인 시스템 적용 (문제 해결) |
|
34 |
+ * OAuth2 로그인 성공 핸들러 - 세션 모드 중복로그인 처리 개선 |
|
34 | 35 |
*/ |
35 | 36 |
@Slf4j |
36 | 37 |
@Component |
... | ... | @@ -64,13 +65,16 @@ |
64 | 65 |
request |
65 | 66 |
); |
66 | 67 |
|
68 |
+ // OAuth2 로그인 이력 저장 |
|
67 | 69 |
saveLoginHistory(request, mber, oAuth2User.getProvider()); |
70 |
+ |
|
71 |
+ request.setAttribute("loginType", "OAUTH2"); |
|
68 | 72 |
|
69 | 73 |
// LoginUtil을 통한 통합 로그인 처리 |
70 | 74 |
loginUtil.successLogin(mber, request, response); |
71 | 75 |
|
72 |
- String redirectUrl = String.format("%s/login.page?oauth_success=true&loginMode=%s", |
|
73 |
- frontUrl, currentLoginMode); |
|
76 |
+ // OAuth2 성공 후 프론트엔드로 리다이렉트 |
|
77 |
+ String redirectUrl = String.format("%s/login.page?oauth_success=true&loginMode=%s",frontUrl, currentLoginMode); |
|
74 | 78 |
getRedirectStrategy().sendRedirect(request, response, redirectUrl); |
75 | 79 |
|
76 | 80 |
} catch (Exception e) { |
... | ... | @@ -79,7 +83,7 @@ |
79 | 83 |
} |
80 | 84 |
|
81 | 85 |
/** |
82 |
- * 로그인 이력 저장 |
|
86 |
+ * 로그인 이력 저장 - OAuth2 전용 |
|
83 | 87 |
*/ |
84 | 88 |
private void saveLoginHistory(HttpServletRequest request, MberVO mber, String provider) { |
85 | 89 |
try { |
... | ... | @@ -96,7 +100,6 @@ |
96 | 100 |
|
97 | 101 |
lgnHstryService.LgnHstrySave(loginHistory); |
98 | 102 |
} catch (Exception e) { |
99 |
- log.error("로그인 이력 저장 실패: {}", e.getMessage()); |
|
100 | 103 |
} |
101 | 104 |
} |
102 | 105 |
|
--- src/main/java/com/takensoft/common/util/LoginUtil.java
+++ src/main/java/com/takensoft/common/util/LoginUtil.java
... | ... | @@ -3,7 +3,6 @@ |
3 | 3 |
import com.fasterxml.jackson.databind.ObjectMapper; |
4 | 4 |
import com.takensoft.cms.loginPolicy.service.LoginModeService; |
5 | 5 |
import com.takensoft.cms.loginPolicy.service.LoginPolicyService; |
6 |
-import com.takensoft.cms.loginPolicy.service.StorageModeService; |
|
7 | 6 |
import com.takensoft.cms.mber.service.LgnHstryService; |
8 | 7 |
import com.takensoft.cms.mber.vo.LgnHstryVO; |
9 | 8 |
import com.takensoft.cms.mber.vo.MberVO; |
... | ... | @@ -13,7 +12,6 @@ |
13 | 12 |
import jakarta.servlet.http.HttpServletResponse; |
14 | 13 |
import jakarta.servlet.http.HttpSession; |
15 | 14 |
import lombok.RequiredArgsConstructor; |
16 |
-import lombok.extern.slf4j.Slf4j; |
|
17 | 15 |
import org.springframework.beans.factory.annotation.Value; |
18 | 16 |
import org.springframework.data.redis.core.RedisTemplate; |
19 | 17 |
import org.springframework.http.HttpStatus; |
... | ... | @@ -34,12 +32,12 @@ |
34 | 32 |
* 2025.03.21 | takensoft | 최초 등록 |
35 | 33 |
* 2025.05.28 | takensoft | 통합 로그인 적용, 문제 해결 |
36 | 34 |
* 2025.05.29 | takensoft | Redis 통합 중복로그인 관리 |
35 |
+ * 2025.06.04 | takensoft | Redis 트랜잭션 및 타이밍 이슈 해결 |
|
37 | 36 |
* |
38 |
- * 통합 로그인 유틸리티 - Redis 통합 중복로그인 관리 |
|
37 |
+ * 통합 로그인 유틸리티 |
|
39 | 38 |
*/ |
40 | 39 |
@Component |
41 | 40 |
@RequiredArgsConstructor |
42 |
-@Slf4j |
|
43 | 41 |
public class LoginUtil { |
44 | 42 |
private final LgnHstryService lgnHstryService; |
45 | 43 |
private final HttpRequestUtil httpRequestUtil; |
... | ... | @@ -49,7 +47,6 @@ |
49 | 47 |
private final JWTUtil jwtUtil; |
50 | 48 |
private final SessionUtil sessionUtil; |
51 | 49 |
private final RedisTemplate<String, String> redisTemplate; |
52 |
- private final StorageModeService storageModeService; |
|
53 | 50 |
|
54 | 51 |
@Value("${jwt.accessTime}") |
55 | 52 |
private long JWT_ACCESSTIME; |
... | ... | @@ -59,19 +56,19 @@ |
59 | 56 |
private int COOKIE_TIME; |
60 | 57 |
|
61 | 58 |
/** |
62 |
- * 통합 로그인 성공 처리 - Redis 기반 중복로그인 관리 |
|
59 |
+ * 통합 로그인 성공 처리 |
|
63 | 60 |
*/ |
64 | 61 |
public void successLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) { |
62 |
+ // 로그인 방식 확인 |
|
63 |
+ String loginMode = loginModeService.getLoginMode(); |
|
64 |
+ |
|
65 |
+ res.setHeader("loginMode", loginMode); |
|
65 | 66 |
try { |
66 | 67 |
// 로그인 이력 등록 |
67 | 68 |
String loginType = (String) req.getAttribute("loginType"); |
68 | 69 |
if (!"OAUTH2".equals(loginType)) { |
69 | 70 |
saveLoginHistory(mber, req); |
70 | 71 |
} |
71 |
- |
|
72 |
- // 로그인 방식 확인 |
|
73 |
- String loginMode = loginModeService.getLoginMode(); |
|
74 |
- |
|
75 | 72 |
if ("S".equals(loginMode)) { |
76 | 73 |
// Redis 기반 중복로그인 관리 적용 |
77 | 74 |
handleSessionLogin(mber, req, res); |
... | ... | @@ -79,29 +76,19 @@ |
79 | 76 |
// 기존 Redis 기반 관리 유지 |
80 | 77 |
handleJwtLogin(mber, req, res); |
81 | 78 |
} |
82 |
- |
|
83 |
- // 스토리지 저장 방식 확인 |
|
84 |
- String storageMode = storageModeService.findByStorageMode(); |
|
85 |
- |
|
86 |
- res.setHeader("login-type", loginMode); |
|
87 |
- res.setHeader("storage-type", storageMode); |
|
88 |
- log.info("통합 로그인 성공 처리 완료: {}, 로그인 모드: {}, 스토리지 모드: {}", mber.getMbrId(), loginMode, storageMode); |
|
89 | 79 |
} |
90 | 80 |
catch (IOException ioe) { |
91 |
- log.error("로그인 응답 처리 중 IO 오류", ioe); |
|
92 | 81 |
throw new RuntimeException(ioe); |
93 | 82 |
} |
94 | 83 |
catch (Exception e) { |
95 |
- log.error("로그인 처리 중 오류 발생", e); |
|
96 | 84 |
throw e; |
97 | 85 |
} |
98 | 86 |
} |
99 | 87 |
|
100 | 88 |
/** |
101 |
- * 세션 모드 로그인 처리 - Redis 기반 중복로그인 관리 |
|
89 |
+ * 세션 모드 로그인 처리 - Redis 트랜잭션 개선 |
|
102 | 90 |
*/ |
103 | 91 |
private void handleSessionLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) throws IOException { |
104 |
- log.info("세션 모드 로그인 처리 (Redis 통합): {}", mber.getMbrId()); |
|
105 | 92 |
|
106 | 93 |
// JWT 토큰은 생성하되 세션에만 저장 |
107 | 94 |
String accessToken = jwtUtil.createJwt("Authorization", |
... | ... | @@ -118,52 +105,38 @@ |
118 | 105 |
session.setAttribute("loginType", req.getAttribute("loginType") != null ? |
119 | 106 |
req.getAttribute("loginType") : "S"); |
120 | 107 |
|
121 |
- //중복 로그인 비허용을 Redis로 통합 관리 |
|
108 |
+ // 토큰만 저장 |
|
122 | 109 |
if (!loginPolicyService.getPolicy()) { |
123 |
- handleDuplicateSessionLogin(mber.getMbrId(), session); |
|
110 |
+ String tokenKey = "session_token:" + mber.getMbrId(); |
|
111 |
+ // 기존 토큰 삭제 후 새 토큰 저장 |
|
112 |
+ redisTemplate.delete(tokenKey); |
|
113 |
+ redisTemplate.opsForValue().set(tokenKey, accessToken, Duration.ofSeconds(session.getMaxInactiveInterval())); |
|
124 | 114 |
} |
125 | 115 |
|
126 |
- // 응답 데이터 구성 (OAuth2는 JSON 응답 없이 리다이렉트만) |
|
116 |
+ // OAuth2가 아닌 경우 JSON 응답 전송 |
|
127 | 117 |
String loginType = (String) req.getAttribute("loginType"); |
128 | 118 |
if (!"OAUTH2".equals(loginType)) { |
129 |
- Map<String, Object> result = new HashMap<>(); |
|
130 |
- result.put("mbrId", mber.getMbrId()); |
|
131 |
- result.put("mbrNm", mber.getMbrNm()); |
|
132 |
- result.put("roles", mber.getAuthorList()); |
|
119 |
+ try { |
|
120 |
+ Map<String, Object> result = new HashMap<>(); |
|
121 |
+ result.put("mbrId", mber.getMbrId()); |
|
122 |
+ result.put("mbrNm", mber.getMbrNm()); |
|
123 |
+ result.put("roles", mber.getAuthorList()); |
|
133 | 124 |
|
134 |
- res.setContentType("application/json;charset=UTF-8"); |
|
135 |
- res.setStatus(HttpStatus.OK.value()); |
|
136 |
- new ObjectMapper().writeValue(res.getOutputStream(), result); |
|
137 |
- } |
|
138 |
- } |
|
125 |
+ res.setContentType("application/json;charset=UTF-8"); |
|
126 |
+ res.setCharacterEncoding("UTF-8"); |
|
127 |
+ res.setStatus(HttpStatus.OK.value()); |
|
139 | 128 |
|
140 |
- /** |
|
141 |
- * Redis 기반 세션 중복로그인 관리 |
|
142 |
- */ |
|
143 |
- private void handleDuplicateSessionLogin(String mbrId, HttpSession newSession) { |
|
144 |
- try { |
|
145 |
- String sessionKey = "session:" + mbrId; |
|
146 |
- |
|
147 |
- // 기존 세션 확인 및 무효화 |
|
148 |
- String oldSessionId = redisTemplate.opsForValue().get(sessionKey); |
|
149 |
- if (oldSessionId != null && !oldSessionId.equals(newSession.getId())) { |
|
150 |
- // 기존 세션 무효화 |
|
151 |
- sessionUtil.invalidateSessionById(oldSessionId); |
|
129 |
+ String jsonResponse = new ObjectMapper().writeValueAsString(result); |
|
130 |
+ res.getWriter().write(jsonResponse); |
|
131 |
+ res.getWriter().flush(); |
|
132 |
+ } catch (Exception e) { |
|
133 |
+ throw e; |
|
152 | 134 |
} |
153 |
- |
|
154 |
- // 새 세션 정보를 Redis에 저장 |
|
155 |
- redisTemplate.opsForValue().set(sessionKey, newSession.getId(), |
|
156 |
- Duration.ofSeconds(newSession.getMaxInactiveInterval())); |
|
157 |
- |
|
158 |
- // 기존 SessionUtil에도 등록 (호환성 유지) |
|
159 |
- sessionUtil.registerSession(mbrId, newSession); |
|
160 |
- } catch (Exception e) { |
|
161 |
- // 실패해도 로그인은 계속 진행 |
|
162 | 135 |
} |
163 | 136 |
} |
164 | 137 |
|
165 | 138 |
/** |
166 |
- * JWT 모드 로그인 처리 - 기존 방식 유지 |
|
139 |
+ * JWT 모드 로그인 처리 |
|
167 | 140 |
*/ |
168 | 141 |
private void handleJwtLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) throws IOException { |
169 | 142 |
// JWT 토큰 생성 |
--- src/main/java/com/takensoft/common/util/SessionUtil.java
+++ src/main/java/com/takensoft/common/util/SessionUtil.java
... | ... | @@ -1,6 +1,7 @@ |
1 | 1 |
package com.takensoft.common.util; |
2 | 2 |
|
3 | 3 |
import jakarta.servlet.http.HttpSession; |
4 |
+import lombok.extern.slf4j.Slf4j; |
|
4 | 5 |
import org.springframework.data.redis.core.RedisTemplate; |
5 | 6 |
import org.springframework.stereotype.Component; |
6 | 7 |
|
... | ... | @@ -16,8 +17,9 @@ |
16 | 17 |
* 2025.03.21 | takensoft | 최초 등록 |
17 | 18 |
* 2025.05.29 | takensoft | Redis 통합 중복로그인 관리 |
18 | 19 |
* |
19 |
- * 세션 로그인 방식의 유틸리티 - Redis 통합 |
|
20 |
+ * 세션 로그인 방식의 유틸리티 |
|
20 | 21 |
*/ |
22 |
+@Slf4j |
|
21 | 23 |
@Component |
22 | 24 |
public class SessionUtil { |
23 | 25 |
|
... | ... | @@ -40,18 +42,15 @@ |
40 | 42 |
try { |
41 | 43 |
oldSession.invalidate(); |
42 | 44 |
} catch (IllegalStateException e) { |
45 |
+ |
|
43 | 46 |
} |
44 | 47 |
} |
45 |
- |
|
46 | 48 |
// 2. 새 세션을 메모리에 등록 |
47 | 49 |
sessionMap.put(mbrId, newSession); |
48 | 50 |
|
49 |
- // 3. Redis에 세션 정보 저장 (중복로그인 관리용) |
|
50 |
- String sessionKey = "session:" + mbrId; |
|
51 |
- redisTemplate.opsForValue().set(sessionKey, newSession.getId(), |
|
52 |
- Duration.ofSeconds(newSession.getMaxInactiveInterval())); |
|
51 |
+ // 3. Redis 동기화는 LoginUtil에서 처리됨 |
|
52 |
+ |
|
53 | 53 |
} catch (Exception e) { |
54 |
- e.printStackTrace(); |
|
55 | 54 |
} |
56 | 55 |
} |
57 | 56 |
|
... | ... | @@ -91,17 +90,19 @@ |
91 | 90 |
try { |
92 | 91 |
session.invalidate(); |
93 | 92 |
} catch (IllegalStateException e) { |
94 |
- e.printStackTrace(); |
|
93 |
+ |
|
95 | 94 |
} |
96 | 95 |
} |
97 | 96 |
sessionMap.remove(mbrId); |
98 | 97 |
|
99 | 98 |
// 2. Redis에서도 제거 |
100 | 99 |
String sessionKey = "session:" + mbrId; |
101 |
- redisTemplate.delete(sessionKey); |
|
100 |
+ String deletedSessionId = redisTemplate.opsForValue().get(sessionKey); |
|
101 |
+ if (deletedSessionId != null) { |
|
102 |
+ redisTemplate.delete(sessionKey); |
|
103 |
+ } |
|
102 | 104 |
|
103 | 105 |
} catch (Exception e) { |
104 |
- e.printStackTrace(); |
|
105 | 106 |
} |
106 | 107 |
} |
107 | 108 |
|
... | ... | @@ -134,46 +135,6 @@ |
134 | 135 |
} |
135 | 136 |
} catch (Exception e) { |
136 | 137 |
e.printStackTrace(); |
137 |
- } |
|
138 |
- } |
|
139 |
- |
|
140 |
- /** |
|
141 |
- * 현재 활성 세션 수 조회 |
|
142 |
- */ |
|
143 |
- public int getActiveSessionCount() { |
|
144 |
- return sessionMap.size(); |
|
145 |
- } |
|
146 |
- |
|
147 |
- /** |
|
148 |
- * 특정 사용자의 세션 존재 여부 확인 |
|
149 |
- */ |
|
150 |
- public boolean hasActiveSession(String mbrId) { |
|
151 |
- HttpSession session = sessionMap.get(mbrId); |
|
152 |
- if (session == null) { |
|
153 |
- return false; |
|
154 |
- } |
|
155 |
- |
|
156 |
- try { |
|
157 |
- // 세션이 유효한지 확인 |
|
158 |
- session.getAttribute("mbrId"); |
|
159 |
- return true; |
|
160 |
- } catch (IllegalStateException e) { |
|
161 |
- // 세션이 무효화됨 |
|
162 |
- sessionMap.remove(mbrId); |
|
163 |
- return false; |
|
164 |
- } |
|
165 |
- } |
|
166 |
- |
|
167 |
- /** |
|
168 |
- * Redis에서 세션 정보 확인 |
|
169 |
- */ |
|
170 |
- public boolean isValidSessionInRedis(String mbrId, String sessionId) { |
|
171 |
- try { |
|
172 |
- String sessionKey = "session:" + mbrId; |
|
173 |
- String storedSessionId = redisTemplate.opsForValue().get(sessionKey); |
|
174 |
- return storedSessionId != null && storedSessionId.equals(sessionId); |
|
175 |
- } catch (Exception e) { |
|
176 |
- return false; |
|
177 | 138 |
} |
178 | 139 |
} |
179 | 140 |
}(파일 끝에 줄바꿈 문자 없음) |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?