You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
416 lines
18 KiB
Java
416 lines
18 KiB
Java
package egovframework.interceptor;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import egovframework.configProperties.InterceptorProperties;
|
|
import egovframework.configProperties.LoginProperties;
|
|
import egovframework.exception.AccessDeniedException;
|
|
import egovframework.filter.XssUtil;
|
|
import egovframework.util.ApiResponseEntity;
|
|
import egovframework.util.HttpServletUtil;
|
|
import egovframework.constant.SessionConstants;
|
|
import go.kr.project.domain.entity.CpSetinfoId;
|
|
import go.kr.project.domain.repo.cp.CpSetinfoRepository;
|
|
import go.kr.project.system.login.mapper.LoginMapper;
|
|
import go.kr.project.system.login.model.SessionVO;
|
|
import go.kr.project.system.login.model.UserSessionVO;
|
|
import go.kr.project.system.login.service.LoginService;
|
|
import go.kr.project.system.menu.model.MenuVO;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.checkerframework.checker.nullness.qual.Nullable;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.util.AntPathMatcher;
|
|
import org.springframework.web.servlet.HandlerInterceptor;
|
|
|
|
import javax.servlet.http.HttpServletRequest;
|
|
import javax.servlet.http.HttpServletResponse;
|
|
import javax.servlet.http.HttpSession;
|
|
import java.io.IOException;
|
|
import java.time.LocalDateTime;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* packageName : egovframework.interceptor
|
|
* fileName : AuthInterceptor
|
|
* author : 시스템 관리자
|
|
* date : 2025-05-13
|
|
* description : 접근 제어를 위한 인터셉터
|
|
* ===========================================================
|
|
* DATE AUTHOR NOTE
|
|
* -----------------------------------------------------------
|
|
* 2025-05-13 시스템 관리자 최초 생성
|
|
*/
|
|
@Slf4j
|
|
@RequiredArgsConstructor
|
|
public class AuthInterceptor implements HandlerInterceptor {
|
|
|
|
private final LoginService loginService;
|
|
|
|
// 세션에 업로드 경로를 담아놓기 위한 setinfo 인젝션
|
|
private final CpSetinfoRepository cpSetinfoRepository;
|
|
|
|
// URL 패턴 매칭을 위한 AntPathMatcher
|
|
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
|
|
|
// XSS 방지 유틸리티
|
|
private final XssUtil xssUtil = new XssUtil();
|
|
|
|
// JSON 변환을 위한 ObjectMapper
|
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
|
// Referer 검사에서 제외할 URL 패턴 목록 설정
|
|
@Autowired
|
|
private InterceptorProperties interceptorProperties;
|
|
|
|
// 로그인 관련 설정
|
|
@Autowired
|
|
private LoginProperties loginProperties;
|
|
|
|
// 로그인 매퍼
|
|
@Autowired
|
|
private LoginMapper loginMapper;
|
|
|
|
/**
|
|
* 컨트롤러 실행 전 접근 제어 처리
|
|
* @param request 요청 객체
|
|
* @param response 응답 객체
|
|
* @param handler 핸들러 객체
|
|
* @return 접근 허용 여부
|
|
* @throws Exception 예외 발생 시
|
|
*/
|
|
@Override
|
|
public boolean preHandle(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Object handler) throws Exception {
|
|
String requestURI = request.getRequestURI();
|
|
|
|
// 접근 제어 예외 URL 패턴은 WebMvcConfigurer에서 처리됨
|
|
// 이 메서드가 호출된다는 것은 이미 예외 패턴이 아니라는 의미
|
|
|
|
// Referer 헤더 검사 - URL을 브라우저 주소창에 직접 입력해서 접근하는 것을 방지
|
|
// 첫 페이지 접근이나 로그인 페이지 등 일부 URL은 Referer 검사에서 제외
|
|
if (!isRefererCheckExcluded(requestURI)) {
|
|
String referer = request.getHeader("Referer");
|
|
if (referer == null || referer.isEmpty()) {
|
|
// Referer 헤더가 없는 경우 (직접 URL 입력 또는 북마크 등)
|
|
log.warn("Referer 헤더 없음: {}", requestURI);
|
|
|
|
// AJAX 요청인 경우 JSON 응답 반환
|
|
if (isAjaxRequest(request)) {
|
|
handleRefererMissing(response);
|
|
return false;
|
|
}
|
|
|
|
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
|
redirectToLoginPageWithPopupCheck(request, response, "잘못된 접근입니다. 로그인 페이지로 이동합니다.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// 세션 정보 조회
|
|
SessionVO sessionVO = loginService.getSessionInfo(request);
|
|
|
|
// 세션이 있고 로그인 상태인 경우, 동시 접속 허용 불가일 때만 세션 정보를 DB에서 확인
|
|
if (sessionVO != null && sessionVO.isLogin() && !loginProperties.isAllowMultipleLogin()) {
|
|
HttpSession session = request.getSession(false);
|
|
if (session != null) {
|
|
String sessionId = session.getId();
|
|
String userId = sessionVO.getUser().getUserId();
|
|
|
|
// DB에서 세션 정보 조회
|
|
UserSessionVO userSessionVO = loginMapper.selectUserSessionBySessionId(sessionId);
|
|
|
|
if (userSessionVO == null) {
|
|
// DB에 세션 정보가 없으면 현재 세션 무효화
|
|
log.warn("DB에 세션 정보가 없음. 세션 무효화 (동시 접속 제한): {}", sessionId);
|
|
session.invalidate();
|
|
|
|
// AJAX 요청인 경우 JSON 응답 반환
|
|
if (isAjaxRequest(request)) {
|
|
handleAjaxSessionExpired(response);
|
|
return false;
|
|
}
|
|
|
|
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
|
redirectToLoginPageWithPopupCheck(request, response, "세션이 종료되었습니다. 로그인 페이지로 이동합니다.");
|
|
|
|
return false;
|
|
} else {
|
|
// DB에 세션 정보가 있으면 마지막 접속 시간 업데이트
|
|
userSessionVO.setLastAccessDttm(LocalDateTime.now());
|
|
loginMapper.updateUserSession(userSessionVO);
|
|
log.debug("세션 정보 업데이트 (동시 접속 제한): {}", sessionId);
|
|
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
// 세션이 없거나 로그인 상태가 아닌 경우
|
|
if (sessionVO == null || !sessionVO.isLogin()) {
|
|
// 방문자 권한 확인
|
|
if (sessionVO != null && sessionVO.isVisitor()) {
|
|
// 방문자 권한으로 접근 가능한지 확인
|
|
if (hasAccess(sessionVO, requestURI)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// AJAX 요청인 경우 JSON 응답 반환
|
|
if (isAjaxRequest(request)) {
|
|
handleAjaxSessionExpired(response);
|
|
return false;
|
|
}
|
|
|
|
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
|
redirectToLoginPageWithPopupCheck(request, response, "세션이 종료되었습니다. 로그인 페이지로 이동합니다.");
|
|
|
|
return false;
|
|
}
|
|
|
|
// 비밀번호 초기화 여부 확인
|
|
if ("Y".equals(sessionVO.getUser().getPasswdInitYn())) {
|
|
// 비밀번호 변경 페이지 또는 비밀번호 변경 처리 URL인 경우에만 접근 허용
|
|
if (requestURI.equals("/mypage/password.do") ||
|
|
requestURI.equals("/mypage/password.ajax") ||
|
|
requestURI.equals("/mypage/check-password.ajax") ||
|
|
requestURI.equals("/mypage/password-reset.do")) {
|
|
return true;
|
|
} else {
|
|
// 비밀번호 변경 페이지로 리다이렉트
|
|
if (isAjaxRequest(request)) {
|
|
// AJAX 요청인 경우 JSON 응답 반환
|
|
sendJsonResponse(response, HttpStatus.UNAUTHORIZED, false, "비밀번호를 변경해야 합니다.", "PASSWORD_RESET_REQUIRED");
|
|
return false;
|
|
} else {
|
|
// 일반 요청인 경우 비밀번호 변경 페이지로 리다이렉트
|
|
redirectToPage(request, response, "비밀번호를 변경해야 합니다.", "/mypage/password-reset.do");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 로그인 상태인 경우 접근 권한 확인
|
|
if (hasAccess(sessionVO, requestURI)) {
|
|
//여기서 사진 업로드 패스를 세션에 저장하면 좋을듯.
|
|
cpSetinfoRepository.findById(new CpSetinfoId("WORKER", "INFO", "LOCAL"))
|
|
.ifPresent(info -> sessionVO.setImgPath(info.getStrValue5()));
|
|
log.info(sessionVO.getImgPath());
|
|
return true;
|
|
}
|
|
|
|
// 접근 권한이 없는 경우 예외 발생
|
|
log.warn("접근 권한 없음: {} - {}", sessionVO.getUser().getUserAcnt(), requestURI);
|
|
throw new AccessDeniedException(sessionVO.getUser().getUserAcnt(), requestURI);
|
|
//response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
|
|
//return false;
|
|
} catch (AccessDeniedException e) {
|
|
throw e;
|
|
} catch (Exception e) {
|
|
log.error("접근 제어 처리 중 오류 발생", e);
|
|
|
|
// AJAX 요청인 경우 JSON 응답 반환
|
|
if (isAjaxRequest(request)) {
|
|
try {
|
|
handleAjaxSessionExpired(response);
|
|
} catch (IOException ex) {
|
|
log.error("AJAX 응답 처리 중 오류 발생", ex);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
|
try {
|
|
redirectToLoginPageWithPopupCheck(request, response, "세션이 종료되었습니다. 로그인 페이지로 이동합니다.");
|
|
} catch (IOException ex) {
|
|
log.error("리다이렉트 처리 중 오류 발생", ex);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 접근 권한이 있는지 확인
|
|
* @param sessionVO 세션 정보
|
|
* @param requestURI 요청 URI
|
|
* @return 접근 권한 여부
|
|
*/
|
|
private boolean hasAccess(SessionVO sessionVO, String requestURI) {
|
|
// 메뉴 트리를 1차원 리스트로 변환
|
|
List<MenuVO> menus = flattenMenuTree(sessionVO.getMenus());
|
|
if (menus.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
// 메뉴의 URL 패턴과 일치하는지 확인
|
|
for (MenuVO menu : menus) {
|
|
if (menu.getUrlPattern() != null && !menu.getUrlPattern().isEmpty()) {
|
|
// XSS 방지를 위한 이스케이프 처리된 URL 패턴을 원래대로 복원하고 쉼표로 구분된 패턴을 분리
|
|
String[] patterns = xssUtil.unescape(menu.getUrlPattern()).split(",");
|
|
for (String pattern : patterns) {
|
|
// 각 패턴과 요청 URI를 비교하여 일치하면 접근 허용
|
|
if (pathMatcher.match(pattern.trim(), requestURI)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 일치하는 패턴이 없으면 접근 불가
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 트리 구조를 1차원 리스트로 변환
|
|
* 재귀적으로 모든 하위 메뉴를 포함하는 평면화된 리스트를 생성
|
|
*
|
|
* @param menus 메뉴 트리 구조
|
|
* @return 1차원으로 변환된 메뉴 리스트
|
|
*/
|
|
private List<MenuVO> flattenMenuTree(List<MenuVO> menus) {
|
|
List<MenuVO> flatList = new ArrayList<>();
|
|
if (menus != null) {
|
|
for (MenuVO menu : menus) {
|
|
// 현재 메뉴를 리스트에 추가
|
|
flatList.add(menu);
|
|
|
|
// 하위 메뉴가 있으면 재귀적으로 처리하여 리스트에 추가
|
|
if (menu.getChildren() != null && !menu.getChildren().isEmpty()) {
|
|
flatList.addAll(flattenMenuTree(menu.getChildren()));
|
|
}
|
|
}
|
|
}
|
|
return flatList;
|
|
}
|
|
|
|
/**
|
|
* AJAX 요청에 대한 세션 만료 응답 처리
|
|
* 클라이언트에게 세션 만료 정보를 JSON 형식으로 반환
|
|
*
|
|
* @param response HTTP 응답 객체
|
|
* @throws IOException 입출력 예외 발생 시
|
|
*/
|
|
private void handleAjaxSessionExpired(HttpServletResponse response) throws IOException {
|
|
sendJsonResponse(response, HttpStatus.UNAUTHORIZED, false,
|
|
"세션이 만료되었습니다. 로그인 페이지로 이동합니다.",
|
|
SessionConstants.SESSION_EXPIRED);
|
|
}
|
|
|
|
/**
|
|
* Referer 헤더가 없는 AJAX 요청에 대한 응답 처리
|
|
* 직접 URL 입력 등 비정상적인 접근 시도에 대한 응답 처리
|
|
*
|
|
* @param response HTTP 응답 객체
|
|
* @throws IOException 입출력 예외 발생 시
|
|
*/
|
|
private void handleRefererMissing(HttpServletResponse response) throws IOException {
|
|
sendJsonResponse(response, HttpStatus.FORBIDDEN, false,
|
|
"잘못된 접근입니다. 로그인 페이지로 이동합니다.",
|
|
SessionConstants.INVALID_ACCESS);
|
|
}
|
|
|
|
/**
|
|
* JSON 응답을 생성하여 클라이언트에게 반환
|
|
*
|
|
* @param response HTTP 응답 객체
|
|
* @param status HTTP 상태 코드
|
|
* @param result 결과 성공 여부
|
|
* @param message 메시지
|
|
* @param errorCode 에러 코드
|
|
* @throws IOException 입출력 예외 발생 시
|
|
*/
|
|
private void sendJsonResponse(HttpServletResponse response, HttpStatus status, boolean result,
|
|
String message, String errorCode) throws IOException {
|
|
// 응답 상태 코드 및 컨텐츠 타입 설정
|
|
response.setStatus(status.value());
|
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
|
response.setCharacterEncoding("UTF-8");
|
|
|
|
// 응답 객체 생성
|
|
ApiResponseEntity<Void> responseBody = ApiResponseEntity.<Void>builder()
|
|
.result(result)
|
|
.message(message)
|
|
.errorCode(errorCode)
|
|
.build();
|
|
|
|
// JSON 변환 및 응답 출력
|
|
response.getWriter().write(objectMapper.writeValueAsString(responseBody));
|
|
}
|
|
|
|
/**
|
|
* Referer 검사에서 제외할 URL 패턴인지 확인
|
|
* 로그인 페이지, 메인 페이지 등 직접 접근이 허용되는 URL인지 확인
|
|
*
|
|
* @param requestURI 요청 URI
|
|
* @return Referer 검사 제외 여부 (true: 제외, false: 검사 필요)
|
|
*/
|
|
private boolean isRefererCheckExcluded(String requestURI) {
|
|
// 설정된 제외 패턴과 요청 URI를 비교
|
|
for (String pattern : interceptorProperties.getRefererExclude()) {
|
|
if (pathMatcher.match(pattern, requestURI)) {
|
|
return true; // 제외 패턴에 해당하면 Referer 검사 제외
|
|
}
|
|
}
|
|
|
|
// 제외 패턴에 해당하지 않으면 Referer 검사 필요
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* AJAX 요청인지 확인
|
|
*
|
|
* @param request HTTP 요청 객체
|
|
* @return AJAX 요청 여부
|
|
*/
|
|
private boolean isAjaxRequest(HttpServletRequest request) {
|
|
return HttpServletUtil.isAjaxRequest(request) || HttpServletUtil.isRealAjaxRequest(request);
|
|
}
|
|
|
|
/**
|
|
* 일반 페이지로 리다이렉트
|
|
*
|
|
* @param request HTTP 요청 객체
|
|
* @param response HTTP 응답 객체
|
|
* @param message 알림 메시지
|
|
* @param targetUrl 리다이렉트 대상 URL
|
|
* @throws IOException 입출력 예외 발생 시
|
|
*/
|
|
private void redirectToPage(HttpServletRequest request, HttpServletResponse response,
|
|
String message, String targetUrl) throws IOException {
|
|
response.setContentType("text/html; charset=UTF-8");
|
|
response.getWriter().write("<script type='text/javascript'>");
|
|
response.getWriter().write("alert('" + message + "');");
|
|
response.getWriter().write("location.href = '" + request.getContextPath() + targetUrl + "';");
|
|
response.getWriter().write("</script>");
|
|
}
|
|
|
|
/**
|
|
* 팝업 창 확인 후 로그인 페이지로 리다이렉트
|
|
*
|
|
* @param request HTTP 요청 객체
|
|
* @param response HTTP 응답 객체
|
|
* @param message 알림 메시지
|
|
* @throws IOException 입출력 예외 발생 시
|
|
*/
|
|
private void redirectToLoginPageWithPopupCheck(HttpServletRequest request, HttpServletResponse response,
|
|
String message) throws IOException {
|
|
response.setContentType("text/html; charset=UTF-8");
|
|
response.getWriter().write("<script type='text/javascript'>");
|
|
response.getWriter().write("alert('" + message + "');");
|
|
response.getWriter().write("if(window.opener) {");
|
|
response.getWriter().write(" var topWindow = window;");
|
|
response.getWriter().write(" while(topWindow.opener && !topWindow.opener.closed) {");
|
|
response.getWriter().write(" topWindow = topWindow.opener;");
|
|
response.getWriter().write(" }");
|
|
response.getWriter().write(" topWindow.location.href = '" + request.getContextPath() + loginProperties.getUrl() + "';");
|
|
response.getWriter().write(" window.close();");
|
|
response.getWriter().write("} else {");
|
|
response.getWriter().write(" location.href = '" + request.getContextPath() + loginProperties.getUrl() + "';");
|
|
response.getWriter().write("}");
|
|
response.getWriter().write("</script>");
|
|
}
|
|
}
|