= HandlerInterceptor

org.springframework.web.servlet.HandlerInterceptor의 구현클래스에 대해서 설명한다.
HandlerInterceptor는 Controller 전후 에 실행되며 preHandle, postHandle, afterCompletion 메소드를 제공한다.

[source, java]
----

default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    return true;
}

default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
}

default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}

----


== LoggingInterceptor

LoggingInterceptor는 시스템 이용 로그를 남기기 위한 Interceptor이다. 대부분의 로직은 postHandle에 구현되어 있으며
TN_CF_SYS_USE_LOG 테이블에 데이터를 기록한다.
사용자정보(ID, IP, 브라우저), 요청시간, 응답시간, 요청 파라미터를 기록하며 이 데이터는 메뉴 사용 이력,
메뉴 활용도, 파일 다운로드 이력등에 사용된다.

[source, java]
----

public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) {
	String requestMethod = request.getMethod();
    if(HttpMethod.OPTIONS.name().equals(requestMethod)) {
    	return;
    }
    String requestURI = request.getRequestURI().substring(request.getContextPath().length());

    
    SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMdd HHmmssSSS", Locale.getDefault());
    String sTime = formatter.format(new Date());

    long start = (long) request.getAttribute("start");
    long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);

    String userId = "anonymous";
    if (ObjectUtils.isNotEmpty(Account.currentUser())) {
        userId = Account.currentUser().getUserId();
    }
    
    String getDecodedRequestURI = WebUtil.getDecodedRequestUrl(request, requestURI);
    SysUseLog sysUseLog = new SysUseLog();
    sysUseLog.setLogOccurId(idGenService.getNextStringId());
    
    String menuId = request.getHeader("menu-Id");
    String pageId = request.getHeader("page-id");
    if(pageId != null) sysUseLog.setDescription(menuService.getPageFullPath(pageId));
    
    sysUseLog.setNodeId(node);
    sysUseLog.setUserId(userId);
    sysUseLog.setUseFromDate(sTime.substring(0, 8));
    sysUseLog.setUseFromHhmmss(sTime.substring(9, 15));
    sysUseLog.setUseThruDatetime(new Date());
    sysUseLog.setResponseTime(elapsedTime);
    sysUseLog.setPath(getDecodedRequestURI);

    String query = request.getQueryString();
    if (query != null) {
        try {
            query = URLDecoder.decode(query, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            log.error(e.getMessage(), e);
        }
        String queryStr = StringUtils.replace(query, "'", "\\'");
        sysUseLog.setParameter(queryStr);
        sysUseLog.setUrl(requestURI + "?" + queryStr);
    }
    sysUseLog.setUseIp(WebUtil.getClientIp(request));
    sysUseLog.setBrowserTypeName(request.getHeader("User-Agent"));
    sysUseLog.setLogFlag(LogFlag.ACCESSLOG.getValue());
    sysUseLog.setReqType(ReqType.WEB);
    sysUseLog.setFirstRegDatetime(new Date());

    sysUseLog.setMenuId(menuId);
    sysUseLog.setPageId(pageId);
    sysUseLog.setMethod(requestMethod);

    // 대리 로그인 시 별도 이력 남김
    String originalUserId = request.getHeader("original-user-id"); 
    if(StringUtils.isNotEmpty(originalUserId)){
        sysUseLog.setLogFlag(LogFlag.IMPERSONATION_ACCESS.getValue());
        sysUseLog.setDescription("Original User Id : " + originalUserId);
    }

    try {
        if ("db".equalsIgnoreCase(storeType)) { 
            accessLogService.insertAccessLog(sysUseLog);
        } else {
            accessLogService.logAccess(sysUseLog);
        }
    } catch (Exception e) {
        log.error(e.getMessage());
    }
}

----

config.properties 파일의 access-log.store-type 값에 따라 db또는 file에 저장된다.


== AuthenticationInterceptor

AuthenticationInterceptor는 사용자가 인증이 되어 있는지 체크하는 인터셉터다.
인증이 되어 있지 않을 경우 로그인 화면으로 이동한다.

[source. java]
----
String token = request.getHeader("x-auth-token"); <1>
// Token 값 유무
if (StringUtils.isEmpty(token)) {
    throw new NotFoundTokenException("Not Found Token");
}
----
인증이 된 사용자는 모든 요청 해더에 x-auth-token 값을 같이 보내야 한다.
Frontend에서 x-auth-token값을 공통으로 Set하는 코드는 main.js 를 참고한다.

[source, javascript]
----
axios.defaults.headers.common['x-auth-token'] = localStorage.getItem('userToken');
----

loginService.js에서 로그인이 완료되면 localStorage에 toekn값을 저장하고, axios 요청 시마다 
해더에 추가 한다.

[source, java]
----
String originalUserId = request.getHeader("original-user-id"); <1>
----

<1> 대리로그인 시 원래 사용자의 ID가 헤더에 담겨있다.

대리로그인은 개발 시 기능, 권한 테스트를 하기 위한 용도로 관리자가 다른 사용자로 로그인하는 기능이다. 이 기능은 운영중인 시스템에 사용할 경우 보안에 문제가 발생하니 반드시 개발 시 테스트 용도로만 사용한다.


CAUTION: 대리로그인은 개발 시 테스트를 위한 기능으로 운영 시 사용하지 않는다.

