

250609 김혜민 로그아웃 세션 및 토큰 제거 수정
@65a7a6aa742fc22ef181c75beffd7ed8bac2040a
--- src/main/java/com/takensoft/cms/mber/service/Impl/UnifiedLoginServiceImpl.java
+++ src/main/java/com/takensoft/cms/mber/service/Impl/UnifiedLoginServiceImpl.java
... | ... | @@ -100,7 +100,7 @@ |
100 | 100 |
// 2-1. 기존 계정 있음 - 해당 소셜이 이미 연동되어 있는지 확인 |
101 | 101 |
HashMap<String, Object> params = new HashMap<>(); |
102 | 102 |
params.put("mbrId", existingUser.getMbrId()); |
103 |
- params.put("providerType", providerType); |
|
103 |
+ params.put("providerType", convertProviderToMbrType(providerType)); |
|
104 | 104 |
|
105 | 105 |
MberSocialAccountVO existingSocial = mberDAO.findSocialAccountByProvider(params); |
106 | 106 |
|
... | ... | @@ -133,6 +133,7 @@ |
133 | 133 |
try { |
134 | 134 |
// 회원 ID 생성 |
135 | 135 |
String mbrId = mberIdgn.getNextStringId(); |
136 |
+ String lowProviderType = convertProviderToMbrType(providerType); |
|
136 | 137 |
|
137 | 138 |
// 새 사용자 정보 설정 |
138 | 139 |
MberVO newUser = new MberVO(); |
... | ... | @@ -141,7 +142,7 @@ |
141 | 142 |
newUser.setLgnId(email.toLowerCase()); |
142 | 143 |
newUser.setMbrNm(name); |
143 | 144 |
newUser.setNcnm(name); |
144 |
- newUser.setMbrType(providerType); |
|
145 |
+ newUser.setMbrType(lowProviderType); |
|
145 | 146 |
newUser.setMbrStts("1"); |
146 | 147 |
newUser.setUseYn(true); |
147 | 148 |
newUser.setSysPvsnYn("1"); |
... | ... | @@ -163,7 +164,7 @@ |
163 | 164 |
// 소셜 계정 정보 저장 |
164 | 165 |
MberSocialAccountVO socialAccount = new MberSocialAccountVO(); |
165 | 166 |
socialAccount.setMbrId(mbrId); |
166 |
- socialAccount.setProviderType(providerType); |
|
167 |
+ socialAccount.setProviderType(lowProviderType); |
|
167 | 168 |
socialAccount.setSocialId(socialId); |
168 | 169 |
socialAccount.setSocialEmail(email); |
169 | 170 |
socialAccount.setSocialName(name); |
... | ... | @@ -189,7 +190,7 @@ |
189 | 190 |
// 중복 연동 확인 |
190 | 191 |
HashMap<String, Object> params = new HashMap<>(); |
191 | 192 |
params.put("mbrId", mbrId); |
192 |
- params.put("providerType", providerType); |
|
193 |
+ params.put("providerType", convertProviderToMbrType(providerType)); |
|
193 | 194 |
|
194 | 195 |
MberSocialAccountVO existing = mberDAO.findSocialAccountByProvider(params); |
195 | 196 |
if (existing != null && existing.getIsActive()) { |
... | ... | @@ -198,7 +199,7 @@ |
198 | 199 |
|
199 | 200 |
MberSocialAccountVO socialAccount = new MberSocialAccountVO(); |
200 | 201 |
socialAccount.setMbrId(mbrId); |
201 |
- socialAccount.setProviderType(providerType); |
|
202 |
+ socialAccount.setProviderType(convertProviderToMbrType(providerType)); |
|
202 | 203 |
socialAccount.setSocialId(socialId); |
203 | 204 |
socialAccount.setLoginId(loginId); |
204 | 205 |
socialAccount.setSocialEmail(email); |
... | ... | @@ -231,21 +232,21 @@ |
231 | 232 |
|
232 | 233 |
HashMap<String, Object> params = new HashMap<>(); |
233 | 234 |
params.put("mbrId", mbrId); |
234 |
- params.put("providerType", providerType); |
|
235 |
+ params.put("providerType", convertProviderToMbrType(providerType)); |
|
235 | 236 |
params.put("mdfr", "UNLINK_SYSTEM"); |
236 | 237 |
|
237 | 238 |
mberDAO.unlinkSocialAccount(params); |
238 | 239 |
|
239 | 240 |
// 해지된 계정이 메인 프로필이었다면 다른 계정을 메인으로 설정 |
240 | 241 |
MberSocialAccountVO unlinkedAccount = linkedAccounts.stream() |
241 |
- .filter(acc -> acc.getProviderType().equals(providerType)) |
|
242 |
+ .filter(acc -> acc.getProviderType().equals(convertProviderToMbrType(providerType))) |
|
242 | 243 |
.findFirst() |
243 | 244 |
.orElse(null); |
244 | 245 |
|
245 | 246 |
if (unlinkedAccount != null && unlinkedAccount.getIsPrimaryProfile()) { |
246 | 247 |
// 첫 번째 활성 계정을 메인으로 설정 |
247 | 248 |
MberSocialAccountVO newPrimary = linkedAccounts.stream() |
248 |
- .filter(acc -> !acc.getProviderType().equals(providerType)) |
|
249 |
+ .filter(acc -> !acc.getProviderType().equals(convertProviderToMbrType(providerType))) |
|
249 | 250 |
.findFirst() |
250 | 251 |
.orElse(null); |
251 | 252 |
|
... | ... | @@ -286,7 +287,7 @@ |
286 | 287 |
try { |
287 | 288 |
HashMap<String, Object> params = new HashMap<>(); |
288 | 289 |
params.put("mbrId", mbrId); |
289 |
- params.put("providerType", providerType); |
|
290 |
+ params.put("providerType", convertProviderToMbrType(providerType)); |
|
290 | 291 |
params.put("mdfr", "PROFILE_UPDATE"); |
291 | 292 |
|
292 | 293 |
mberDAO.setPrimaryProfile(params); |
... | ... | @@ -341,10 +342,20 @@ |
341 | 342 |
* 소셜 계정 정보 업데이트 |
342 | 343 |
*/ |
343 | 344 |
private void updateSocialAccountInfo(MberSocialAccountVO socialAccount) { |
344 |
- try { |
|
345 | 345 |
mberDAO.linkSocialAccount(socialAccount); |
346 |
- } catch (Exception e) { |
|
347 |
- log.warn("소셜 계정 정보 업데이트 실패", e); |
|
348 |
- } |
|
346 |
+ } |
|
347 |
+ |
|
348 |
+ /** |
|
349 |
+ * 제공자명을 회원타입으로 변환 |
|
350 |
+ */ |
|
351 |
+ @Override |
|
352 |
+ public String convertProviderToMbrType(String provider) { |
|
353 |
+ return switch (provider.toLowerCase()) { |
|
354 |
+ case "kakao" -> "K"; |
|
355 |
+ case "naver" -> "N"; |
|
356 |
+ case "google" -> "G"; |
|
357 |
+ case "facebook" -> "F"; |
|
358 |
+ default -> "S"; |
|
359 |
+ }; |
|
349 | 360 |
} |
350 | 361 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/cms/mber/service/UnifiedLoginService.java
+++ src/main/java/com/takensoft/cms/mber/service/UnifiedLoginService.java
... | ... | @@ -80,4 +80,11 @@ |
80 | 80 |
* @return HashMap<String, Object> 통합 제안 정보 |
81 | 81 |
*/ |
82 | 82 |
HashMap<String, Object> suggestAccountMerge(String email, String newProviderType); |
83 |
+ |
|
84 |
+ /** |
|
85 |
+ * 제공자명을 회원타입으로 변환 |
|
86 |
+ * @param provider 제공자명 |
|
87 |
+ * @return String 변환된 제공자 명 |
|
88 |
+ */ |
|
89 |
+ String convertProviderToMbrType(String provider); |
|
83 | 90 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/cms/token/web/RefreshTokenController.java
+++ src/main/java/com/takensoft/cms/token/web/RefreshTokenController.java
... | ... | @@ -6,16 +6,12 @@ |
6 | 6 |
import com.takensoft.cms.token.service.RefreshTokenService; |
7 | 7 |
import com.takensoft.cms.token.vo.RefreshTknVO; |
8 | 8 |
import com.takensoft.common.message.MessageCode; |
9 |
-import com.takensoft.common.util.ResponseData; |
|
10 | 9 |
import com.takensoft.common.util.ResponseUtil; |
11 | 10 |
import com.takensoft.common.util.SessionUtil; |
12 | 11 |
import jakarta.servlet.http.HttpSession; |
13 | 12 |
import lombok.RequiredArgsConstructor; |
14 | 13 |
import lombok.extern.slf4j.Slf4j; |
15 | 14 |
import org.springframework.data.redis.core.RedisTemplate; |
16 |
-import org.springframework.http.HttpHeaders; |
|
17 |
-import org.springframework.http.HttpStatus; |
|
18 |
-import org.springframework.http.MediaType; |
|
19 | 15 |
import org.springframework.http.ResponseEntity; |
20 | 16 |
import org.springframework.security.core.Authentication; |
21 | 17 |
import org.springframework.security.core.context.SecurityContextHolder; |
... | ... | @@ -25,7 +21,7 @@ |
25 | 21 |
import jakarta.servlet.http.Cookie; |
26 | 22 |
import jakarta.servlet.http.HttpServletRequest; |
27 | 23 |
import jakarta.servlet.http.HttpServletResponse; |
28 |
-import java.nio.charset.Charset; |
|
24 |
+import java.util.Set; |
|
29 | 25 |
|
30 | 26 |
/** |
31 | 27 |
* @author takensoft |
... | ... | @@ -54,67 +50,221 @@ |
54 | 50 |
* @param res - HTTP 응답 객체 |
55 | 51 |
* @return ResponseEntity - 로그아웃 응답 결과 |
56 | 52 |
* |
57 |
- * 로그아웃 - 세션/JWT 모드 통합 처리 |
|
53 |
+ * 로그아웃 - 세션/JWT 모드 통합 처리 + 완전 정리 |
|
58 | 54 |
*/ |
59 | 55 |
@PostMapping(value = "/mbr/logout.json") |
60 | 56 |
public ResponseEntity<?> logout(HttpServletRequest req, HttpServletResponse res){ |
61 |
- Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|
57 |
+ String mbrId = null; |
|
58 |
+ String loginMode = loginModeService.getLoginMode(); |
|
62 | 59 |
|
63 |
- if (auth != null && auth.getPrincipal() instanceof MberVO) { |
|
64 |
- MberVO mber = (MberVO) auth.getPrincipal(); |
|
65 |
- String mbrId = mber.getMbrId(); |
|
66 |
- String loginMode = loginModeService.getLoginMode(); // J or S |
|
67 |
- |
|
68 |
- // Refresh 토큰 삭제 (DB) |
|
69 |
- RefreshTknVO refresh = new RefreshTknVO(); |
|
70 |
- refresh.setMbrId(mbrId); |
|
71 |
- int result = refreshTokenService.delete(req, refresh); |
|
72 |
- |
|
73 |
- if (loginMode.equals("S")) { |
|
74 |
- HttpSession session = req.getSession(false); |
|
75 |
- if (session != null) { |
|
76 |
- session.invalidate(); |
|
77 |
- } |
|
78 |
- |
|
79 |
- // SessionUtil에서 제거 |
|
80 |
- sessionUtil.removeSession(mbrId); |
|
81 |
- |
|
82 |
- // Redis에서 세션 정보 삭제 (중복로그인 관리용) |
|
83 |
- String sessionKey = "session:" + mbrId; |
|
84 |
- try { |
|
85 |
- redisTemplate.delete(sessionKey); |
|
86 |
- } catch (Exception e) { |
|
87 |
- } |
|
88 |
- |
|
89 |
- // JSESSIONID 쿠키 제거 |
|
90 |
- Cookie cookie = new Cookie("JSESSIONID", null); |
|
91 |
- cookie.setMaxAge(0); // 삭제 |
|
92 |
- cookie.setPath("/"); |
|
93 |
- res.addCookie(cookie); |
|
94 |
- |
|
95 |
- } else { |
|
96 |
- // JWT 방식: Redis에서 삭제 |
|
97 |
- if (!loginPolicyService.getPolicy()) { |
|
98 |
- try { |
|
99 |
- redisTemplate.delete("jwt:" + mbrId); |
|
100 |
- } catch (Exception e) { |
|
101 |
- } |
|
102 |
- } |
|
103 |
- |
|
104 |
- // refresh 쿠키 제거 |
|
105 |
- Cookie cookie = new Cookie("refresh", null); |
|
106 |
- cookie.setMaxAge(0); |
|
107 |
- cookie.setHttpOnly(true); |
|
108 |
- cookie.setPath("/"); |
|
109 |
- res.addCookie(cookie); |
|
60 |
+ try { |
|
61 |
+ // 1. 인증 정보에서 사용자 ID 추출 |
|
62 |
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|
63 |
+ if (auth != null && auth.getPrincipal() instanceof MberVO) { |
|
64 |
+ MberVO mber = (MberVO) auth.getPrincipal(); |
|
65 |
+ mbrId = mber.getMbrId(); |
|
110 | 66 |
} |
111 | 67 |
|
112 |
- // SecurityContext 제거 |
|
68 |
+ // 2. 세션에서 사용자 ID 추출 (인증 정보가 없는 경우) |
|
69 |
+ if (mbrId == null) { |
|
70 |
+ HttpSession session = req.getSession(false); |
|
71 |
+ if (session != null) { |
|
72 |
+ mbrId = (String) session.getAttribute("mbrId"); |
|
73 |
+ } |
|
74 |
+ } |
|
75 |
+ |
|
76 |
+ log.info("로그아웃 시작 - 사용자: {}, 모드: {}", mbrId, loginMode); |
|
77 |
+ |
|
78 |
+ // 3. DB에서 Refresh 토큰 삭제 |
|
79 |
+ int dbResult = 0; |
|
80 |
+ if (mbrId != null) { |
|
81 |
+ RefreshTknVO refresh = new RefreshTknVO(); |
|
82 |
+ refresh.setMbrId(mbrId); |
|
83 |
+ dbResult = refreshTokenService.delete(req, refresh); |
|
84 |
+ } |
|
85 |
+ |
|
86 |
+ // 4. 로그인 모드별 처리 |
|
87 |
+ if ("S".equals(loginMode)) { |
|
88 |
+ handleSessionLogout(req, res, mbrId); |
|
89 |
+ } else { |
|
90 |
+ handleJWTLogout(req, res, mbrId); |
|
91 |
+ } |
|
92 |
+ |
|
93 |
+ // 5. 공통 정리 작업 |
|
94 |
+ performCommonCleanup(req, res); |
|
95 |
+ |
|
96 |
+ log.info("로그아웃 완료 - 사용자: {}", mbrId); |
|
97 |
+ return resUtil.successRes(dbResult, MessageCode.LOGOUT_SUCCESS); |
|
98 |
+ |
|
99 |
+ } catch (Exception e) { |
|
100 |
+ log.error("로그아웃 처리 중 오류 발생 - 사용자: {}, 오류: {}", mbrId, e.getMessage(), e); |
|
101 |
+ |
|
102 |
+ // 오류가 발생해도 기본 정리는 수행 |
|
103 |
+ try { |
|
104 |
+ performCommonCleanup(req, res); |
|
105 |
+ } catch (Exception cleanupError) { |
|
106 |
+ log.error("정리 작업 중 오류: {}", cleanupError.getMessage()); |
|
107 |
+ } |
|
108 |
+ |
|
109 |
+ return resUtil.successRes(0, MessageCode.LOGOUT_SUCCESS); // 클라이언트에는 성공으로 응답 |
|
110 |
+ } |
|
111 |
+ } |
|
112 |
+ |
|
113 |
+ /** |
|
114 |
+ * 세션 모드 로그아웃 처리 |
|
115 |
+ */ |
|
116 |
+ private void handleSessionLogout(HttpServletRequest req, HttpServletResponse res, String mbrId) { |
|
117 |
+ try { |
|
118 |
+ // 1. 현재 세션 무효화 |
|
119 |
+ HttpSession session = req.getSession(false); |
|
120 |
+ if (session != null) { |
|
121 |
+ try { |
|
122 |
+ session.invalidate(); |
|
123 |
+ log.debug("세션 무효화 완료: {}", session.getId()); |
|
124 |
+ } catch (IllegalStateException e) { |
|
125 |
+ log.debug("이미 무효화된 세션: {}", e.getMessage()); |
|
126 |
+ } |
|
127 |
+ } |
|
128 |
+ |
|
129 |
+ // 2. SessionUtil에서 제거 |
|
130 |
+ if (mbrId != null) { |
|
131 |
+ sessionUtil.removeSession(mbrId); |
|
132 |
+ } |
|
133 |
+ |
|
134 |
+ // 3. Redis에서 세션 관련 정보 삭제 |
|
135 |
+ if (mbrId != null) { |
|
136 |
+ cleanupSessionRedisData(mbrId); |
|
137 |
+ } |
|
138 |
+ |
|
139 |
+ // 4. 세션 쿠키 제거 |
|
140 |
+ removeSessionCookies(res); |
|
141 |
+ |
|
142 |
+ } catch (Exception e) { |
|
143 |
+ log.error("세션 모드 로그아웃 처리 중 오류: {}", e.getMessage(), e); |
|
144 |
+ } |
|
145 |
+ } |
|
146 |
+ |
|
147 |
+ /** |
|
148 |
+ * JWT 모드 로그아웃 처리 |
|
149 |
+ */ |
|
150 |
+ private void handleJWTLogout(HttpServletRequest req, HttpServletResponse res, String mbrId) { |
|
151 |
+ try { |
|
152 |
+ // 1. Redis에서 JWT 정보 삭제 (중복로그인 관리용) |
|
153 |
+ if (mbrId != null && !loginPolicyService.getPolicy()) { |
|
154 |
+ redisTemplate.delete("jwt:" + mbrId); |
|
155 |
+ log.debug("Redis JWT 토큰 삭제: jwt:{}", mbrId); |
|
156 |
+ } |
|
157 |
+ |
|
158 |
+ // 2. JWT 관련 쿠키 제거 |
|
159 |
+ removeJWTCookies(res); |
|
160 |
+ |
|
161 |
+ } catch (Exception e) { |
|
162 |
+ log.error("JWT 모드 로그아웃 처리 중 오류: {}", e.getMessage(), e); |
|
163 |
+ } |
|
164 |
+ } |
|
165 |
+ |
|
166 |
+ /** |
|
167 |
+ * Redis 세션 데이터 정리 |
|
168 |
+ */ |
|
169 |
+ private void cleanupSessionRedisData(String mbrId) { |
|
170 |
+ try { |
|
171 |
+ // 세션 토큰 키 삭제 |
|
172 |
+ String sessionTokenKey = "session_token:" + mbrId; |
|
173 |
+ redisTemplate.delete(sessionTokenKey); |
|
174 |
+ |
|
175 |
+ // 세션 키 삭제 |
|
176 |
+ String sessionKey = "session:" + mbrId; |
|
177 |
+ redisTemplate.delete(sessionKey); |
|
178 |
+ |
|
179 |
+ // 기타 사용자별 Redis 키 패턴 삭제 |
|
180 |
+ Set<String> userKeys = redisTemplate.keys("*:" + mbrId); |
|
181 |
+ if (userKeys != null && !userKeys.isEmpty()) { |
|
182 |
+ redisTemplate.delete(userKeys); |
|
183 |
+ log.debug("사용자별 Redis 키 삭제: {}", userKeys); |
|
184 |
+ } |
|
185 |
+ |
|
186 |
+ } catch (Exception e) { |
|
187 |
+ log.error("Redis 세션 데이터 정리 중 오류: {}", e.getMessage(), e); |
|
188 |
+ } |
|
189 |
+ } |
|
190 |
+ |
|
191 |
+ /** |
|
192 |
+ * 세션 관련 쿠키 제거 |
|
193 |
+ */ |
|
194 |
+ private void removeSessionCookies(HttpServletResponse res) { |
|
195 |
+ String[] sessionCookies = {"JSESSIONID", "SESSION"}; |
|
196 |
+ |
|
197 |
+ for (String cookieName : sessionCookies) { |
|
198 |
+ // 기본 경로 |
|
199 |
+ Cookie cookie = new Cookie(cookieName, null); |
|
200 |
+ cookie.setMaxAge(0); |
|
201 |
+ cookie.setPath("/"); |
|
202 |
+ res.addCookie(cookie); |
|
203 |
+ |
|
204 |
+ // 도메인별 쿠키도 삭제 시도 |
|
205 |
+ Cookie domainCookie = new Cookie(cookieName, null); |
|
206 |
+ domainCookie.setMaxAge(0); |
|
207 |
+ domainCookie.setPath("/"); |
|
208 |
+ // domainCookie.setDomain(".example.com"); // 필요시 도메인 설정 |
|
209 |
+ res.addCookie(domainCookie); |
|
210 |
+ } |
|
211 |
+ } |
|
212 |
+ |
|
213 |
+ /** |
|
214 |
+ * JWT 관련 쿠키 제거 |
|
215 |
+ */ |
|
216 |
+ private void removeJWTCookies(HttpServletResponse res) { |
|
217 |
+ String[] jwtCookies = {"refresh", "Authorization", "access_token"}; |
|
218 |
+ |
|
219 |
+ for (String cookieName : jwtCookies) { |
|
220 |
+ Cookie cookie = new Cookie(cookieName, null); |
|
221 |
+ cookie.setMaxAge(0); |
|
222 |
+ cookie.setHttpOnly(true); |
|
223 |
+ cookie.setPath("/"); |
|
224 |
+ res.addCookie(cookie); |
|
225 |
+ } |
|
226 |
+ } |
|
227 |
+ |
|
228 |
+ /** |
|
229 |
+ * OAuth2 관련 쿠키 제거 |
|
230 |
+ */ |
|
231 |
+ private void removeOAuth2Cookies(HttpServletResponse res) { |
|
232 |
+ String[] oauthCookies = { |
|
233 |
+ "oauth_access_token", "oauth_refresh_token", "oauth_state", |
|
234 |
+ "OAUTH2_AUTHORIZATION_REQUEST", "oauth2_auth_request" |
|
235 |
+ }; |
|
236 |
+ |
|
237 |
+ for (String cookieName : oauthCookies) { |
|
238 |
+ Cookie cookie = new Cookie(cookieName, null); |
|
239 |
+ cookie.setMaxAge(0); |
|
240 |
+ cookie.setPath("/"); |
|
241 |
+ res.addCookie(cookie); |
|
242 |
+ } |
|
243 |
+ } |
|
244 |
+ |
|
245 |
+ /** |
|
246 |
+ * 공통 정리 작업 |
|
247 |
+ */ |
|
248 |
+ private void performCommonCleanup(HttpServletRequest req, HttpServletResponse res) { |
|
249 |
+ try { |
|
250 |
+ // 1. SecurityContext 제거 |
|
113 | 251 |
SecurityContextHolder.clearContext(); |
114 | 252 |
|
115 |
- return resUtil.successRes(result, MessageCode.LOGOUT_SUCCESS); |
|
253 |
+ // 2. OAuth2 쿠키 제거 |
|
254 |
+ removeOAuth2Cookies(res); |
|
255 |
+ |
|
256 |
+ // 3. 응답 헤더에서 인증 정보 제거 |
|
257 |
+ res.setHeader("Authorization", ""); |
|
258 |
+ res.setHeader("loginMode", ""); |
|
259 |
+ |
|
260 |
+ // 4. 캐시 무효화 헤더 설정 |
|
261 |
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); |
|
262 |
+ res.setHeader("Pragma", "no-cache"); |
|
263 |
+ res.setHeader("Expires", "0"); |
|
264 |
+ |
|
265 |
+ } catch (Exception e) { |
|
266 |
+ log.error("공통 정리 작업 중 오류: {}", e.getMessage(), e); |
|
116 | 267 |
} |
117 |
- return resUtil.errorRes(MessageCode.COMMON_UNKNOWN_ERROR); |
|
118 | 268 |
} |
119 | 269 |
|
120 | 270 |
/** |
... | ... | @@ -126,19 +276,17 @@ |
126 | 276 |
*/ |
127 | 277 |
@PostMapping("/refresh/tokenReissue.json") |
128 | 278 |
public ResponseEntity<?> tokenReissue(HttpServletRequest req, HttpServletResponse res) { |
129 |
- int result = refreshTokenService.tokenReissueProc(req, res); |
|
279 |
+ try { |
|
280 |
+ int result = refreshTokenService.tokenReissueProc(req, res); |
|
130 | 281 |
|
131 |
- // 응답 처리 |
|
132 |
- HttpHeaders headers = new HttpHeaders(); |
|
133 |
- headers.setContentType(new MediaType("application", "json", Charset.forName("UTF-8"))); |
|
134 |
- ResponseData responseData = new ResponseData(); |
|
135 |
- if(result > 0) { |
|
136 |
- return resUtil.successRes(result, MessageCode.COMMON_SUCCESS); |
|
137 |
- } else { |
|
138 |
- responseData.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); |
|
139 |
- responseData.setStatusText(HttpStatus.INTERNAL_SERVER_ERROR); |
|
140 |
- responseData.setMessage("로그인을 다시해주시기 바랍니다."); |
|
141 |
- return new ResponseEntity<>(responseData, headers, HttpStatus.INTERNAL_SERVER_ERROR); |
|
282 |
+ if(result > 0) { |
|
283 |
+ return resUtil.successRes(result, MessageCode.COMMON_SUCCESS); |
|
284 |
+ } else { |
|
285 |
+ return resUtil.errorRes(MessageCode.JWT_EXPIRED); |
|
286 |
+ } |
|
287 |
+ } catch (Exception e) { |
|
288 |
+ log.error("토큰 재발급 중 오류: {}", e.getMessage(), e); |
|
289 |
+ return resUtil.errorRes(MessageCode.JWT_EXPIRED); |
|
142 | 290 |
} |
143 | 291 |
} |
144 | 292 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/common/config/SecurityConfig.java
+++ src/main/java/com/takensoft/common/config/SecurityConfig.java
... | ... | @@ -195,7 +195,7 @@ |
195 | 195 |
http.addFilterBefore(new AccesFilter(accesCtrlService, httpRequestUtil, appConfig), UsernamePasswordAuthenticationFilter.class); |
196 | 196 |
|
197 | 197 |
// 로그인 필터 |
198 |
- http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), emailServiceImpl, loginUtil, email2ndAuth, unifiedLoginService), UsernamePasswordAuthenticationFilter.class); |
|
198 |
+ http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), emailServiceImpl, loginUtil, email2ndAuth), UsernamePasswordAuthenticationFilter.class); |
|
199 | 199 |
|
200 | 200 |
// JWT 토큰 검증 필터 |
201 | 201 |
http.addFilterAfter(new JWTFilter(jwtUtil, appConfig, loginModeService, loginPolicyService, redisTemplate), UsernamePasswordAuthenticationFilter.class); |
--- src/main/java/com/takensoft/common/filter/JWTFilter.java
+++ src/main/java/com/takensoft/common/filter/JWTFilter.java
... | ... | @@ -10,8 +10,12 @@ |
10 | 10 |
import com.takensoft.common.util.JWTUtil; |
11 | 11 |
import io.jsonwebtoken.ExpiredJwtException; |
12 | 12 |
import io.jsonwebtoken.JwtException; |
13 |
-import jakarta.servlet.http.HttpSession; |
|
13 |
+import jakarta.servlet.FilterChain; |
|
14 |
+import jakarta.servlet.ServletException; |
|
15 |
+import jakarta.servlet.http.HttpServletRequest; |
|
16 |
+import jakarta.servlet.http.HttpServletResponse; |
|
14 | 17 |
import jakarta.servlet.http.HttpServletRequestWrapper; |
18 |
+import jakarta.servlet.http.HttpSession; |
|
15 | 19 |
import lombok.extern.slf4j.Slf4j; |
16 | 20 |
import org.springframework.data.redis.core.RedisTemplate; |
17 | 21 |
import org.springframework.http.HttpStatus; |
... | ... | @@ -22,14 +26,10 @@ |
22 | 26 |
import org.springframework.security.core.context.SecurityContextHolder; |
23 | 27 |
import org.springframework.web.filter.OncePerRequestFilter; |
24 | 28 |
|
25 |
-import jakarta.servlet.FilterChain; |
|
26 |
-import jakarta.servlet.ServletException; |
|
27 |
-import jakarta.servlet.http.HttpServletRequest; |
|
28 |
-import jakarta.servlet.http.HttpServletResponse; |
|
29 | 29 |
import java.io.IOException; |
30 |
-import java.time.Duration; |
|
31 | 30 |
import java.time.LocalDateTime; |
32 | 31 |
import java.util.List; |
32 |
+import java.util.concurrent.TimeUnit; |
|
33 | 33 |
|
34 | 34 |
/** |
35 | 35 |
* @author takensoft |
... | ... | @@ -40,8 +40,9 @@ |
40 | 40 |
* 2025.05.30 | takensoft | 모드별 명확한 분기 처리, Redis 통합 |
41 | 41 |
* 2025.06.02 | takensoft | 세션 모드 중복로그인 검증 개선, 401 에러 통일 |
42 | 42 |
* 2025.06.04 | takensoft | 세션에서 JWT 토큰 추출하여 통합 처리 |
43 |
+ * 2025.06.09 | takensoft | 중복로그인 검증 로직 개선, 안정성 향상 |
|
43 | 44 |
* |
44 |
- * JWT 토큰 검증 Filter |
|
45 |
+ * JWT 토큰 검증 Filter - 중복로그인 검증 개선 |
|
45 | 46 |
*/ |
46 | 47 |
@Slf4j |
47 | 48 |
public class JWTFilter extends OncePerRequestFilter { |
... | ... | @@ -64,7 +65,8 @@ |
64 | 65 |
} |
65 | 66 |
|
66 | 67 |
@Override |
67 |
- protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { |
|
68 |
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) |
|
69 |
+ throws ServletException, IOException { |
|
68 | 70 |
String requestURI = request.getRequestURI(); |
69 | 71 |
|
70 | 72 |
// OAuth2 관련 경로 및 로그인 요청은 검증 제외 |
... | ... | @@ -75,20 +77,22 @@ |
75 | 77 |
|
76 | 78 |
try { |
77 | 79 |
String loginMode = loginModeService.getLoginMode(); |
78 |
- |
|
79 | 80 |
if ("S".equals(loginMode)) { |
80 | 81 |
handleSessionMode(request, response, filterChain); |
81 | 82 |
} else { |
82 | 83 |
handleJwtMode(request, response, filterChain); |
83 | 84 |
} |
84 |
- |
|
85 | 85 |
} catch (ExpiredJwtException e) { |
86 |
+ log.debug("JWT 토큰 만료: {}", e.getMessage()); |
|
86 | 87 |
FilterExceptionHandler.jwtError(response, e); |
87 | 88 |
} catch (JwtException e) { |
89 |
+ log.debug("JWT 토큰 오류: {}", e.getMessage()); |
|
88 | 90 |
FilterExceptionHandler.jwtError(response, e); |
89 | 91 |
} catch (InsufficientAuthenticationException e) { |
92 |
+ log.debug("인증 부족: {}", e.getMessage()); |
|
90 | 93 |
FilterExceptionHandler.jwtError(response, e); |
91 | 94 |
} catch (Exception e) { |
95 |
+ log.error("JWT 필터 처리 중 예상치 못한 오류: {}", e.getMessage(), e); |
|
92 | 96 |
FilterExceptionHandler.jwtError(response, e); |
93 | 97 |
} |
94 | 98 |
} |
... | ... | @@ -96,7 +100,8 @@ |
96 | 100 |
/** |
97 | 101 |
* 세션 모드 처리 - 세션에서 JWT 토큰을 꺼내서 JWT 검증 로직 재사용 |
98 | 102 |
*/ |
99 |
- private void handleSessionMode(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { |
|
103 |
+ private void handleSessionMode(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) |
|
104 |
+ throws ServletException, IOException { |
|
100 | 105 |
HttpSession session = request.getSession(false); |
101 | 106 |
|
102 | 107 |
if (session == null) { |
... | ... | @@ -111,32 +116,25 @@ |
111 | 116 |
} |
112 | 117 |
|
113 | 118 |
// 세션에서 JWT 토큰 꺼내기 |
114 |
- String sessionToken = (String) session.getAttribute("JWT_TOKEN"); |
|
119 |
+ String sessionToken = (String) session.getAttribute(SESSION_JWT_KEY); |
|
115 | 120 |
if (sessionToken == null) { |
116 |
- sendSessionExpiredResponse(response, request); |
|
121 |
+ log.debug("세션에 JWT 토큰이 없음 - 사용자: {}", mbrId); |
|
122 |
+ // 토큰이 없어도 세션 자체는 유효할 수 있으므로 계속 진행 |
|
123 |
+ filterChain.doFilter(request, response); |
|
117 | 124 |
return; |
118 | 125 |
} |
119 | 126 |
|
120 |
- |
|
127 |
+ // 중복 로그인 검증 (비허용 모드일 때만) |
|
121 | 128 |
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 |
- // 토큰이 다르면 바로 차단 |
|
127 |
- try { |
|
128 |
- session.invalidate(); |
|
129 |
- } catch (IllegalStateException e) { |
|
130 |
- // 이미 무효화됨 |
|
131 |
- } |
|
132 |
- sendSessionExpiredResponse(response, request); |
|
129 |
+ if (!validateSessionToken(mbrId, sessionToken, session)) { |
|
130 |
+ log.info("세션 중복로그인 감지 - 사용자: {}", mbrId); |
|
131 |
+ sendTokenExpiredResponse(response, request); |
|
133 | 132 |
return; |
134 | 133 |
} |
135 | 134 |
} |
136 | 135 |
|
137 | 136 |
// 세션에서 꺼낸 JWT 토큰을 헤더에 설정하고 JWT 검증 로직 재사용 |
138 | 137 |
try { |
139 |
- // Authorization 헤더 설정 |
|
140 | 138 |
HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(request) { |
141 | 139 |
@Override |
142 | 140 |
public String getHeader(String name) { |
... | ... | @@ -147,18 +145,57 @@ |
147 | 145 |
} |
148 | 146 |
}; |
149 | 147 |
|
150 |
- // 기존 JWT 검증 로직 재사용 |
|
151 |
- handleJwtMode(wrappedRequest, response, filterChain); |
|
148 |
+ // 기존 JWT 검증 로직 재사용 (단, 재귀 호출 방지) |
|
149 |
+ processJwtToken(wrappedRequest, response, filterChain, sessionToken); |
|
152 | 150 |
|
153 | 151 |
} catch (Exception e) { |
154 |
- sendSessionExpiredResponse(response, request); |
|
152 |
+ log.debug("세션 모드 JWT 검증 중 오류 - 사용자: {}, 오류: {}", mbrId, e.getMessage()); |
|
153 |
+ // JWT 토큰에 문제가 있어도 세션은 유효할 수 있으므로 계속 진행 |
|
154 |
+ filterChain.doFilter(request, response); |
|
155 |
+ } |
|
156 |
+ } |
|
157 |
+ |
|
158 |
+ /** |
|
159 |
+ * 세션 토큰 유효성 검증 |
|
160 |
+ */ |
|
161 |
+ private boolean validateSessionToken(String mbrId, String sessionToken, HttpSession session) { |
|
162 |
+ try { |
|
163 |
+ String tokenKey = "session_token:" + mbrId; |
|
164 |
+ String storedToken = redisTemplate.opsForValue().get(tokenKey); |
|
165 |
+ |
|
166 |
+ if (storedToken == null) { |
|
167 |
+ log.debug("Redis에 저장된 토큰이 없음 - 사용자: {}", mbrId); |
|
168 |
+ return false; |
|
169 |
+ } |
|
170 |
+ |
|
171 |
+ if (!storedToken.equals(sessionToken)) { |
|
172 |
+ log.info("토큰 불일치로 인한 중복로그인 감지 - 사용자: {}", mbrId); |
|
173 |
+ |
|
174 |
+ // 세션 무효화 |
|
175 |
+ try { |
|
176 |
+ session.invalidate(); |
|
177 |
+ } catch (IllegalStateException e) { |
|
178 |
+ log.debug("이미 무효화된 세션: {}", e.getMessage()); |
|
179 |
+ } |
|
180 |
+ |
|
181 |
+ return false; |
|
182 |
+ } |
|
183 |
+ |
|
184 |
+ // 토큰 갱신 (활성 상태 유지) |
|
185 |
+ redisTemplate.expire(tokenKey, session.getMaxInactiveInterval(), TimeUnit.SECONDS); |
|
186 |
+ return true; |
|
187 |
+ |
|
188 |
+ } catch (Exception e) { |
|
189 |
+ log.error("세션 토큰 검증 중 오류 - 사용자: {}, 오류: {}", mbrId, e.getMessage(), e); |
|
190 |
+ return false; |
|
155 | 191 |
} |
156 | 192 |
} |
157 | 193 |
|
158 | 194 |
/** |
159 | 195 |
* JWT 모드 처리 |
160 | 196 |
*/ |
161 |
- private void handleJwtMode(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { |
|
197 |
+ private void handleJwtMode(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) |
|
198 |
+ throws ServletException, IOException { |
|
162 | 199 |
String accessToken = request.getHeader(AUTHORIZATION_HEADER); |
163 | 200 |
|
164 | 201 |
if (accessToken == null) { |
... | ... | @@ -167,24 +204,85 @@ |
167 | 204 |
} |
168 | 205 |
|
169 | 206 |
// JWT 토큰 만료 체크 |
170 |
- if ((Boolean) jwtUtil.getClaim(accessToken, "isExpired")) { |
|
207 |
+ Boolean isExpired = (Boolean) jwtUtil.getClaim(accessToken, "isExpired"); |
|
208 |
+ if (isExpired != null && isExpired) { |
|
209 |
+ log.debug("JWT 토큰 만료됨"); |
|
171 | 210 |
sendTokenExpiredResponse(response, request); |
172 | 211 |
return; |
173 | 212 |
} |
174 | 213 |
|
175 | 214 |
// 토큰에서 사용자 정보 추출 |
176 |
- MberVO mber = new MberVO( |
|
177 |
- (String) jwtUtil.getClaim(accessToken, "mbrId"), |
|
178 |
- (String) jwtUtil.getClaim(accessToken, "lgnId"), |
|
179 |
- (List<MberAuthorVO>) jwtUtil.getClaim(accessToken, "roles") |
|
180 |
- ); |
|
215 |
+ String mbrId = (String) jwtUtil.getClaim(accessToken, "mbrId"); |
|
216 |
+ String lgnId = (String) jwtUtil.getClaim(accessToken, "lgnId"); |
|
217 |
+ List<MberAuthorVO> roles = (List<MberAuthorVO>) jwtUtil.getClaim(accessToken, "roles"); |
|
181 | 218 |
|
182 |
- // 중복로그인 체크 |
|
219 |
+ MberVO mber = new MberVO(mbrId, lgnId, roles); |
|
220 |
+ |
|
221 |
+ // 중복로그인 체크 (JWT 모드 + 비허용일 때만) |
|
183 | 222 |
String loginMode = loginModeService.getLoginMode(); |
184 |
- if ("J".equals(loginMode) && !loginPolicyService.getPolicy() && !isTokenValid(mber.getMbrId(), accessToken)) { |
|
185 |
- sendTokenExpiredResponse(response, request); |
|
223 |
+ if ("J".equals(loginMode) && !loginPolicyService.getPolicy()) { |
|
224 |
+ if (!validateJwtToken(mbrId, accessToken)) { |
|
225 |
+ log.info("JWT 중복로그인 감지 - 사용자: {}", mbrId); |
|
226 |
+ sendTokenExpiredResponse(response, request); |
|
227 |
+ return; |
|
228 |
+ } |
|
229 |
+ } |
|
230 |
+ |
|
231 |
+ // Authentication 설정 |
|
232 |
+ Authentication authToken = new UsernamePasswordAuthenticationToken(mber, null, mber.getAuthorities()); |
|
233 |
+ SecurityContextHolder.getContext().setAuthentication(authToken); |
|
234 |
+ |
|
235 |
+ filterChain.doFilter(request, response); |
|
236 |
+ } |
|
237 |
+ |
|
238 |
+ /** |
|
239 |
+ * JWT 토큰 유효성 검증 (개선된 로직) |
|
240 |
+ */ |
|
241 |
+ private boolean validateJwtToken(String mbrId, String accessToken) { |
|
242 |
+ try { |
|
243 |
+ String tokenKey = "jwt:" + mbrId; |
|
244 |
+ String storedToken = redisTemplate.opsForValue().get(tokenKey); |
|
245 |
+ |
|
246 |
+ if (storedToken == null) { |
|
247 |
+ log.debug("Redis에 저장된 JWT 토큰이 없음 - 사용자: {}", mbrId); |
|
248 |
+ return false; |
|
249 |
+ } |
|
250 |
+ |
|
251 |
+ String currentToken = jwtUtil.extractToken(accessToken); |
|
252 |
+ |
|
253 |
+ if (!storedToken.equals(currentToken)) { |
|
254 |
+ log.info("JWT 토큰 불일치로 인한 중복로그인 감지 - 사용자: {}", mbrId); |
|
255 |
+ return false; |
|
256 |
+ } |
|
257 |
+ |
|
258 |
+ return true; |
|
259 |
+ |
|
260 |
+ } catch (Exception e) { |
|
261 |
+ log.error("JWT 토큰 검증 중 오류 - 사용자: {}, 오류: {}", mbrId, e.getMessage(), e); |
|
262 |
+ return false; |
|
263 |
+ } |
|
264 |
+ } |
|
265 |
+ |
|
266 |
+ /** |
|
267 |
+ * JWT 토큰 처리 (재귀 호출 방지) |
|
268 |
+ */ |
|
269 |
+ private void processJwtToken(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, String accessToken) |
|
270 |
+ throws ServletException, IOException { |
|
271 |
+ |
|
272 |
+ // JWT 토큰 만료 체크 |
|
273 |
+ Boolean isExpired = (Boolean) jwtUtil.getClaim("Bearer " + accessToken, "isExpired"); |
|
274 |
+ if (isExpired != null && isExpired) { |
|
275 |
+ log.debug("JWT 토큰 만료됨"); |
|
276 |
+ filterChain.doFilter(request, response); |
|
186 | 277 |
return; |
187 | 278 |
} |
279 |
+ |
|
280 |
+ // 토큰에서 사용자 정보 추출 |
|
281 |
+ String mbrId = (String) jwtUtil.getClaim("Bearer " + accessToken, "mbrId"); |
|
282 |
+ String lgnId = (String) jwtUtil.getClaim("Bearer " + accessToken, "lgnId"); |
|
283 |
+ List<MberAuthorVO> roles = (List<MberAuthorVO>) jwtUtil.getClaim("Bearer " + accessToken, "roles"); |
|
284 |
+ |
|
285 |
+ MberVO mber = new MberVO(mbrId, lgnId, roles); |
|
188 | 286 |
|
189 | 287 |
// Authentication 설정 |
190 | 288 |
Authentication authToken = new UsernamePasswordAuthenticationToken(mber, null, mber.getAuthorities()); |
... | ... | @@ -215,35 +313,11 @@ |
215 | 313 |
} |
216 | 314 |
|
217 | 315 |
/** |
218 |
- * JWT 토큰 유효성 검증 (기존 로직) |
|
219 |
- */ |
|
220 |
- private boolean isTokenValid(String mbrId, String accessToken) { |
|
221 |
- String storedToken = redisTemplate.opsForValue().get("jwt:" + mbrId); |
|
222 |
- return storedToken == null || storedToken.equals(jwtUtil.extractToken(accessToken)); |
|
223 |
- } |
|
224 |
- |
|
225 |
- /** |
|
226 |
- * 토큰 만료 응답 |
|
316 |
+ * 토큰 만료 응답 전송 |
|
227 | 317 |
*/ |
228 | 318 |
private void sendTokenExpiredResponse(HttpServletResponse response, HttpServletRequest request) throws IOException { |
229 | 319 |
ErrorResponse errorResponse = new ErrorResponse(); |
230 | 320 |
errorResponse.setMessage("Token expired"); |
231 |
- errorResponse.setPath(request.getRequestURI()); |
|
232 |
- errorResponse.setError(HttpStatus.UNAUTHORIZED.getReasonPhrase()); |
|
233 |
- errorResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); |
|
234 |
- errorResponse.setTimestamp(LocalDateTime.now()); |
|
235 |
- |
|
236 |
- response.setContentType(MediaType.APPLICATION_JSON_VALUE); |
|
237 |
- response.setStatus(HttpStatus.UNAUTHORIZED.value()); |
|
238 |
- response.getOutputStream().write(appConfig.getObjectMapper().writeValueAsBytes(errorResponse)); |
|
239 |
- } |
|
240 |
- |
|
241 |
- /** |
|
242 |
- * 세션 만료 응답 - JWT와 동일한 메시지로 통일 |
|
243 |
- */ |
|
244 |
- private void sendSessionExpiredResponse(HttpServletResponse response, HttpServletRequest request) throws IOException { |
|
245 |
- ErrorResponse errorResponse = new ErrorResponse(); |
|
246 |
- errorResponse.setMessage("Token expired"); // JWT와 동일한 메시지 |
|
247 | 321 |
errorResponse.setPath(request.getRequestURI()); |
248 | 322 |
errorResponse.setError(HttpStatus.UNAUTHORIZED.getReasonPhrase()); |
249 | 323 |
errorResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); |
--- src/main/java/com/takensoft/common/filter/LoginFilter.java
+++ src/main/java/com/takensoft/common/filter/LoginFilter.java
... | ... | @@ -3,7 +3,6 @@ |
3 | 3 |
import com.fasterxml.jackson.databind.ObjectMapper; |
4 | 4 |
import com.takensoft.cms.loginPolicy.service.Email2ndAuthService; |
5 | 5 |
import com.takensoft.cms.mber.dto.LoginDTO; |
6 |
-import com.takensoft.cms.mber.service.UnifiedLoginService; |
|
7 | 6 |
import com.takensoft.cms.mber.vo.MberVO; |
8 | 7 |
import com.takensoft.common.exception.FilterExceptionHandler; |
9 | 8 |
import com.takensoft.common.util.LoginUtil; |
... | ... | @@ -34,8 +33,9 @@ |
34 | 33 |
* 2024.04.01 | takensoft | 최초 등록 |
35 | 34 |
* 2025.05.28 | takensoft | 통합 로그인 적용 |
36 | 35 |
* 2025.05.29 | takensoft | OAuth2 통합 개선 |
36 |
+ * 2025.06.09 | takensoft | 기존 시스템 로그인 방식 복원 |
|
37 | 37 |
* |
38 |
- * 사용자 로그인 요청을 처리하는 Filter - 통합 로그인 시스템 적용 |
|
38 |
+ * 사용자 로그인 요청을 처리하는 Filter - 기존 방식 복원 |
|
39 | 39 |
*/ |
40 | 40 |
public class LoginFilter extends UsernamePasswordAuthenticationFilter { |
41 | 41 |
|
... | ... | @@ -43,15 +43,13 @@ |
43 | 43 |
private final EmailServiceImpl emailServiceImpl; |
44 | 44 |
private final LoginUtil loginUtil; |
45 | 45 |
private final Email2ndAuthService email2ndAuth; |
46 |
- private final UnifiedLoginService unifiedLoginService; |
|
47 | 46 |
|
48 | 47 |
public LoginFilter(AuthenticationManager authenticationManager, EmailServiceImpl emailServiceImpl, |
49 |
- LoginUtil loginUtil, Email2ndAuthService email2ndAuth, UnifiedLoginService unifiedLoginService) { |
|
48 |
+ LoginUtil loginUtil, Email2ndAuthService email2ndAuth) { |
|
50 | 49 |
this.authenticationManager = authenticationManager; |
51 | 50 |
this.emailServiceImpl = emailServiceImpl; |
52 | 51 |
this.loginUtil = loginUtil; |
53 | 52 |
this.email2ndAuth = email2ndAuth; |
54 |
- this.unifiedLoginService = unifiedLoginService; |
|
55 | 53 |
|
56 | 54 |
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/mbr/loginProc.json","POST")); |
57 | 55 |
} |
... | ... | @@ -67,15 +65,7 @@ |
67 | 65 |
req.setAttribute("lgnReqPage", login.getLgnReqPage()); |
68 | 66 |
req.setAttribute("loginMode", "S"); // 시스템 로그인 표시 |
69 | 67 |
|
70 |
- // 통합 로그인 시스템을 통한 사용자 검증 |
|
71 |
- try { |
|
72 |
- MberVO authenticatedUser = unifiedLoginService.authenticateUser("S", lgnId, pswd); |
|
73 |
- req.setAttribute("authenticatedUser", authenticatedUser); |
|
74 |
- } catch (Exception e) { |
|
75 |
- // 기존 Spring Security 방식으로 폴백 |
|
76 |
- } |
|
77 |
- |
|
78 |
- // 스프링 시큐리티 인증 토큰 생성 |
|
68 |
+ // 기존 Spring Security 방식으로 인증 (MberServiceImpl.loadUserByUsername 사용) |
|
79 | 69 |
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(lgnId, pswd, null); |
80 | 70 |
|
81 | 71 |
return authenticationManager.authenticate(authToken); |
... | ... | @@ -85,7 +75,6 @@ |
85 | 75 |
@Override |
86 | 76 |
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication authentication) throws IOException { |
87 | 77 |
Map<String, Object> result = new HashMap<>(); |
88 |
- |
|
89 | 78 |
MberVO mber = (MberVO) authentication.getPrincipal(); |
90 | 79 |
boolean isAdmin = mber.getAuthorities().stream().anyMatch(role -> role.getAuthority().equals("ROLE_ADMIN")); |
91 | 80 |
String lgnReqPage = (String) req.getAttribute("lgnReqPage"); |
--- src/main/java/com/takensoft/common/message/MessageCode.java
+++ src/main/java/com/takensoft/common/message/MessageCode.java
... | ... | @@ -74,7 +74,14 @@ |
74 | 74 |
EMAIL_VERIFY_SUCCESS("email.verify_success", HttpStatus.OK), // 이메일 인증 성공 |
75 | 75 |
EMAIL_VERIFY_EXPIRED("email.verify_expired", HttpStatus.UNAUTHORIZED), // 이메일 인증 만료 |
76 | 76 |
EMAIL_VERIFY_FAIL("email.verify_fail", HttpStatus.UNAUTHORIZED), // 이메일 인증 실패 |
77 |
- CODE_NOT_MATCH("email.code_not_match", HttpStatus.UNAUTHORIZED); // 인증 코드 불일치 |
|
77 |
+ CODE_NOT_MATCH("email.code_not_match", HttpStatus.UNAUTHORIZED), // 인증 코드 불일치 |
|
78 |
+ |
|
79 |
+ //소셜 로그인 관련 |
|
80 |
+ OAUTH2_LOGIN_ERROR("oauth2.login_error", HttpStatus.INTERNAL_SERVER_ERROR), //소셜 로그인 실패 |
|
81 |
+ OAUTH2_ACCESS_DENIED("oauth2.access_denied", HttpStatus.FORBIDDEN), // 사용자 인증 취소 |
|
82 |
+ OAUTH2_INVALID_REQUEST("oauth2.invalid_request", HttpStatus.BAD_REQUEST), // 잘못된 소셜 로그인 요청 |
|
83 |
+ OAUTH2_UNAUTHORIZED_CLIENT("oauth2.unauthorized_client", HttpStatus.UNAUTHORIZED), // 인증 되지 않은 클라이언트 |
|
84 |
+ OAUTH2_SERVER_ERROR("oauth2.server_error", HttpStatus.INTERNAL_SERVER_ERROR); // 소셜로그인 서버 오류 |
|
78 | 85 |
|
79 | 86 |
private final String messageKey; // 메시지 |
80 | 87 |
private final HttpStatus status; // HTTP 상태 |
--- src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationFailureHandler.java
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationFailureHandler.java
... | ... | @@ -32,8 +32,7 @@ |
32 | 32 |
private String FRONT_URL; |
33 | 33 |
|
34 | 34 |
@Override |
35 |
- public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, |
|
36 |
- AuthenticationException exception) throws IOException, ServletException { |
|
35 |
+ public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException{ |
|
37 | 36 |
|
38 | 37 |
String errorMessage = mapErrorMessage(exception); |
39 | 38 |
String encodedMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); |
--- src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
... | ... | @@ -15,11 +15,14 @@ |
15 | 15 |
import lombok.extern.slf4j.Slf4j; |
16 | 16 |
import org.springframework.beans.factory.annotation.Value; |
17 | 17 |
import org.springframework.security.core.Authentication; |
18 |
+import org.springframework.security.oauth2.core.oidc.user.OidcUser; |
|
19 |
+import org.springframework.security.oauth2.core.user.OAuth2User; |
|
18 | 20 |
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; |
19 | 21 |
import org.springframework.stereotype.Component; |
20 | 22 |
|
21 | 23 |
import java.io.IOException; |
22 | 24 |
import java.net.URLEncoder; |
25 |
+import java.util.Map; |
|
23 | 26 |
|
24 | 27 |
/** |
25 | 28 |
* @author takensoft |
... | ... | @@ -30,8 +33,9 @@ |
30 | 33 |
* 2025.05.28 | takensoft | 통합 로그인 적용 |
31 | 34 |
* 2025.05.29 | takensoft | OAuth2 통합 문제 해결 |
32 | 35 |
* 2025.06.02 | takensoft | 세션 모드 중복로그인 처리 개선 |
36 |
+ * 2025.06.09 | takensoft | OIDC 타입 캐스팅 문제 해결 |
|
33 | 37 |
* |
34 |
- * OAuth2 로그인 성공 핸들러 - 세션 모드 중복로그인 처리 개선 |
|
38 |
+ * OAuth2 로그인 성공 핸들러 |
|
35 | 39 |
*/ |
36 | 40 |
@Slf4j |
37 | 41 |
@Component |
... | ... | @@ -48,25 +52,30 @@ |
48 | 52 |
private String frontUrl; |
49 | 53 |
|
50 | 54 |
@Override |
51 |
- public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { |
|
52 |
- |
|
53 |
- CustomOAuth2UserVO oAuth2User = (CustomOAuth2UserVO) authentication.getPrincipal(); |
|
54 |
- |
|
55 |
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { |
|
55 | 56 |
try { |
57 |
+ // OAuth2User 타입 확인 및 정보 추출 |
|
58 |
+ OAuth2UserInfo userInfo = extractUserInfo(authentication); |
|
59 |
+ |
|
60 |
+ if (userInfo == null) { |
|
61 |
+ handleOAuth2Error(response, new Exception("사용자 정보 추출 실패")); |
|
62 |
+ return; |
|
63 |
+ } |
|
64 |
+ |
|
56 | 65 |
// 현재 설정된 로그인 모드 확인 |
57 | 66 |
String currentLoginMode = loginModeService.getLoginMode(); |
58 | 67 |
|
59 | 68 |
// 통합 로그인 서비스를 통한 OAuth2 사용자 처리 |
60 | 69 |
MberVO mber = unifiedLoginService.processOAuth2User( |
61 |
- oAuth2User.getEmail(), |
|
62 |
- convertProviderToMbrType(oAuth2User.getProvider()), |
|
63 |
- oAuth2User.getId(), |
|
64 |
- oAuth2User.getName(), |
|
70 |
+ userInfo.email, |
|
71 |
+ unifiedLoginService.convertProviderToMbrType(userInfo.provider), |
|
72 |
+ userInfo.id, |
|
73 |
+ userInfo.name, |
|
65 | 74 |
request |
66 | 75 |
); |
67 | 76 |
|
68 | 77 |
// OAuth2 로그인 이력 저장 |
69 |
- saveLoginHistory(request, mber, oAuth2User.getProvider()); |
|
78 |
+ saveLoginHistory(request, mber, userInfo.provider); |
|
70 | 79 |
|
71 | 80 |
request.setAttribute("loginType", "OAUTH2"); |
72 | 81 |
|
... | ... | @@ -74,7 +83,7 @@ |
74 | 83 |
loginUtil.successLogin(mber, request, response); |
75 | 84 |
|
76 | 85 |
// OAuth2 성공 후 프론트엔드로 리다이렉트 |
77 |
- String redirectUrl = String.format("%s/login.page?oauth_success=true&loginMode=%s",frontUrl, currentLoginMode); |
|
86 |
+ String redirectUrl = String.format("%s/login.page?oauth_success=true&loginMode=%s", frontUrl, currentLoginMode); |
|
78 | 87 |
getRedirectStrategy().sendRedirect(request, response, redirectUrl); |
79 | 88 |
|
80 | 89 |
} catch (Exception e) { |
... | ... | @@ -83,37 +92,137 @@ |
83 | 92 |
} |
84 | 93 |
|
85 | 94 |
/** |
95 |
+ * OAuth2User에서 사용자 정보 추출 (타입별 처리) |
|
96 |
+ */ |
|
97 |
+ private OAuth2UserInfo extractUserInfo(Authentication authentication) { |
|
98 |
+ Object principal = authentication.getPrincipal(); |
|
99 |
+ |
|
100 |
+ try { |
|
101 |
+ if (principal instanceof CustomOAuth2UserVO) { |
|
102 |
+ // 커스텀 OAuth2 사용자 |
|
103 |
+ CustomOAuth2UserVO customUser = (CustomOAuth2UserVO) principal; |
|
104 |
+ return new OAuth2UserInfo( |
|
105 |
+ customUser.getProvider(), |
|
106 |
+ customUser.getId(), |
|
107 |
+ customUser.getName(), |
|
108 |
+ customUser.getEmail() |
|
109 |
+ ); |
|
110 |
+ |
|
111 |
+ } else if (principal instanceof OidcUser) { |
|
112 |
+ // OIDC 사용자 (구글) |
|
113 |
+ OidcUser oidcUser = (OidcUser) principal; |
|
114 |
+ return extractOidcUserInfo(oidcUser, authentication); |
|
115 |
+ |
|
116 |
+ } else if (principal instanceof OAuth2User) { |
|
117 |
+ // 일반 OAuth2 사용자 |
|
118 |
+ OAuth2User oauth2User = (OAuth2User) principal; |
|
119 |
+ return extractOAuth2UserInfo(oauth2User, authentication); |
|
120 |
+ } else { |
|
121 |
+ return null; |
|
122 |
+ } |
|
123 |
+ } catch (Exception e) { |
|
124 |
+ return null; |
|
125 |
+ } |
|
126 |
+ } |
|
127 |
+ |
|
128 |
+ /** |
|
129 |
+ * OIDC 사용자 정보 추출 (구글) |
|
130 |
+ */ |
|
131 |
+ private OAuth2UserInfo extractOidcUserInfo(OidcUser oidcUser, Authentication authentication) { |
|
132 |
+ try { |
|
133 |
+ String provider = determineProvider(authentication); |
|
134 |
+ Map<String, Object> attributes = oidcUser.getAttributes(); |
|
135 |
+ |
|
136 |
+ return new OAuth2UserInfo( |
|
137 |
+ provider, |
|
138 |
+ oidcUser.getSubject(), // OIDC의 subject가 사용자 ID |
|
139 |
+ oidcUser.getFullName() != null ? oidcUser.getFullName() : oidcUser.getGivenName(), |
|
140 |
+ oidcUser.getEmail() |
|
141 |
+ ); |
|
142 |
+ } catch (Exception e) { |
|
143 |
+ return null; |
|
144 |
+ } |
|
145 |
+ } |
|
146 |
+ |
|
147 |
+ /** |
|
148 |
+ * 일반 OAuth2 사용자 정보 추출 |
|
149 |
+ */ |
|
150 |
+ private OAuth2UserInfo extractOAuth2UserInfo(OAuth2User oauth2User, Authentication authentication) { |
|
151 |
+ try { |
|
152 |
+ String provider = determineProvider(authentication); |
|
153 |
+ Map<String, Object> attributes = oauth2User.getAttributes(); |
|
154 |
+ |
|
155 |
+ String id = null; |
|
156 |
+ String name = null; |
|
157 |
+ String email = null; |
|
158 |
+ |
|
159 |
+ // 제공자별 정보 추출 |
|
160 |
+ switch (provider.toLowerCase()) { |
|
161 |
+ case "kakao": |
|
162 |
+ id = String.valueOf(attributes.get("id")); |
|
163 |
+ Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); |
|
164 |
+ if (kakaoAccount != null) { |
|
165 |
+ email = (String) kakaoAccount.get("email"); |
|
166 |
+ Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile"); |
|
167 |
+ if (profile != null) { |
|
168 |
+ name = (String) profile.get("nickname"); |
|
169 |
+ } |
|
170 |
+ } |
|
171 |
+ break; |
|
172 |
+ |
|
173 |
+ case "naver": |
|
174 |
+ Map<String, Object> naverResponse = (Map<String, Object>) attributes.get("response"); |
|
175 |
+ if (naverResponse != null) { |
|
176 |
+ id = (String) naverResponse.get("id"); |
|
177 |
+ name = (String) naverResponse.get("name"); |
|
178 |
+ email = (String) naverResponse.get("email"); |
|
179 |
+ } |
|
180 |
+ break; |
|
181 |
+ |
|
182 |
+ case "google": |
|
183 |
+ id = (String) attributes.get("sub"); |
|
184 |
+ name = (String) attributes.get("name"); |
|
185 |
+ email = (String) attributes.get("email"); |
|
186 |
+ break; |
|
187 |
+ } |
|
188 |
+ |
|
189 |
+ return new OAuth2UserInfo(provider, id, name, email); |
|
190 |
+ } catch (Exception e) { |
|
191 |
+ return null; |
|
192 |
+ } |
|
193 |
+ } |
|
194 |
+ |
|
195 |
+ /** |
|
196 |
+ * 제공자 결정 |
|
197 |
+ */ |
|
198 |
+ private String determineProvider(Authentication authentication) { |
|
199 |
+ // 클라이언트 등록 ID에서 제공자 결정 |
|
200 |
+ String name = authentication.getName(); |
|
201 |
+ if (name != null) { |
|
202 |
+ if (name.contains("google")) return "google"; |
|
203 |
+ if (name.contains("kakao")) return "kakao"; |
|
204 |
+ if (name.contains("naver")) return "naver"; |
|
205 |
+ } |
|
206 |
+ |
|
207 |
+ // 기본값 |
|
208 |
+ return "google"; |
|
209 |
+ } |
|
210 |
+ |
|
211 |
+ /** |
|
86 | 212 |
* 로그인 이력 저장 - OAuth2 전용 |
87 | 213 |
*/ |
88 | 214 |
private void saveLoginHistory(HttpServletRequest request, MberVO mber, String provider) { |
89 |
- try { |
|
90 | 215 |
String userAgent = httpRequestUtil.getUserAgent(request); |
91 | 216 |
|
92 | 217 |
LgnHstryVO loginHistory = new LgnHstryVO(); |
93 | 218 |
loginHistory.setLgnId(mber.getLgnId()); |
94 |
- loginHistory.setLgnType(mber.getAuthorities().stream() |
|
95 |
- .anyMatch(r -> r.getAuthority().equals("ROLE_ADMIN")) ? "0" : "1"); |
|
219 |
+ loginHistory.setLgnType(mber.getAuthorities().stream().anyMatch(r -> r.getAuthority().equals("ROLE_ADMIN")) ? "0" : "1"); |
|
96 | 220 |
loginHistory.setCntnIp(httpRequestUtil.getIp(request)); |
97 | 221 |
loginHistory.setCntnOperSys(httpRequestUtil.getOS(userAgent)); |
98 | 222 |
loginHistory.setDeviceNm(httpRequestUtil.getDevice(userAgent)); |
99 | 223 |
loginHistory.setBrwsrNm(httpRequestUtil.getBrowser(userAgent)); |
100 | 224 |
|
101 | 225 |
lgnHstryService.LgnHstrySave(loginHistory); |
102 |
- } catch (Exception e) { |
|
103 |
- } |
|
104 |
- } |
|
105 |
- |
|
106 |
- /** |
|
107 |
- * 제공자명을 회원타입으로 변환 |
|
108 |
- */ |
|
109 |
- private String convertProviderToMbrType(String provider) { |
|
110 |
- return switch (provider.toLowerCase()) { |
|
111 |
- case "kakao" -> "K"; |
|
112 |
- case "naver" -> "N"; |
|
113 |
- case "google" -> "G"; |
|
114 |
- case "facebook" -> "F"; |
|
115 |
- default -> "S"; |
|
116 |
- }; |
|
117 | 226 |
} |
118 | 227 |
|
119 | 228 |
/** |
... | ... | @@ -124,4 +233,21 @@ |
124 | 233 |
String errorUrl = String.format("%s/login.page?error=oauth2_failed&message=%s", frontUrl, message); |
125 | 234 |
getRedirectStrategy().sendRedirect(null, response, errorUrl); |
126 | 235 |
} |
236 |
+ |
|
237 |
+ /** |
|
238 |
+ * OAuth2 사용자 정보 내부 클래스 |
|
239 |
+ */ |
|
240 |
+ private static class OAuth2UserInfo { |
|
241 |
+ final String provider; |
|
242 |
+ final String id; |
|
243 |
+ final String name; |
|
244 |
+ final String email; |
|
245 |
+ |
|
246 |
+ OAuth2UserInfo(String provider, String id, String name, String email) { |
|
247 |
+ this.provider = provider; |
|
248 |
+ this.id = id; |
|
249 |
+ this.name = name; |
|
250 |
+ this.email = email; |
|
251 |
+ } |
|
252 |
+ } |
|
127 | 253 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/common/oauth/service/Impl/CustomOAuth2UserServiceImpl.java
+++ src/main/java/com/takensoft/common/oauth/service/Impl/CustomOAuth2UserServiceImpl.java
... | ... | @@ -72,7 +72,6 @@ |
72 | 72 |
OAuth2UserInfoVO oAuth2UserInfo = new OAuth2UserInfoVO(registrationId, oAuth2User.getAttributes()); |
73 | 73 |
|
74 | 74 |
// DB 작업 없이 단순히 CustomOAuth2User 객체만 반환 |
75 |
- // 실제 사용자 저장/업데이트는 OAuth2SuccessHandler에서 처리 |
|
76 | 75 |
return new CustomOAuth2UserVO(oAuth2UserInfo, oAuth2User.getAttributes()); |
77 | 76 |
} catch (DataAccessException dae) { |
78 | 77 |
throw dae; |
--- src/main/java/com/takensoft/common/oauth/vo/OAuth2UserInfoVO.java
+++ src/main/java/com/takensoft/common/oauth/vo/OAuth2UserInfoVO.java
... | ... | @@ -4,6 +4,7 @@ |
4 | 4 |
import lombok.Getter; |
5 | 5 |
import lombok.NoArgsConstructor; |
6 | 6 |
import lombok.Setter; |
7 |
+import lombok.extern.slf4j.Slf4j; |
|
7 | 8 |
|
8 | 9 |
import java.util.Map; |
9 | 10 |
|
... | ... | @@ -48,28 +49,101 @@ |
48 | 49 |
} |
49 | 50 |
|
50 | 51 |
private void setKakaoUserInfo(Map<String, Object> attributes) { |
51 |
- Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); |
|
52 |
- Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile"); |
|
52 |
+ try { |
|
53 |
+ Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); |
|
54 |
+ if (kakaoAccount == null) { |
|
55 |
+ throw new IllegalArgumentException("kakao_account 정보가 없습니다."); |
|
56 |
+ } |
|
53 | 57 |
|
54 |
- this.id = String.valueOf(attributes.get("id")); |
|
55 |
- this.name = (String) profile.get("nickname"); |
|
56 |
- this.email = (String) kakaoAccount.get("email"); |
|
57 |
- this.imageUrl = (String) profile.get("profile_image_url"); |
|
58 |
+ Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile"); |
|
59 |
+ if (profile == null) { |
|
60 |
+ throw new IllegalArgumentException("profile 정보가 없습니다."); |
|
61 |
+ } |
|
62 |
+ |
|
63 |
+ this.id = String.valueOf(attributes.get("id")); |
|
64 |
+ this.name = (String) profile.get("nickname"); |
|
65 |
+ this.email = (String) kakaoAccount.get("email"); |
|
66 |
+ this.imageUrl = (String) profile.get("profile_image_url"); |
|
67 |
+ |
|
68 |
+ // 필수 정보 검증 |
|
69 |
+ validateRequiredFields("kakao"); |
|
70 |
+ } catch (Exception e) { |
|
71 |
+ throw new RuntimeException("카카오 로그인 정보 처리 실패", e); |
|
72 |
+ } |
|
58 | 73 |
} |
59 | 74 |
|
60 | 75 |
private void setNaverUserInfo(Map<String, Object> attributes) { |
61 |
- Map<String, Object> response = (Map<String, Object>) attributes.get("response"); |
|
76 |
+ try { |
|
77 |
+ Map<String, Object> response = (Map<String, Object>) attributes.get("response"); |
|
78 |
+ if (response == null) { |
|
79 |
+ throw new IllegalArgumentException("response 정보가 없습니다."); |
|
80 |
+ } |
|
62 | 81 |
|
63 |
- this.id = (String) response.get("id"); |
|
64 |
- this.name = (String) response.get("name"); |
|
65 |
- this.email = (String) response.get("email"); |
|
66 |
- this.imageUrl = (String) response.get("profile_image"); |
|
82 |
+ this.id = (String) response.get("id"); |
|
83 |
+ this.name = (String) response.get("name"); |
|
84 |
+ this.email = (String) response.get("email"); |
|
85 |
+ this.imageUrl = (String) response.get("profile_image"); |
|
86 |
+ |
|
87 |
+ // 필수 정보 검증 |
|
88 |
+ validateRequiredFields("naver"); |
|
89 |
+ } catch (Exception e) { |
|
90 |
+ throw new RuntimeException("네이버 로그인 정보 처리 실패", e); |
|
91 |
+ } |
|
67 | 92 |
} |
68 | 93 |
|
69 | 94 |
private void setGoogleUserInfo(Map<String, Object> attributes) { |
70 |
- this.id = (String) attributes.get("sub"); |
|
71 |
- this.name = (String) attributes.get("name"); |
|
72 |
- this.email = (String) attributes.get("email"); |
|
73 |
- this.imageUrl = (String) attributes.get("picture"); |
|
95 |
+ try { |
|
96 |
+ // 구글은 직접 attributes에서 정보 추출 |
|
97 |
+ this.id = (String) attributes.get("sub"); |
|
98 |
+ this.name = (String) attributes.get("name"); |
|
99 |
+ this.email = (String) attributes.get("email"); |
|
100 |
+ this.imageUrl = (String) attributes.get("picture"); |
|
101 |
+ |
|
102 |
+ // 구글 특정 필드 검증 및 대안 처리 |
|
103 |
+ if (this.id == null) { |
|
104 |
+ this.id = (String) attributes.get("id"); // 대안 필드 |
|
105 |
+ } |
|
106 |
+ |
|
107 |
+ if (this.name == null) { |
|
108 |
+ // given_name과 family_name 조합 |
|
109 |
+ String givenName = (String) attributes.get("given_name"); |
|
110 |
+ String familyName = (String) attributes.get("family_name"); |
|
111 |
+ if (givenName != null || familyName != null) { |
|
112 |
+ this.name = (givenName != null ? givenName : "") + |
|
113 |
+ (familyName != null ? " " + familyName : "").trim(); |
|
114 |
+ } |
|
115 |
+ } |
|
116 |
+ // 이메일 검증 상태 확인 |
|
117 |
+ Boolean emailVerified = (Boolean) attributes.get("email_verified"); |
|
118 |
+ // 필수 정보 검증 |
|
119 |
+ validateRequiredFields("google"); |
|
120 |
+ } catch (Exception e) { |
|
121 |
+ throw new RuntimeException("구글 로그인 정보 처리 실패", e); |
|
122 |
+ } |
|
123 |
+ } |
|
124 |
+ |
|
125 |
+ /** |
|
126 |
+ * 필수 필드 검증 |
|
127 |
+ */ |
|
128 |
+ private void validateRequiredFields(String provider) { |
|
129 |
+ if (this.id == null || this.id.trim().isEmpty()) { |
|
130 |
+ throw new IllegalArgumentException(provider + " 사용자 ID가 없습니다."); |
|
131 |
+ } |
|
132 |
+ if (this.email == null || this.email.trim().isEmpty()) { |
|
133 |
+ throw new IllegalArgumentException(provider + " 이메일 정보가 없습니다."); |
|
134 |
+ } |
|
135 |
+ if (this.name == null || this.name.trim().isEmpty()) { |
|
136 |
+ this.name = this.email.split("@")[0]; |
|
137 |
+ } |
|
138 |
+ } |
|
139 |
+ |
|
140 |
+ /** |
|
141 |
+ * 파싱 실패 시 기본값 설정 |
|
142 |
+ */ |
|
143 |
+ private void setDefaultValues() { |
|
144 |
+ if (this.id == null) this.id = "unknown_" + System.currentTimeMillis(); |
|
145 |
+ if (this.name == null) this.name = "Unknown User"; |
|
146 |
+ if (this.email == null) this.email = this.id + "@unknown.com"; |
|
147 |
+ if (this.imageUrl == null) this.imageUrl = ""; |
|
74 | 148 |
} |
75 | 149 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/common/util/LoginUtil.java
+++ src/main/java/com/takensoft/common/util/LoginUtil.java
... | ... | @@ -12,6 +12,7 @@ |
12 | 12 |
import jakarta.servlet.http.HttpServletResponse; |
13 | 13 |
import jakarta.servlet.http.HttpSession; |
14 | 14 |
import lombok.RequiredArgsConstructor; |
15 |
+import lombok.extern.slf4j.Slf4j; |
|
15 | 16 |
import org.springframework.beans.factory.annotation.Value; |
16 | 17 |
import org.springframework.data.redis.core.RedisTemplate; |
17 | 18 |
import org.springframework.http.HttpStatus; |
... | ... | @@ -22,6 +23,7 @@ |
22 | 23 |
import java.util.HashMap; |
23 | 24 |
import java.util.List; |
24 | 25 |
import java.util.Map; |
26 |
+import java.util.Set; |
|
25 | 27 |
import java.util.concurrent.TimeUnit; |
26 | 28 |
|
27 | 29 |
/** |
... | ... | @@ -33,11 +35,13 @@ |
33 | 35 |
* 2025.05.28 | takensoft | 통합 로그인 적용, 문제 해결 |
34 | 36 |
* 2025.05.29 | takensoft | Redis 통합 중복로그인 관리 |
35 | 37 |
* 2025.06.04 | takensoft | Redis 트랜잭션 및 타이밍 이슈 해결 |
38 |
+ * 2025.06.09 | takensoft | 중복로그인 처리 로직 개선 |
|
36 | 39 |
* |
37 |
- * 통합 로그인 유틸리티 |
|
40 |
+ * 통합 로그인 유틸리티 - 중복로그인 처리 개선 |
|
38 | 41 |
*/ |
39 | 42 |
@Component |
40 | 43 |
@RequiredArgsConstructor |
44 |
+@Slf4j |
|
41 | 45 |
public class LoginUtil { |
42 | 46 |
private final LgnHstryService lgnHstryService; |
43 | 47 |
private final HttpRequestUtil httpRequestUtil; |
... | ... | @@ -58,94 +62,82 @@ |
58 | 62 |
/** |
59 | 63 |
* 통합 로그인 성공 처리 |
60 | 64 |
*/ |
61 |
- public void successLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) { |
|
62 |
- // 로그인 방식 확인 |
|
65 |
+ public void successLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) throws IOException { |
|
63 | 66 |
String loginMode = loginModeService.getLoginMode(); |
64 |
- |
|
65 | 67 |
res.setHeader("loginMode", loginMode); |
66 |
- try { |
|
67 | 68 |
// 로그인 이력 등록 |
68 | 69 |
String loginType = (String) req.getAttribute("loginType"); |
69 | 70 |
if (!"OAUTH2".equals(loginType)) { |
70 | 71 |
saveLoginHistory(mber, req); |
71 | 72 |
} |
73 |
+ |
|
72 | 74 |
if ("S".equals(loginMode)) { |
73 |
- // Redis 기반 중복로그인 관리 적용 |
|
74 | 75 |
handleSessionLogin(mber, req, res); |
75 | 76 |
} else { |
76 |
- // 기존 Redis 기반 관리 유지 |
|
77 | 77 |
handleJwtLogin(mber, req, res); |
78 | 78 |
} |
79 |
- } |
|
80 |
- catch (IOException ioe) { |
|
81 |
- throw new RuntimeException(ioe); |
|
82 |
- } |
|
83 |
- catch (Exception e) { |
|
84 |
- throw e; |
|
85 |
- } |
|
86 | 79 |
} |
87 | 80 |
|
88 | 81 |
/** |
89 |
- * 세션 모드 로그인 처리 - Redis 트랜잭션 개선 |
|
82 |
+ * 세션 모드 로그인 처리 - 중복로그인 개선 |
|
90 | 83 |
*/ |
91 | 84 |
private void handleSessionLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) throws IOException { |
85 |
+ String mbrId = mber.getMbrId(); |
|
86 |
+ |
|
87 |
+ // 중복로그인 비허용 시 기존 세션 정리 |
|
88 |
+ if (!loginPolicyService.getPolicy()) { |
|
89 |
+ forceLogoutExistingSession(mbrId); |
|
90 |
+ } |
|
92 | 91 |
|
93 | 92 |
// JWT 토큰은 생성하되 세션에만 저장 |
94 |
- String accessToken = jwtUtil.createJwt("Authorization", |
|
95 |
- mber.getMbrId(), mber.getLgnId(), mber.getMbrNm(), |
|
96 |
- (List) mber.getAuthorities(), JWT_ACCESSTIME); |
|
93 |
+ String accessToken = jwtUtil.createJwt("Authorization", mbrId, mber.getLgnId(), |
|
94 |
+ mber.getMbrNm(), (List) mber.getAuthorities(), JWT_ACCESSTIME); |
|
97 | 95 |
|
98 | 96 |
// 세션 생성 및 정보 저장 |
99 | 97 |
HttpSession session = req.getSession(true); |
100 | 98 |
session.setAttribute("JWT_TOKEN", accessToken); |
101 |
- session.setAttribute("mbrId", mber.getMbrId()); |
|
99 |
+ session.setAttribute("mbrId", mbrId); |
|
102 | 100 |
session.setAttribute("mbrNm", mber.getMbrNm()); |
103 | 101 |
session.setAttribute("lgnId", mber.getLgnId()); |
104 | 102 |
session.setAttribute("roles", mber.getAuthorList()); |
105 |
- session.setAttribute("loginType", req.getAttribute("loginType") != null ? |
|
106 |
- req.getAttribute("loginType") : "S"); |
|
103 |
+ session.setAttribute("loginType", req.getAttribute("loginType") != null ? req.getAttribute("loginType") : "S"); |
|
107 | 104 |
|
108 |
- // 토큰만 저장 |
|
105 |
+ // Redis에 세션 토큰 저장 (중복로그인 관리용) |
|
109 | 106 |
if (!loginPolicyService.getPolicy()) { |
110 |
- String tokenKey = "session_token:" + mber.getMbrId(); |
|
111 |
- // 기존 토큰 삭제 후 새 토큰 저장 |
|
112 |
- redisTemplate.delete(tokenKey); |
|
113 |
- redisTemplate.opsForValue().set(tokenKey, accessToken, Duration.ofSeconds(session.getMaxInactiveInterval())); |
|
107 |
+ saveSessionTokenToRedis(mbrId, accessToken, session.getMaxInactiveInterval()); |
|
114 | 108 |
} |
109 |
+ |
|
110 |
+ // SessionUtil에 등록 |
|
111 |
+ sessionUtil.registerSession(mbrId, session); |
|
115 | 112 |
|
116 | 113 |
// OAuth2가 아닌 경우 JSON 응답 전송 |
117 | 114 |
String loginType = (String) req.getAttribute("loginType"); |
118 | 115 |
if (!"OAUTH2".equals(loginType)) { |
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()); |
|
124 |
- |
|
125 |
- res.setContentType("application/json;charset=UTF-8"); |
|
126 |
- res.setCharacterEncoding("UTF-8"); |
|
127 |
- res.setStatus(HttpStatus.OK.value()); |
|
128 |
- |
|
129 |
- String jsonResponse = new ObjectMapper().writeValueAsString(result); |
|
130 |
- res.getWriter().write(jsonResponse); |
|
131 |
- res.getWriter().flush(); |
|
132 |
- } catch (Exception e) { |
|
133 |
- throw e; |
|
134 |
- } |
|
116 |
+ sendSessionLoginResponse(res, mber); |
|
135 | 117 |
} |
118 |
+ |
|
136 | 119 |
} |
137 | 120 |
|
138 | 121 |
/** |
139 |
- * JWT 모드 로그인 처리 |
|
122 |
+ * JWT 모드 로그인 처리 - 중복로그인 개선 |
|
140 | 123 |
*/ |
141 | 124 |
private void handleJwtLogin(MberVO mber, HttpServletRequest req, HttpServletResponse res) throws IOException { |
125 |
+ String mbrId = mber.getMbrId(); |
|
126 |
+ |
|
127 |
+ // 중복로그인 비허용 시 기존 토큰 정리 |
|
128 |
+ if (!loginPolicyService.getPolicy()) { |
|
129 |
+ forceLogoutExistingJWT(mbrId); |
|
130 |
+ } |
|
131 |
+ |
|
142 | 132 |
// JWT 토큰 생성 |
143 |
- String accessToken = jwtUtil.createJwt("Authorization", mber.getMbrId(), mber.getLgnId(), mber.getMbrNm(), (List) mber.getAuthorities(), JWT_ACCESSTIME); |
|
144 |
- String refreshToken = jwtUtil.createJwt("refresh", mber.getMbrId(), mber.getLgnId(), mber.getMbrNm(), (List) mber.getAuthorities(), JWT_REFRESHTIME); |
|
133 |
+ String accessToken = jwtUtil.createJwt("Authorization", mbrId, mber.getLgnId(), |
|
134 |
+ mber.getMbrNm(), (List) mber.getAuthorities(), JWT_ACCESSTIME); |
|
135 |
+ String refreshToken = jwtUtil.createJwt("refresh", mbrId, mber.getLgnId(), |
|
136 |
+ mber.getMbrNm(), (List) mber.getAuthorities(), JWT_REFRESHTIME); |
|
145 | 137 |
|
146 | 138 |
// Refresh 토큰 처리 |
147 | 139 |
RefreshTknVO refresh = new RefreshTknVO(); |
148 |
- refresh.setMbrId(mber.getMbrId()); |
|
140 |
+ refresh.setMbrId(mbrId); |
|
149 | 141 |
|
150 | 142 |
// 기존 refresh 토큰 삭제 |
151 | 143 |
if (refreshTokenService.findByCheckRefresh(req, refresh)) { |
... | ... | @@ -157,10 +149,9 @@ |
157 | 149 |
res.setHeader("Authorization", accessToken); |
158 | 150 |
res.addCookie(jwtUtil.createCookie("refresh", refreshToken, COOKIE_TIME)); |
159 | 151 |
|
160 |
- // 중복 로그인 비허용일 때 Redis 저장 |
|
152 |
+ // Redis에 JWT 저장 (중복로그인 관리용) |
|
161 | 153 |
if (!loginPolicyService.getPolicy()) { |
162 |
- redisTemplate.delete("jwt:" + mber.getMbrId()); |
|
163 |
- redisTemplate.opsForValue().set("jwt:" + mber.getMbrId(), accessToken, JWT_ACCESSTIME, TimeUnit.MILLISECONDS); |
|
154 |
+ saveJWTTokenToRedis(mbrId, accessToken); |
|
164 | 155 |
} |
165 | 156 |
|
166 | 157 |
// Refresh 토큰 저장 |
... | ... | @@ -171,13 +162,87 @@ |
171 | 162 |
if (!"OAUTH2".equals(loginType)) { |
172 | 163 |
res.setStatus(HttpStatus.OK.value()); |
173 | 164 |
} |
165 |
+ |
|
166 |
+ } |
|
167 |
+ |
|
168 |
+ /** |
|
169 |
+ * 기존 세션 강제 로그아웃 |
|
170 |
+ */ |
|
171 |
+ private void forceLogoutExistingSession(String mbrId) { |
|
172 |
+ // 1. SessionUtil에서 기존 세션 제거 |
|
173 |
+ sessionUtil.removeSession(mbrId); |
|
174 |
+ |
|
175 |
+ // 2. Redis에서 기존 세션 토큰 삭제 |
|
176 |
+ String sessionTokenKey = "session_token:" + mbrId; |
|
177 |
+ String existingToken = redisTemplate.opsForValue().get(sessionTokenKey); |
|
178 |
+ |
|
179 |
+ if (existingToken != null) { |
|
180 |
+ redisTemplate.delete(sessionTokenKey); |
|
181 |
+ } |
|
182 |
+ // 3. 기타 세션 관련 키 정리 |
|
183 |
+ cleanupSessionRedisKeys(mbrId); |
|
184 |
+ } |
|
185 |
+ |
|
186 |
+ /** |
|
187 |
+ * 기존 JWT 강제 로그아웃 |
|
188 |
+ */ |
|
189 |
+ private void forceLogoutExistingJWT(String mbrId) { |
|
190 |
+ String jwtKey = "jwt:" + mbrId; |
|
191 |
+ String existingToken = redisTemplate.opsForValue().get(jwtKey); |
|
192 |
+ |
|
193 |
+ if (existingToken != null) { |
|
194 |
+ redisTemplate.delete(jwtKey); |
|
195 |
+ } |
|
196 |
+ } |
|
197 |
+ |
|
198 |
+ /** |
|
199 |
+ * Redis에 세션 토큰 저장 |
|
200 |
+ */ |
|
201 |
+ private void saveSessionTokenToRedis(String mbrId, String accessToken, int sessionTimeout) { |
|
202 |
+ String tokenKey = "session_token:" + mbrId; |
|
203 |
+ redisTemplate.opsForValue().set(tokenKey, accessToken, Duration.ofSeconds(sessionTimeout)); |
|
204 |
+ } |
|
205 |
+ |
|
206 |
+ /** |
|
207 |
+ * Redis에 JWT 토큰 저장 |
|
208 |
+ */ |
|
209 |
+ private void saveJWTTokenToRedis(String mbrId, String accessToken) { |
|
210 |
+ String jwtKey = "jwt:" + mbrId; |
|
211 |
+ redisTemplate.opsForValue().set(jwtKey, accessToken, JWT_ACCESSTIME, TimeUnit.MILLISECONDS); |
|
212 |
+ } |
|
213 |
+ |
|
214 |
+ /** |
|
215 |
+ * 세션 관련 Redis 키 정리 |
|
216 |
+ */ |
|
217 |
+ private void cleanupSessionRedisKeys(String mbrId) { |
|
218 |
+ Set<String> sessionKeys = redisTemplate.keys("session*:" + mbrId); |
|
219 |
+ if (sessionKeys != null && !sessionKeys.isEmpty()) { |
|
220 |
+ redisTemplate.delete(sessionKeys); |
|
221 |
+ } |
|
222 |
+ } |
|
223 |
+ |
|
224 |
+ /** |
|
225 |
+ * 세션 로그인 응답 전송 |
|
226 |
+ */ |
|
227 |
+ private void sendSessionLoginResponse(HttpServletResponse res, MberVO mber) throws IOException { |
|
228 |
+ Map<String, Object> result = new HashMap<>(); |
|
229 |
+ result.put("mbrId", mber.getMbrId()); |
|
230 |
+ result.put("mbrNm", mber.getMbrNm()); |
|
231 |
+ result.put("roles", mber.getAuthorList()); |
|
232 |
+ |
|
233 |
+ res.setContentType("application/json;charset=UTF-8"); |
|
234 |
+ res.setCharacterEncoding("UTF-8"); |
|
235 |
+ res.setStatus(HttpStatus.OK.value()); |
|
236 |
+ |
|
237 |
+ String jsonResponse = new ObjectMapper().writeValueAsString(result); |
|
238 |
+ res.getWriter().write(jsonResponse); |
|
239 |
+ res.getWriter().flush(); |
|
174 | 240 |
} |
175 | 241 |
|
176 | 242 |
/** |
177 | 243 |
* 로그인 이력 저장 |
178 | 244 |
*/ |
179 | 245 |
private void saveLoginHistory(MberVO mber, HttpServletRequest req) { |
180 |
- try { |
|
181 | 246 |
String userAgent = httpRequestUtil.getUserAgent(req); |
182 | 247 |
|
183 | 248 |
LgnHstryVO lgnHstryVO = new LgnHstryVO(); |
... | ... | @@ -190,7 +255,5 @@ |
190 | 255 |
lgnHstryVO.setBrwsrNm(httpRequestUtil.getBrowser(userAgent)); |
191 | 256 |
|
192 | 257 |
lgnHstryService.LgnHstrySave(lgnHstryVO); |
193 |
- } catch (Exception e) { |
|
194 |
- } |
|
195 | 258 |
} |
196 | 259 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/java/com/takensoft/common/util/SessionUtil.java
+++ src/main/java/com/takensoft/common/util/SessionUtil.java
... | ... | @@ -7,6 +7,7 @@ |
7 | 7 |
|
8 | 8 |
import java.time.Duration; |
9 | 9 |
import java.util.Map; |
10 |
+import java.util.Set; |
|
10 | 11 |
import java.util.concurrent.ConcurrentHashMap; |
11 | 12 |
|
12 | 13 |
/** |
... | ... | @@ -16,6 +17,7 @@ |
16 | 17 |
* since | author | description |
17 | 18 |
* 2025.03.21 | takensoft | 최초 등록 |
18 | 19 |
* 2025.05.29 | takensoft | Redis 통합 중복로그인 관리 |
20 |
+ * 2025.06.09 | takensoft | 세션 관리 로직 개선, 안정성 향상 |
|
19 | 21 |
* |
20 | 22 |
* 세션 로그인 방식의 유틸리티 |
21 | 23 |
*/ |
... | ... | @@ -31,110 +33,136 @@ |
31 | 33 |
} |
32 | 34 |
|
33 | 35 |
/** |
34 |
- * 세션 등록 - Redis 연동 |
|
35 |
- * 기존 세션 있으면 강제 로그아웃 후 새 세션 등록 |
|
36 |
+ * 세션 등록 - 중복로그인 처리 개선 |
|
36 | 37 |
*/ |
37 | 38 |
public synchronized void registerSession(String mbrId, HttpSession newSession) { |
38 |
- try { |
|
39 |
- // 1. 기존 메모리 세션 처리 |
|
40 |
- HttpSession oldSession = sessionMap.get(mbrId); |
|
41 |
- if (oldSession != null && oldSession != newSession) { |
|
42 |
- try { |
|
43 |
- oldSession.invalidate(); |
|
44 |
- } catch (IllegalStateException e) { |
|
45 | 39 |
|
46 |
- } |
|
40 |
+ // 1. 기존 세션 확인 및 무효화 |
|
41 |
+ HttpSession oldSession = sessionMap.get(mbrId); |
|
42 |
+ if (oldSession != null && !oldSession.getId().equals(newSession.getId())) { |
|
43 |
+ oldSession.invalidate(); |
|
47 | 44 |
} |
48 | 45 |
// 2. 새 세션을 메모리에 등록 |
49 | 46 |
sessionMap.put(mbrId, newSession); |
50 | 47 |
|
51 |
- // 3. Redis 동기화는 LoginUtil에서 처리됨 |
|
52 |
- |
|
53 |
- } catch (Exception e) { |
|
54 |
- } |
|
48 |
+ // 3. Redis에 세션 정보 저장 (세션 ID 저장) |
|
49 |
+ String sessionKey = "session:" + mbrId; |
|
50 |
+ redisTemplate.opsForValue().set(sessionKey, newSession.getId(), |
|
51 |
+ Duration.ofSeconds(newSession.getMaxInactiveInterval())); |
|
55 | 52 |
} |
56 | 53 |
|
57 | 54 |
/** |
58 | 55 |
* 세션 ID로 세션 무효화 |
59 | 56 |
*/ |
60 | 57 |
public void invalidateSessionById(String sessionId) { |
61 |
- try { |
|
62 |
- boolean found = false; |
|
63 | 58 |
// 메모리에서 해당 세션 ID를 가진 세션 찾아서 무효화 |
59 |
+ String targetMbrId = null; |
|
64 | 60 |
sessionMap.entrySet().removeIf(entry -> { |
65 | 61 |
HttpSession session = entry.getValue(); |
66 | 62 |
if (session != null && session.getId().equals(sessionId)) { |
67 |
- try { |
|
68 | 63 |
session.invalidate(); |
69 | 64 |
return true; |
70 |
- } catch (IllegalStateException e) { |
|
71 |
- return true; |
|
72 |
- } |
|
73 | 65 |
} |
74 | 66 |
return false; |
75 | 67 |
}); |
76 |
- |
|
77 |
- } catch (Exception e) { |
|
78 |
- e.printStackTrace(); |
|
79 |
- } |
|
80 | 68 |
} |
81 | 69 |
|
82 | 70 |
/** |
83 |
- * 사용자별 세션 제거 - Redis 연동 |
|
71 |
+ * 사용자별 세션 제거 - Redis 연동 개선 |
|
84 | 72 |
*/ |
85 | 73 |
public void removeSession(String mbrId) { |
86 |
- try { |
|
87 | 74 |
// 1. 메모리 세션 무효화 |
88 | 75 |
HttpSession session = sessionMap.get(mbrId); |
89 | 76 |
if (session != null) { |
90 |
- try { |
|
91 | 77 |
session.invalidate(); |
92 |
- } catch (IllegalStateException e) { |
|
93 |
- |
|
94 |
- } |
|
95 | 78 |
} |
96 | 79 |
sessionMap.remove(mbrId); |
97 | 80 |
|
98 |
- // 2. Redis에서도 제거 |
|
99 |
- String sessionKey = "session:" + mbrId; |
|
100 |
- String deletedSessionId = redisTemplate.opsForValue().get(sessionKey); |
|
101 |
- if (deletedSessionId != null) { |
|
102 |
- redisTemplate.delete(sessionKey); |
|
103 |
- } |
|
104 |
- |
|
105 |
- } catch (Exception e) { |
|
106 |
- } |
|
81 |
+ // 2. Redis에서 모든 관련 키 제거 |
|
82 |
+ cleanupRedisSessionData(mbrId); |
|
107 | 83 |
} |
108 | 84 |
|
109 | 85 |
/** |
110 |
- * 전체 세션 무효화 - Redis 연동 |
|
86 |
+ * 전체 세션 무효화 - Redis 연동 개선 |
|
111 | 87 |
*/ |
112 | 88 |
public void invalidateAllSessions() { |
113 |
- try { |
|
89 |
+ |
|
114 | 90 |
// 1. 모든 메모리 세션 무효화 |
91 |
+ int invalidatedCount = 0; |
|
115 | 92 |
for (Map.Entry<String, HttpSession> entry : sessionMap.entrySet()) { |
116 | 93 |
HttpSession session = entry.getValue(); |
117 | 94 |
if (session != null) { |
118 |
- try { |
|
119 | 95 |
session.invalidate(); |
120 |
- } catch (IllegalStateException e) { |
|
121 |
- e.printStackTrace(); |
|
122 |
- } |
|
96 |
+ invalidatedCount++; |
|
123 | 97 |
} |
124 | 98 |
} |
125 | 99 |
sessionMap.clear(); |
126 | 100 |
|
127 |
- // 2. Redis에서 모든 세션 키 삭제 |
|
128 |
- try { |
|
129 |
- var sessionKeys = redisTemplate.keys("session:*"); |
|
130 |
- if (sessionKeys != null && !sessionKeys.isEmpty()) { |
|
131 |
- redisTemplate.delete(sessionKeys); |
|
132 |
- } |
|
133 |
- } catch (Exception e) { |
|
134 |
- e.printStackTrace(); |
|
101 |
+ // 2. Redis에서 모든 세션 관련 키 삭제 |
|
102 |
+ cleanupAllRedisSessionData(); |
|
103 |
+ } |
|
104 |
+ |
|
105 |
+ /** |
|
106 |
+ * 특정 사용자의 Redis 세션 데이터 정리 |
|
107 |
+ */ |
|
108 |
+ private void cleanupRedisSessionData(String mbrId) { |
|
109 |
+ // 세션 관련 키들 삭제 |
|
110 |
+ String sessionKey = "session:" + mbrId; |
|
111 |
+ String sessionTokenKey = "session_token:" + mbrId; |
|
112 |
+ |
|
113 |
+ redisTemplate.delete(sessionKey); |
|
114 |
+ redisTemplate.delete(sessionTokenKey); |
|
115 |
+ |
|
116 |
+ // 패턴 매칭으로 사용자 관련 모든 세션 키 찾아서 삭제 |
|
117 |
+ Set<String> userSessionKeys = redisTemplate.keys("session*:" + mbrId); |
|
118 |
+ if (userSessionKeys != null && !userSessionKeys.isEmpty()) { |
|
119 |
+ redisTemplate.delete(userSessionKeys); |
|
135 | 120 |
} |
136 |
- } catch (Exception e) { |
|
137 |
- e.printStackTrace(); |
|
121 |
+ } |
|
122 |
+ |
|
123 |
+ /** |
|
124 |
+ * 모든 Redis 세션 데이터 정리 |
|
125 |
+ */ |
|
126 |
+ private void cleanupAllRedisSessionData() { |
|
127 |
+ // 모든 세션 관련 키 패턴들 |
|
128 |
+ String[] sessionKeyPatterns = { |
|
129 |
+ "session:*", |
|
130 |
+ "session_token:*" |
|
131 |
+ }; |
|
132 |
+ |
|
133 |
+ for (String pattern : sessionKeyPatterns) { |
|
134 |
+ Set<String> keys = redisTemplate.keys(pattern); |
|
135 |
+ if (keys != null && !keys.isEmpty()) { |
|
136 |
+ redisTemplate.delete(keys); |
|
137 |
+ } |
|
138 |
+ } |
|
139 |
+ } |
|
140 |
+ |
|
141 |
+ /** |
|
142 |
+ * 활성 세션 수 조회 |
|
143 |
+ */ |
|
144 |
+ public int getActiveSessionCount() { |
|
145 |
+ return sessionMap.size(); |
|
146 |
+ } |
|
147 |
+ |
|
148 |
+ /** |
|
149 |
+ * 특정 사용자의 세션 활성 상태 확인 |
|
150 |
+ */ |
|
151 |
+ public boolean isSessionActive(String mbrId) { |
|
152 |
+ HttpSession session = sessionMap.get(mbrId); |
|
153 |
+ if (session == null) { |
|
154 |
+ return false; |
|
155 |
+ } |
|
156 |
+ |
|
157 |
+ try { |
|
158 |
+ // 세션 접근을 통한 유효성 확인 |
|
159 |
+ session.getLastAccessedTime(); |
|
160 |
+ return true; |
|
161 |
+ } catch (IllegalStateException e) { |
|
162 |
+ // 무효화된 세션이면 맵에서 제거 |
|
163 |
+ sessionMap.remove(mbrId); |
|
164 |
+ return false; |
|
138 | 165 |
} |
139 | 166 |
} |
167 |
+ |
|
140 | 168 |
}(파일 끝에 줄바꿈 문자 없음) |
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -95,8 +95,12 @@ |
95 | 95 |
client-secret: ${GOOGLE_CLIENT_SECRET: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} |
96 | 96 |
redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:9090/login/oauth2/code/google} |
97 | 97 |
authorization-grant-type: authorization_code |
98 |
- scope: profile,email |
|
98 |
+ scope: |
|
99 |
+ - openid |
|
100 |
+ - profile |
|
101 |
|
|
99 | 102 |
client-name: Google |
103 |
+ client-authentication-method: client_secret_basic |
|
100 | 104 |
|
101 | 105 |
provider: |
102 | 106 |
# 카카오 제공업체 설정 |
... | ... | @@ -113,6 +117,16 @@ |
113 | 117 |
user-info-uri: https://openapi.naver.com/v1/nid/me |
114 | 118 |
user-name-attribute: response |
115 | 119 |
|
120 |
+ # 구글 제공업체 설정 |
|
121 |
+ google: |
|
122 |
+ authorization-uri: https://accounts.google.com/o/oauth2/v2/auth |
|
123 |
+ token-uri: https://www.googleapis.com/oauth2/v4/token |
|
124 |
+ user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo |
|
125 |
+ user-name-attribute: sub |
|
126 |
+ # OIDC 지원을 위한 설정 |
|
127 |
+ jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs |
|
128 |
+ issuer-uri: https://accounts.google.com |
|
129 |
+ |
|
116 | 130 |
|
117 | 131 |
# Mybatis settings |
118 | 132 |
#mybatis: |
--- src/main/resources/message/messages_en.yml
+++ src/main/resources/message/messages_en.yml
... | ... | @@ -66,4 +66,12 @@ |
66 | 66 |
verify_success: "Email verification completed successfully." |
67 | 67 |
verify_expired: "Email verification has expired." |
68 | 68 |
verify_fail: "Email verification failed." |
69 |
- code_not_match: "verification code does not match."(파일 끝에 줄바꿈 문자 없음) |
|
69 |
+ code_not_match: "verification code does not match." |
|
70 |
+ |
|
71 |
+# 소셜로그인 관련 |
|
72 |
+oauth2: |
|
73 |
+ login_error: "Social sign-in failed." |
|
74 |
+ access_denied: "User cancelled authentication." |
|
75 |
+ invalid_request: "Invalid social sign-in request." |
|
76 |
+ unauthorized_client: "Unauthorized client." |
|
77 |
+ server_error: "Social authentication server error occurred."(파일 끝에 줄바꿈 문자 없음) |
--- src/main/resources/message/messages_ko.yml
+++ src/main/resources/message/messages_ko.yml
... | ... | @@ -68,3 +68,11 @@ |
68 | 68 |
verify_expired: "인증 시간이 만료되었습니다." |
69 | 69 |
verify_fail: "이메일 인증에 실패했습니다." |
70 | 70 |
code_not_match: "인증 코드가 일치하지 않습니다." |
71 |
+ |
|
72 |
+# 소셜로그인 관련 |
|
73 |
+oauth2: |
|
74 |
+ login_error: "소셜 로그인에 실패했습니다." |
|
75 |
+ access_denied: "사용자가 인증을 취소했습니다." |
|
76 |
+ invalid_request: "잘못된 소셜 로그인 요청입니다." |
|
77 |
+ unauthorized_client: "인증 되지 않은 클라이언트입니다." |
|
78 |
+ server_error: "소셜 로그인 서버 오류가 발생했습니다." |
--- src/main/resources/mybatis/mapper/loginPolicy/loginMode-SQL.xml
+++ src/main/resources/mybatis/mapper/loginPolicy/loginMode-SQL.xml
... | ... | @@ -15,7 +15,7 @@ |
15 | 15 |
<select id="selectLatestLoginMode" resultType="String"> |
16 | 16 |
SELECT lgn_mode |
17 | 17 |
FROM lgn_mode_hstry |
18 |
- ORDER BY reg_dt DESC |
|
18 |
+ ORDER BY reg_dt ASC |
|
19 | 19 |
LIMIT 1 |
20 | 20 |
</select> |
21 | 21 |
|
--- src/main/resources/mybatis/mapper/loginPolicy/loginPolicy-SQL.xml
+++ src/main/resources/mybatis/mapper/loginPolicy/loginPolicy-SQL.xml
... | ... | @@ -15,7 +15,7 @@ |
15 | 15 |
<select id="selectLatestPolicy" resultType="String"> |
16 | 16 |
SELECT allow_multiple_login |
17 | 17 |
FROM lgn_policy_hstry |
18 |
- ORDER BY reg_dt DESC |
|
18 |
+ ORDER BY reg_dt ASC |
|
19 | 19 |
LIMIT 1 |
20 | 20 |
</select> |
21 | 21 |
|
--- src/main/resources/mybatis/mapper/mber/mber-SQL.xml
+++ src/main/resources/mybatis/mapper/mber/mber-SQL.xml
... | ... | @@ -122,11 +122,11 @@ |
122 | 122 |
WHERE EXISTS ( |
123 | 123 |
SELECT 1 FROM mbr_social_accounts msa |
124 | 124 |
WHERE msa.mbr_id = mi.mbr_id |
125 |
- <if test="providerType == 'S'"> |
|
125 |
+ <if test="providerType != null and 'S'.equals(providerType)"> |
|
126 | 126 |
AND msa.provider_type = 'S' |
127 | 127 |
AND msa.login_id = #{identifier} |
128 | 128 |
</if> |
129 |
- <if test="providerType != 'S'"> |
|
129 |
+ <if test="providerType != null and !'S'.equals(providerType)"> |
|
130 | 130 |
AND msa.provider_type = #{providerType} |
131 | 131 |
AND msa.social_id = #{identifier} |
132 | 132 |
</if> |
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?