[Spring Web MVC] 로그인 처리 - Filter / Interceptor
로그인 한 사용자에 한해서만 웹 페이지가 제공하는 서비스를 사용할 수 있어야 한다.
로그인 하지 않은 사용자가 특정 URL으로 접속을 시도할 경우 로그인을 하지 않아도 서비스를 사용할 수 있으면 안 된다.
컨트롤러에서 로그인 여부를 체크하는 로직을 하나하나 추가해서 작성하는 방법도 있지만, 이렇게 설계하면 나중에 로그인 관련 로직이 변경될 때 작성된 로직을 모두 수정해야 하는 불편함이 생긴다.
여기서의 로그인처럼 여러 로직에서 공통으로 관심이 있는 작업을 공통 관심사라고 한다.
웹과 관련된 공통 관심사는 서블릿 필터 또는 스프링 인터셉터를 사용해 한 번에 처리할 수 있다.
Servlet Filter
필터는 수문장 역할을 한다.
HTTP 요청 -> 서버 -> 필터 -> 디스패처 서블릿 -> 컨트롤러
서버는 서블릿과 컨트롤러를 호출하기 전에 필터를 먼저 호출한다.
특정 URL 패턴에 필터가 걸리게끔 필터를 설계한다.
필터는 체인으로 구성돼 중간에 필터를 자유롭게 추가할 수 있다.
필터1 -> 필터2 -> 필터3 ... 이런식으로 호출하는게 가능하고, 남기는 필터 / 로그인 여부를 검사하는 필터 ... 등 필터별로 역할을 나눠서 사용할 수 있다.
가볍게 로그를 남기는 필터를 만들어보자.
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("initialize");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("do ");
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try{
log.info("REQUEST [{}] [{}]", uuid, requestURI);
chain.doFilter(request, response);
}catch(Exception e){
throw e;
}finally{
log.info("REQUEST [{}] [{}]", uuid, requestURI);
}
}
@Override
public void destroy() {
log.info("destroy");
}
}
필터 인터페이스를 구현하고 등록 시 서블릿 컨테이너가 필터를 싱글톤으로 생성하고 관리한다.
init() : 서블릿 컨테이너가 생성될 때 호출된다.
doFilter() : 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 로직을 여기에 구현하자.
destroy() : 서블릿 컨테이너가 종료될 때 호출된다.
ServletRequest 인터페이스는 요청이 HTTP 타입이 아닌 경우까지 고려해서 만든 인터페이스이다. 다운캐스팅해서 사용하자.
HTTP 요청을 구분하기 위해 요청마다 UUID를 할당해줬다.
chain.doFilter() 메서드를 통해 체인을 구현한다. 다음 필터가 있으면 다음 필터를 호출하고 필터가 없으면 서블릿을 호출한다. (호출하지 않으면 다음 단계로 갈 수 없음)
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
FilterRegistrationBean을 사용하면 스프링이 알아서 필터를 등록해준다.
필터를 등록하는 방법으로는 @WebFilter @ServletComponentScan 등 여러 방법이 있지만, FilterRegistrationBean을 사용하자.
setFilter : 등록할 필터 지정
setOrder : 체인에 따라 동작할 때 순서 지정
addUrlPatterns : 필터를 적용할 URL 패턴 지정
(쓰레드별로 로그를 분류할 때는 logback mdc를 사용하자)
로그인 하지 않으면 웹 페이지가 제공하는 서비스를 이용할 수 없도록 설계해보자.
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/css/*", "/logout"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try{
log.info("필터 시작합니다. {}", requestURI);
if(isLoginCheckPath(requestURI)){
log.info("인증 체크 로직 실행. {}", requestURI);
HttpSession session = httpRequest.getSession(false);
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("인증되지 않은 사용자입니다. {}", requestURI);
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
chain.doFilter(request, response);
}catch(Exception e){
throw e;
}finally{
log.info("인증 체크 필터 종료합니다.");
}
}
/**
* whitelist can get connection without login
*/
private boolean isLoginCheckPath(String requestURI){
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
미래에 제공될 서비스도 로그인하지 않으면 이용할 수 없도록 설계하자.
로그인 하지 않으면 로그인 서비스도 이용할 수 없는 경우는 발생하면 안 되기 때문에, whitelist 배열을 통해 로그인 없이도 접근할 수 있는 URL들을 정의해놨다.
미인증 사용자는 로그인 화면으로 리다이렉트한다.
로그인 이후 홈으로 이동하는 대신 열람하려는 서비스 페이지로 이동하도록 하기 위해 현재 요청한 경로인 requestURI를 /login 의 쿼리 파라미터로 함께 전달한다.
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
WebConfig 클래스에 필터를 등록해줬다.
로그 필터 이후에 작동하도록 하기 위해 우선순위는 2로 부여했다.
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request, @RequestParam(defaultValue = "/") String redirectURL){
if(bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(loginMember == null){
bindingResult.reject("loginFail", "아이디나 비번 확인하세요");
return "login/loginForm";
}
// success
// 세션이 있으면 반환, 없으면 새로 생성
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL ;
}
미인증 사용자가 로그인 후 서비스 페이지로 바로 이동할 수 있도록 리턴값을 변경해줬다.
Spring Interceptor
스프링 인터셉터도 서블릿 필터와 같이 웹 관련 공통 관심사를 효과적으로 처리하는 기술이다.
HTTP 요청 -> 서버 -> 필터 -> (Dispatcher) 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터는 컨트롤러 호출 직전에 호출된다.
스프링 인터셉터는 스프링 MVC가 제공하는 기술이고, 스프링 MVC의 디스패처 시작점은 서블릿이라고 생각하자.
역시 체인을 지원한다. 인터셉터1 -> 인터셉터2 -> ... 이런 식으로..
스프링 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현해 preHandler / postHandler / afterCompletion 메서드를 오버라이드하면 된다.
인터셉터의 preHandle을 호출하고, 핸들러 어댑터와 컨트롤러를 통해 ModelAndView를 반환받는다.
이후 postHandle을 호출하고 뷰를 렌더링 한 이후 afterCompletion을 호출한다.
여기서 preHandle의 응답값이 false이면 더 진행하지 않고, true이면 다음 단계로 진행한다.
컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않지만, afterCompletion은 호출된다.
즉, 예외와 무관하게 공통 처리를 진행하려면 afterCompletion을 사용하자.
인터셉터는 스프링 MVC에 특화된 필터 기능을 제공하니 웬만하면 필터보다는 인터셉터를 사용하는 편이 합리적이다.
스프링 인터셉터로 로그를 만드는 인터셉터를 만들어보자.
@Slf4j
public class Loginterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
if(handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler;
}
log.info("REQUST {} {} {}", uuid, requestURI, handler);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("post handle");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String)request.getAttribute(LOG_ID);
log.info("adf");
}
}
스프링 인터셉터의 호출 시점은 완전히 분리돼있기 때문에 request.setAttribute() 메서드로 지정한 값을 저장해둔다.
LogInterceptor도 싱글톤으로 관리되기 때문에 멤버변수의 사용은 최대한 피하자.
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new Loginterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
WebConfig 파일에 인터셉터를 등록해주자.
WebMvcConfigurer 가 제공하는 addInterceptor() 메서드를 통해서 인터셉터를 등록한다.
등록 메서드명은 모두 직관적이라.. 쉽게 이해할 수 있다.
스프링 인터셉터를 사용할 때의 URL패턴은 서블릿 필터가 사용하는 URL패턴과 완전히 다르다.
스프링이 더 자세하고 세밀한 경로를 지원한다.
? : 한 문자 일치
* : 경로 안에서 0개 이상의 문자 일치
** : 경로 끝까지 0개 이상의 경로 일치 등등..
인터셉터로 로그인을 확인하는 작업은 필터를 사용할 때 보다 훨씬 간단하게 진행할 수 있다.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("로그인 검사 시작 {}", requestURI);
HttpSession session = request.getSession();
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("인증되지않은사용자");
// redirect!!@~
response.sendRedirect("/login?redirectURL=" + requestURI);
return false; // false 로 끝내기
}
return true;
}
로그인 인증은 컨트롤러 호출 전에만 확인하면 되기 때문에 preHandle만 구현하면 된다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new Loginterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns("/", "/members/add", "/login", "/css/**");
}
추가는 addInterceptor() 메서드를 사용한다.
ArgumentResolver를 사용하면 로그인 회원을 편리하게 찾을 수 있다.
컨트롤러 메서드의 매개변수를 해석하는 역할을 수행하고, 매번 세션에서 정보를 꺼내는 코드를 작성할 필요 없이 자동으로 매개변수를 사용해 받아오도록 할 수 있다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
ArgumentResolver를 동작시켜 알아서 세션에 있는 로그인 회원을 찾는 @Login 애너테이션을 만들자.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
@Login 애너테이션이 있으면 ArgumentResolver가 동작한다.
@Target(ElementType.PARAMETER) : 해당 애너테이션을 파라미터에만 적용.
@Retention(RetentionPolicy.RUNTIME) : 런타임까지 애너테이션 정보가 남아있음. 보통 런타임까지만 사용
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
log.info("지원하는지 확인할게요");
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
return hasLoginAnnotation && hasMemberType;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
log.info("리졸버 실행");
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if(session == null){
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
먼저 파라미터를 지원하는지 확인하고, ArgumentResolver 실행 시 어떤 값을 넣어줄지 설계해줬다.
@Login 애너테이션이 있으면서 Member 타입이면 ArgumentResolver를 실행시키고, 컨트롤러 호출 직전에 호출돼 필요한 파라미터를 생성한다.
여기서는 세션에 있는 로그인 멤버인 member객체를 찾아서 반환해주고, 컨트롤러의 메서드를 호출할 때 여기서 찾은 member객체를 파라미터에 전달해준다.
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
만든 ArgumentResolver를 등록하자.
ArgumentResolver를 사용하면 컨트롤러를 더욱 편하게 사용할 수 있다.
스프링 시큐리티를 사용하면 더 편하게 구현할 수 있다. (스프링 시큐리티도 위와 같은 방법으로 구현되어있다)
시큐리티는 CSRF 비밀번호 인코딩 등 보안과 인증 관련 다양한 기능을 제공하니 해당 기능을 직접 구현하기보다는 검증된 솔루션인 스프링 시큐리티를 사용하는걸 고려해보자.
'Spring > Spring Web MVC' 카테고리의 다른 글
[Spring Web MVC] 로그인 처리 - Cookie / Session (0) | 2023.07.10 |
---|---|
[Spring Web MVC] Multipart (1) | 2022.08.29 |
[Spring Web MVC] 스프링 타입 컨버터 (0) | 2022.08.28 |
[Spring Web MVC] API 예외 처리 (0) | 2022.08.26 |
[Spring Web MVC] 예외 처리와 오류 페이지 (0) | 2022.08.26 |
댓글
이 글 공유하기
다른 글
-
[Spring Web MVC] 로그인 처리 - Cookie / Session
[Spring Web MVC] 로그인 처리 - Cookie / Session
2023.07.10 -
[Spring Web MVC] Multipart
[Spring Web MVC] Multipart
2022.08.29 -
[Spring Web MVC] 스프링 타입 컨버터
[Spring Web MVC] 스프링 타입 컨버터
2022.08.28 -
[Spring Web MVC] API 예외 처리
[Spring Web MVC] API 예외 처리
2022.08.26