[source, java]
----
Boolean jwtValid = jwtUtil.validateToken(token, user); <1>
if (Boolean.TRUE.equals(jwtValid)) {
    if (user.isActiveFlag()) {
        user.setSystemAdminUser(roleService.isSystemAdmin(userId));
        Account.updateCurrentAccount(user);
        return true;
    } else {
        // 시스템 승인 대기중
        throw new WaitingUserException("Waiting User"); <2>
    }
} else {
    throw new InvalidTokenException("Token Expired");
}
----

<1> 유효한 Token인치 확인
<2> Active Flag가 false일 경우 시스템 승인 대기중 상태

로그인 Token 관련해서는 <<_로그인>>을 참고한다.

NOTE: 중복로그인 방지를 위해 로그인시 발급된 jwt값을 db에 저장하고, 현재 jwt값을 비교한다.
config.properties파일 security.check.duplicate.login이 true일 때만 동작한다.


== AuthorizationInterceptor
AuthorizationInterceptor는 요청 URL(API)가 사용자에게 접근이 가능한지 판단하는 인터셉터다.

[source, java]
----
if (user == null) throw new NoSearchUserException("No Search User"); <1>
if (roleService.isSystemAdmin(user.getUserId())) { <2>
	if(adminAddressCheck) {
		List<String> adminAddresses = adminAddressService.getAdminAddresses(null); <3>
		String remoteAddr = WebUtil.getClientIp(request);
		if(ObjectUtils.isNotEmpty(adminAddresses) && !adminAddresses.contains(remoteAddr)) {
			throw new AuthorizationException("Remote address is not admin ip address.");
		}
	}
	return true;
}
----

<1> 사용자가 null일 경우에는 Exception을 발생한다.
<2> 현재 사용자가 System Admin일 경우 
<3> 접근 IP가 System Admin IP에 포함 되어 있는지 판단한다.

NOTE: config.properties의 admin.address.check값이 true일 때만 System Admin IP를 체크 한다.

[source, java]
----
for (Api api : authMenuList) {
    if (api.getHttpMethod().equals(httpMethod) && checkUriMatch(api.getApiPath(), api.getApiParameters(), decodedUrl, queryString)) {
        return true;
    }
}
throw new AuthorizationException("API 권한 없음");
----

IMPORTANT: URL 권한 체크 시에는 파라미터 단위까지 체크한다. test?abc=123과 test?adb=123은 다른 권한이다.


== PrivacyPolicyInterceptor

PrivacyPolicyInterceptor는 이용 약관 동의 여부를 체크하는 Interceptor이다.

[source, java]
----
/**
 * 약관동의 사용 여부
 */
@Value("${privacy-policy.check.enabled:true}")
private boolean privacyPolicyCheck;

/**
 * 약관동의 인터셉터 체크 제외 URI
 */
@Value("${privacy-policy.check.exclude-path}")
private String privacyPolicyCheckExcludePath;
----

약관 동의 체크 인터셉터 사용 여부와 제외 URI는 위 프로퍼티 값을 이용한다.

[source, java]
----
if (user != null && !user.isPrivacyPolicy()) {
    throw new PrivacyPolicyException("Privacy policy agreement required.");
} else {
    return true;
}
----
약관 동의가 완료되지 않은 경우 PrivacyPolicyException이 발생하고 약관 동의 페이지로 이동한다.


== TimeoutCheckInterceptor

TimeoutCheckInterceptor는 일정시간 동안 사용하지 않을 경우 자동 로그아웃 하는 기능이다.

config.properties파일 security.check.access.timeout 값이 true 일때만 동작한다. security.access.limit.timeout(분) 동안 시스템을 사용하지 않을 경우 자동 로그아웃 한다.


[source, java]
----
@Value("${security.access.limit.timeout:30}")
private int limitTimeout;

static final long MILLISECONDS_PER_MINUTE = 60L*1000L;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
    String lastAccessTimeHeader = request.getHeader("last-access-time");
    String impersonate = request.getHeader("original-user-id");
    if(StringUtils.isNotEmpty(lastAccessTimeHeader) && !StringUtils.equals("undefined", lastAccessTimeHeader) && !StringUtils.equals("NaN", lastAccessTimeHeader) && StringUtils.isEmpty(impersonate)){
        long lastActivityTime = Long.parseLong(lastAccessTimeHeader);
        long currentTime = System.currentTimeMillis();
        long jwtRetentionTime = currentTime - lastActivityTime;
        if (jwtRetentionTime > (limitTimeout * MILLISECONDS_PER_MINUTE)) {
            //limitTimeout 동안 사용하지 않으면 자동 로그아웃
            throw new TokenRetentionTimeoutException("Token Retention Timeout");
        }
    }
    return true;
}
----

동작 방식은 Backend 요청 시 Response Header에 마지막으로 요청한 시간을 세팅하고 다음 요청시 Request Header에 last-access-time에 설정된다. 현재 시간과 last-access-time을 비교해 로그아웃 여부를 결정한다.



== UploadFileExtensionCheckInterceptor

UploadFileExtensionCheckInterceptor는 파일 업로드 시 파일 확장자를 검사한다.

config.properties파일 common.upload.allowed-extensions에 정의된 확장자를 가진 파일만 업로드 할 수 있다.

NOTE: UI에서도 허용되는 파일 확장자를 정의해줘야한다. MultipleFileUploader.vue를 부모컴포넌트에서 사용할때 pros useExtList을 설정해야 한다. 자세한것은 <<_file_component>>를 참고한다.




