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.
clean-parking/src/main/java/egovframework/interceptor/AuthInterceptor.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>");
}
}