본문 바로가기
Back-End/🌱 Spring Boot (java)

[Spring] 서블릿 필터 개념과 로그인 필터 구현

by 코딩하는 동현😎 2023. 5. 6.

저번 포스트에 이어 로그인 기능을 구현했습니다.

그러나 로그인 기능을 구현하고 로그인된 사용자에 맞춰서 기능을 구현하는 것 까진 잘되는데, 특정 페이지에 접근하는 것을 막는것은 불가능합니다.

예를 들면 로그인하지 않은 사람이 '회원정보 수정' 페이지를 들어갈수 있는 버튼이 뜨지 않도록 페이지를 만들지만, 회원정보 수정 URL만 있으면 접근자체는 백엔드에서 막지 않았으므로 언제든지 url로 접근이 가능합니다.

 

컨트롤러에서 각 요청마다 로그인 여부를 체크하는 로직을 하나하나 작성하면 되겠지만, 수많은 컨트롤러마다 로직을 작성하는 것은 정말 비효율적이고, 나중에 로그인과 관련된 로직이 바뀌는 순간 모든 컨트롤러에 적용시킨 로그인 여부 체크 로직을 일일히 하나하나 바꿔야 되므로 유지보수가 어렵습니다.

 

이런 관심사는 스프링의 AOP를 이용해서 해결할수 있지만, 서블릿 필터나 스프링 인터셉터를 사용하는 것이 더 편리합니다.

웹과 관련된 공통 관심사를 처리할 때 Http 헤버나 URL정보들이 필요한데, HttpServletRequest를 이용해서 조회할 수 있습니다.


서블릿 필터

 

필터를 적용하면 필터가 호출 된 다음에 서블릿이 호출됩니다.

모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 됩니다.

 

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

 

 

 

필터에 URL패턴과 체인을 설정할 수 있는데 URL을 /* 이라고 적용하면 모든 요청에 필터가 적용되고, 필터의 체인 기능을 이용해서 중간에 필터를 여러개 추가할 수 있습니다.

 

HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러


서블릿 필터 인터페이스

public interface Filter {
     public default void init(FilterConfig filterConfig) throws ServletException{}
     public void doFilter(ServletRequest request, ServletResponse response,FilterChain chain) throws IOException, ServletException;
     public default void destroy() {}
}

필터 인터페이스를 오버라이딩해서 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리합니다.

 

  • init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
  • doFilter(): 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
  • destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.

여기서 필수적으로 오버라이딩해서 구현해야되는 것은 doFliter이고 실질적인 기능을 여기다가 작성할 것 입니다.


간단한 요청 로그

필터가 매 요청마다 문지기 역할을 하는지 확인하기 위해서 가장 단순한 필터인, 모든 요청을 로그로 남기는 필터를 개발하고 적용해 보겠습니다.

 

filter 폴더를 만들고 LogFilter라는 클래스를 만듭니다.


filter/LogFilter.java

ServletRequest는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스이므로,

HTTP를 사용하면 HttpServletRequest httpRequest = (HttpServletRequest) request; 와 같이 다운 케스팅 하면 됩니다.

package jpabook.jpastore.filter;

import java.io.IOException;
import java.util.UUID;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LogFilter implements Filter {
  @Override
  public void init(FilterConfig filterConfig) throws ServletException {
    log.info("log filter init");
  }

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    log.info("log filter doFilter");

    HttpServletRequest httpRequest = (HttpServletRequest) request;
    String requestURI = httpRequest.getRequestURI();

    String uuid = UUID.randomUUID().toString();

    try {
      log.info("REQUEST [{}][{}]", uuid, requestURI);
      chain.doFilter(request, response);
      // chain이 없으면 여기서 끝난다. 즉, 로그만 띄우고 컨트롤러까지 가지 않아서 백지만 나온다.
      // chain doFilter로 다시 호출해주면 controller로 넘어가서 정상적으로 페이지를 띄운다.
    } catch (Exception e) {
      throw e;
    } finally {
      log.info("REQUEST [{}][{}]", uuid, requestURI);
    }
  }

  @Override
  public void destroy() {
    log.info("log filter des");
  }

}

 

 

javax.servlet의 필터 인터페이스를 implement 시킨 후, URL요청과 랜덤 uuid값을 로그에 남기도록 작성했습니다.

필터에 대한 기능을 구현한 후, chain.doFilter(request, response); 를 호출합니다.
만약에 호출하지 않으면 필터에서 끝나고 컨트롤러 까지 가지 않습니다. 즉, 로그만 띄우고 컨트롤러까지 가지 않아서 백지만 나옵니다.


doFilter로 다시 호출해줘야지 controller로 넘어가서 정상적으로 페이지를 띄웁니다.


필터를 빈으로 등록

환경변수를 담당하는 Configuration 파일인 WebConfig.java를 생성하겠습니다.

@Configuration 어노테이션으로 환경변수 파일을 등록할수 있습니다.

 

WebConfig.java

package jpabook.jpastore;

import javax.servlet.Filter;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import jpabook.jpastore.filter.LogFilter;

@Configuration
public class WebConfig {

  @Bean
  public FilterRegistrationBean logFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<Filter>();
    filterRegistrationBean.setFilter(new LogFilter()); // 여기서 만든 필터 클래스  등록
    filterRegistrationBean.setOrder(1);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
  }

}

위와같이 작성해주시면 되고, Order와 UrlPatterns를 적용시켜줄 수 있습니다.

 아래와 같이 REQUEST 와 같이 매요청 로그가 나오는걸 볼 수 있습니다.


로그인 인증 체크 필터

이제 본론인 로그인 인증 체크 필터를 구현해보겠습니다.

 

filter/LoginCheckFilter

"/" , "/members/add", "/login" , "/logout" , "/css/*" , "/test" 등의 루트는 로그인 하기 전 사용자도 들어가야되기때문에, 화이트리스트로 저장하고, loginCheckFilter가 이 화이트 리스트를 제외한 모든 요청에 필터를 적용하도록 할 것입니다.

package jpabook.jpastore.filter;


import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.util.PatternMatchUtils;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LoginCheckFilter implements Filter {

  private static final String[] whiteList = {"/" , "/members/add", "/login" , "/logout" , "/css/*" , "/test"};
  
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
      HttpServletRequest httpRequest = (HttpServletRequest) request;
      HttpServletResponse httpResponse = (HttpServletResponse) response;
      String requestURI = httpRequest.getRequestURI();
      try {
        log.info("인증 체크 필터 시작{}", requestURI);
        if (isLoginCheckPath(requestURI)) {
          log.info("인증 체크 로직 실행{}", requestURI);
          HttpSession session = httpRequest.getSession(false);
          if (session == null || session.getAttribute("memberId") == null) {
            log.info("미인증 사용자 요청 {}", requestURI);
            // 로그인으로 redirect
            httpResponse.sendRedirect("/login?redirectURL="+ requestURI);
            return;
          }
        }
        chain.doFilter(request, response);
      } catch (Exception e) {
        throw e;
      } finally {
        log.info("인증 체크 필터 종료{}", requestURI);
      }
  }

  /**
   * 화이트 리스트의 경우 인증 체크 X 
   */
  private boolean isLoginCheckPath(String requestURI) {
    return !PatternMatchUtils.simpleMatch(whiteList, requestURI); // 화이트리스크에 있는건 !true = false 체크 안함
  }
}

