로그인을 하기 위한 Controller와 Service를 만들어보자.
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
/**
* @return null이면 로그인 실패
*/
public Member login(String loginId, String password) {
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult
bindingResult) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(),
form.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공 처리 TODO
return "redirect:/";
}
}
브라우저에 접근할 때마다 로그인하면 사용자 입장에서 번거롭기 때문에 로그인 상태를 유지해야 한다. 바로 쿠키를 사용해서 로그인 상태를 유지할 수 있다.
쿠키를 사용하여 동작하는 예시
서버에서 로그인하여 성공하면 서버는 HTTP응답에 쿠키를 담아서 브라우저에 전달한다.
그 후 브라우저에서 서버로 모든 요청은 쿠키를 지속해서 보내준다.
쿠키 종류
로그인 성공시 세션 쿠키를 생성하는 코드
@PostMapping("/login")
public String login(@Valid @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult, HttpServletResponse response){
if(bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(loginMember == null){
bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공 처리
//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료 시 모두 종료)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
로그인에 성공하면 쿠키를 생성하고 HttpServletResponse에 담는다.
쿠키 이름은 memberId이고, 같은 회원의 id를 담아둔다.
웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내준다.
쿠키를 사용해서 로그인Id를 전달하여 로그인을 유지할 수 있다. 하지만 이는 다음과 같은 보안 문제에 취약하다.
이 같은 문제를 해결하기 위해 다음과 같은 해결책을 알아보자.
중요한 정보는 모두 서버에 저장하고 클라이언트-서버는 추정 불가능한 임의의 랜덤값으로 연결하여 보안 문제를 해결할 수 있다.
세션 동작 방식
웹 브라우저가 로그인하면 서버에서 db를 조회하여 사용자 검증을 한다.
이 때 로그인이 됐다면 세션 ID를 생성하는데 임의의 랜덤 값으로 설정 후 서버의 세션 저장소에 저장한다.
이후 서버는 클라이언트에 랜덤한 값으로 설정한 sessionId를 쿠키에 담아 전달해주고 클라이언트는 쿠키 저장소에 쿠키를 보관한다.
(랜덤값 외의 다른 회원 정보는 저장해주지 않음!! 즉 서버-클라이언트는 랜덤 값만으로 통신)
이후 클라이언트는 서버에 요청 시 쿠키 저장소에 저장되어 있는 쿠키를 항상 같이 보내고 서버는 db를 조회하지 않고 세션 저장소를 찾아 로그인을 해준다.
보안 문제 해결
@GetMapping("/")
public String homeLoginV3Spring(
@SessionAttribute(name= SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model){
//세션에 회원 데이터가 없으면 home
if(loginMember == null){
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
세션 타임아웃 설정
세션은 사용자가 로그아웃을 직접 호출하여 session.invalidate()가 호출 될 경우에 삭제되는데 일반적인 사용자들은 로그아웃 버튼을 누르지 않고 브라우저를 종료한다.
따라서 서버에서 자동으로 세션을 삭제해야 하는데 이 때 세션 타임아웃을 설정해줄 수 있다.
세션 타임아웃을 30분으로 설정했다고 30분 마다 재로그인을 하면 번거로울 것이다. 따라서 HTTP 요청(다른 페이지로 이동 등)이 있으면 현재 시간으로 세션이 다시 초기화된다.
하지만 문제가 있다. items 항목 같은 경우 로그인을 해야 볼 수 있는데 url로 접근하면 로그인하지 않은 사용자도 볼 수 있는 것이다. 어떻게 해결할까?
이 문제는 상품 관리 컨트롤러 뿐만 아니라 수정, 등록 조회까지 공통되는 문제이므로 하나로 관리하면 효율적이다.
바로 서블릿 필터, or 스프링 인터셉터를 통해 공통 관심사(접근 기능)를 처리할 수 있다.
필터 흐름 : HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러
필터 제한
필터 체인 : HTTP 요청 → WAS → 필터1 → 필터2 → 필터3 → 서블릿 → 컨트롤러
필터는 체인으로 구성되는데 중간에 필터를 추가할 수 있다.(필터1은 로그인 남김, 필터 2는 로그인 여부 체크 등)
필터 인터페이스
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() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출
필터 등록 방법
@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를 통해 필터를 등록할 수 있다.
서블릿 필터 - 인증 체크
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
@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);
//로그인으로 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);
}
}
스프링 인터셉터도 위에서 사용한 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다.
스프링 인터셉터 흐름 : HTTP 요청 → WAS → 필터 → 서블릿 → 스프링 인터셉터 → 컨트롤러
스프링 인터셉터 제한
스프링 인터셉터 체인 : HTTP 요청 → WAS → 필터 → 서블릿 → 인터셉터1 → 인터셉터2 → 컨트롤러
서블릿 필터와 마찬가지로 중간에 인터셉터를 추가할 수 있다.
스프링 인터셉터 인터페이스
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler) throws Exception {
}
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) throwsException {
}
}
스프링 인터셉터 정상 호출
스프링 인터셉트 예외
인터셉트는 스프링 MVC 구조에 특화된 필터 기능 제공. 스프링 MVC를 사용하고 특별히 필터 기능을 사용하는 상황이 아니면 인터셉터를 사용하는 것이 편리.
스프링 인터셉터 - 인증체크
서블릿 필터에서 사용한 인증 체크 기능을 스프링 인터셉터로 개발
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@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;
}
return true;
}
}
인증은 컨트롤러 호출 전에만 호출하면 되므로 preHandle만 구현하면 된다.
WebConfig에 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@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", "/logout",
"/css/**", "/*.ico", "/error");
}
결론
서블릿 필터와 스프링 인터셉터는 웹과 관련된 공통 관심사를 해결하기 위한 기술이다. 서블릿 필터보다 스프링 인터셉터가 개발자 입장에서 사용하기 편리하므로 인터셉터를 사용하자