서블릿 필터
서블릿에서 제공하는 필터 기능을 사용하면, 컨트롤러 호출 전에 로그인 확인과 같은 처리를 할 수 있다. 단, url 패턴 등의 제공하는 기능이 제한적이라, 간단한 로그인 체크에만 사용해보았다.
로그인 체크
기존에는 로그인이 필요할 때마다 SessionAttribute에서 user를 가져와 null인지 확인했다. 하지만, 이렇게 할 경우 로그인 체크를 개발자가 누락시킬 수도 있고, 지겨운 코드를 계속 쳐야 한다.
로그인 체크 기능을 필터로 등록해봤다.
LoginCheckFilter
서블릿 필터는, jakarta.servlet.Filter 인터페이스를 구현한다. init, destroy 메서드도 존재하지만, doFilter 메서드가 필수 구현 메서드이다.
로그인을 안 해도 되는 곳을 화이트리스트로 지정해서, 화이트리스트가 아닌 경우에만 로그인 체크를 하도록 했다. 또, redirectUrl을 넣어주어서, 로그인 후에는 다시 redirectUrl로 이동하게 했는데, /book, /user의 경우에는 restApi라서 루트로 이동하게 해주었다.
public class LoginCheckFilter implements Filter {
private static final String[] whiteList = { "/", "/login", "/join", "/signout", "/css/*", "/js/*", "/access-denied"};
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String requestURI = request.getRequestURI();
HttpServletResponse response = (HttpServletResponse) servletResponse;
try {
if (isLoginCheckPath(requestURI)) {
log.debug("requestURI={}", requestURI);
HttpSession session = request.getSession(false);
if (session == null) {
if (requestURI.startsWith("/books") || requestURI.startsWith("/users")) {
requestURI = "/";
}
response.sendRedirect("/access-denied?redirectUrl=" + requestURI);
return;
}
}
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e) {
throw e;
}
}
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whiteList, requestURI);
}
}
WebConfig
Configuration 클래스에서 이렇게 필터를 등록해줄 수 있다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<Filter> loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
Controller
온라인 도서관에서는 권한 없음 페이지가 존재한다. 따라서, 권한 없음에 redirectUrl을 넣어서 이동시킨 다음, 로그인 폼 링크 버튼을 넣어주고, 거기에도 redirectUrl을 넣어서 로그인 폼까지 가져가게 한 뒤에, 로그인 post요청에까지 가져가서 나중에 로그인 후에 redirectUrl로 이동하도록 했다.
public class AccessDeniedController {
@GetMapping("/access-denied")
public String accessDenied(@RequestParam(name = "redirectUrl", defaultValue = "/") String redirectUrl, Model model) {
model.addAttribute("redirectUrl", redirectUrl);
return "access-denied";
}
}
@PostMapping("/login")
public String login(HttpSession session, @Validated @ModelAttribute("user") LoginUserDto user, BindingResult bindingResult,
@RequestParam(value = "redirectUrl", defaultValue = "/") String redirectUrl) {
log.debug("objectName={}", bindingResult.getObjectName()); // loginUserDto로 나오고 있었다. @ModelAttribute("user")로 해결
log.debug("target={}", bindingResult.getTarget()); // 정상적으로 LoginUserDto 인스턴스를 찾아옴.
log.debug("Input User DTO: {}", user);
/* 검증 실행 */
loginValidator.validate(user, bindingResult);
/* 검증에 에러가 발견되면, 폼을 보여줌. */
if (bindingResult.hasErrors()) {
log.debug("errors={}", bindingResult);
return "home/login";
}
/* 검증이 끝나면, 컨트롤러에서 로그인 처리 */
userService.login(session, user);
/* 로그인 후에 redirectUrl로 리다이렉트 */
return "redirect:" + redirectUrl;
}
js에서 문제 발생
restApi를 처리하는 경우, 문제가 발생했다. sendRedirect의 경우 html을 보내주기 때문에, 응답을 json으로 처리하던 곳에서 문제가 발생했다.
export const fetchRequest = async (url, method, body = null) => {
resetErrorFields();
const options = {
method: method,
headers: {
"Content-Type": "application/json",
},
redirect : "m"
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const responseJson = await response.json();
const error = new Error(`HTTP Error! status: ${response.status}, message: ${responseJson}`);
error.status = response.status;
error.response = responseJson;
throw error;
}
return await response.json();
};
처음에는, 302 status 코드를 잡아서 처리하려고 했지만, 자동으로 redirect처리가 되기 때문에 처리가 어려웠다.
그러면 리다이렉트 처리가 되는 게 아닌가?
fetch 함수의 경우 redircet를 page가 아닌 보통 JSON, XML, 또는 단순 텍스트 같은 데이터 형식을 기대하기 때문에, 브라우저 주소창이 이동되지 않는다. 리다이렉트를 직접 처리하려면 option에 redirect: ‘manual’를 추가하라고 했지만, fetch가 기대하는게 html 문서가 아니기 때문에 불가했다.
Content-Type을 잡자
문제는, 마지막에 return await response.json(); 를 무조건 해주기 때문에 발생한다. text/html 타입을 application/json 타입처럼 처리하려고 하니 예외가 발생하는 것이었다.
그래서, 중간에 Content-Type을 체크해서 { redirect : true } 라는 object를 반환해주었다.
// 페이지가 리다이렉트된 경우 페이지 이동
const contentType = response.headers.get("Content-Type");
if (contentType.startsWith("text/html")) {
location.href = response.url;
return { redirected: true }; // 리다이렉트를 명시적으로 반환
}
fetchRequest 호출
fetchRequest 함수를 호출하는 함수에서는, 이제 data.redirect를 체크해서 리다이렉트된지를 로그를 찍어주게 되었고, 정상 호출된 경우 alret를 띄워준다.
export const rent = async (id, errorContainer) => {
try {
const data = await fetchRequest(`/books/${id}/rent`, "POST");
if (data.redirected) {
console.log("페이지가 리다이렉트되었습니다.");
return;
}
alert("결과 : " + data.message);
} catch (e) {
handleError(e, errorContainer);
}
};
정리
이 방법은 정상 흐름은 아니다. 단, 서블릿 필터를 사용한다는데 의의를 두었고, 예상 못한 예외 상황(fetch 함수에서 html redirect 처리하기)를 만날 수 있어서 도움이 됐다.