예를 들어 items/add로 요청을 가면 미인증 사용자면 접근하기 전에 바로 login 페이지로 리디렉트합니다.

근데 로그인 완료후에 다시 items/add로 보내줘야지 편하기 때문에 (로그인 하래서 로그인 했더니 다시 items 페이지를 찾아야되므로) 파라미터로 현재 URL도 보내줍니다.


WebConfig.java

LoginCheckFilter를 추가적으로 더 등록해줍니다.

아래와 같이 순서 1,2로 등록을 해주면 모든 요청에 대해

HTTP 요청 -> WAS -> 로그필터 -> 로그인 인증 체크 필어  -> 서블릿 -> 컨트롤러

와 같이 접근이 진행됩니다.

package jpabook.jpastore;

import javax.servlet.Filter;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import jpabook.jpastore.filter.LogFilter;
import jpabook.jpastore.filter.LoginCheckFilter;

@Configuration
public class WebConfig {

  @Bean
  public FilterRegistrationBean logFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<Filter>();
    filterRegistrationBean.setFilter(new LogFilter()); // 여기서 만든 필터 클래스  등록
    filterRegistrationBean.setOrder(1);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
  }

  @Bean
  public FilterRegistrationBean LoginCheckFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<Filter>();
    filterRegistrationBean.setFilter(new LoginCheckFilter()); // 여기서 만든 필터 클래스  등록
    filterRegistrationBean.setOrder(2); // 1번인 로그필터 다음으로 수행.
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
  }
}

/login?redirectURL로 받았을때

redirectURL 파라미터로 받은것을 이용해서

return "redirect:" + redirectURL;로 다시 원래 페이지로 복귀시킵니다.

나머지 코드는 참고용으로 첨부합니다.

 

package jpabook.jpastore.controller;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.service.LoginService;
import jpabook.jpastore.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Controller
@RequiredArgsConstructor
@Slf4j
public class LoginController {
    private final LoginService loginService;
    private final SessionManager sessionManager;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm) {
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String loginV3(
            @Valid @ModelAttribute("loginForm") LoginForm loginForm,
            BindingResult bindingResult,
            HttpServletRequest request,
            HttpServletResponse response,
            @RequestParam(defaultValue = "/") String redirectURL) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        } // 폼 부족하게 입력

        Member member = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        log.info("login? {}", member);

        if (member == null) {
            bindingResult.reject("loginFail", "id or password incorrect");
            return "login/loginForm";
        }

        HttpSession session = request.getSession();
        session.setAttribute("memberId", member.getId());
        return "redirect:" + redirectURL;
    }

    // @PostMapping("/login")
    public String login(
            @Valid @ModelAttribute("loginForm") LoginForm loginForm,
            BindingResult bindingResult,
            HttpServletResponse response) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        } // 폼 부족하게 입력
        Member member = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        log.info("login? {}", member);
        if (member == null) {
            bindingResult.reject("loginFail", "id or password incorrect");
            return "login/loginForm";
        }

        Cookie cookie = new Cookie("memberId", String.valueOf(member.getId()));
        response.addCookie(cookie);
        return "redirect:/";
    }

    @PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
        session.invalidate();
        }
        return "redirect:/";
    }
}

결과

미인증된 상태로 /items/add로 접근 했을때 로그.

/items/add로 접근하자마자 바로 /login?redirectURL=/items/add로 갔기 때문에 /login으로 바로 간것이 보입니다.

인증후 /items/add에 접근

반응형

댓글