스프링 로그인 처리

mark1106·2024년 1월 4일
0

spring

목록 보기
4/5
post-thumbnail

로그인을 하기 위한 Controller와 Service를 만들어보자.

  • LoginService
@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);
    }
}
  • LoginController
@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:/";
    }
}
  1. LoginController로 들어올 때 바인딩조차 되지 않으면 로그인 폼으로 다시 돌려보낸다.
  2. 바인딩이 됐다면 로그인 서비스를 호출해서 loginMember가 유효한지 검증하고 유효하다면 홈 화면으로, 유효하지 않은 아이디라면 로그인 화면으로 보내준다.

로그인 처리 - 쿠키 사용

브라우저에 접근할 때마다 로그인하면 사용자 입장에서 번거롭기 때문에 로그인 상태를 유지해야 한다. 바로 쿠키를 사용해서 로그인 상태를 유지할 수 있다.

쿠키를 사용하여 동작하는 예시


서버에서 로그인하여 성공하면 서버는 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:/";
    }
  1. 로그인에 성공하면 쿠키를 생성하고 HttpServletResponse에 담는다.

  2. 쿠키 이름은 memberId이고, 같은 회원의 id를 담아둔다.

  3. 웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내준다.

쿠키의 보안 문제

쿠키를 사용해서 로그인Id를 전달하여 로그인을 유지할 수 있다. 하지만 이는 다음과 같은 보안 문제에 취약하다.

  • 쿠키 값은 임의로 변경 가능 : 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다 : 쿠키의 정보가 내 로컬PC, 혹은 네트워크 전송 구간에서 털릴 수 있다.
  • 해커가 쿠키를 훔쳐가면 평생 사용할 수 있다.

이 같은 문제를 해결하기 위해 다음과 같은 해결책을 알아보자.

  • 쿠키에 중요한 값을 노출하지 않고 임의의 랜덤 값을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식.
  • 토큰을 해킹당해도 시간이 지나면 사용할 수 없도록 서버에서 토큰의 만료 시간을 짧게 설정한다. (해킹 의심이 되는 경우 토큰을 강제로 제거)

중요한 정보는 모두 서버에 저장하고 클라이언트-서버는 추정 불가능한 임의의 랜덤값으로 연결하여 보안 문제를 해결할 수 있다.

세션 동작 방식

웹 브라우저가 로그인하면 서버에서 db를 조회하여 사용자 검증을 한다.

이 때 로그인이 됐다면 세션 ID를 생성하는데 임의의 랜덤 값으로 설정 후 서버의 세션 저장소에 저장한다.


이후 서버는 클라이언트에 랜덤한 값으로 설정한 sessionId를 쿠키에 담아 전달해주고 클라이언트는 쿠키 저장소에 쿠키를 보관한다.

(랜덤값 외의 다른 회원 정보는 저장해주지 않음!! 즉 서버-클라이언트는 랜덤 값만으로 통신)

이후 클라이언트는 서버에 요청 시 쿠키 저장소에 저장되어 있는 쿠키를 항상 같이 보내고 서버는 db를 조회하지 않고 세션 저장소를 찾아 로그인을 해준다.

보안 문제 해결

  • 쿠키 값 변조 → 임의의 랜덤 값으로 복잡한 세션 Id 사용
  • 쿠키 정보 해킹 → 쿠키에는 임의의 랜덤Id만 있고 중요한 정보는 없음
  • 쿠키 해킹 후 사용 → 세션 만료 시간을 짧게 설정하거나 해킹 의심 시 세션 제거

로그인 처리하기 - 서블릿 http세션

@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()가 호출 될 경우에 삭제되는데 일반적인 사용자들은 로그아웃 버튼을 누르지 않고 브라우저를 종료한다.

따라서 서버에서 자동으로 세션을 삭제해야 하는데 이 때 세션 타임아웃을 설정해줄 수 있다.

  • 세션 타임아웃 설정 방법
    1. 글로벌 설정 : application.properties에 server.servlet.session.timeout=60 (모든 세션의 기본 값, 이 때 단위는 second)
    2. 특정 세션 단위 : session.setMaxInactiveInterval(1800); //1800초

세션 타임아웃을 30분으로 설정했다고 30분 마다 재로그인을 하면 번거로울 것이다. 따라서 HTTP 요청(다른 페이지로 이동 등)이 있으면 현재 시간으로 세션이 다시 초기화된다.

로그인 처리 - 필터

하지만 문제가 있다. items 항목 같은 경우 로그인을 해야 볼 수 있는데 url로 접근하면 로그인하지 않은 사용자도 볼 수 있는 것이다. 어떻게 해결할까?

이 문제는 상품 관리 컨트롤러 뿐만 아니라 수정, 등록 조회까지 공통되는 문제이므로 하나로 관리하면 효율적이다.

바로 서블릿 필터, or 스프링 인터셉터를 통해 공통 관심사(접근 기능)를 처리할 수 있다.

서블릿 필터

필터 흐름 : HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러

필터 제한

  • 로그인 사용자 : HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러
  • 비 로그인 사용자 : HTTP 요청 → WAS → 필터(적절하지 않은 요청이라 판단, 서블릿 호출 x)

필터 체인 : 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를 통해 필터를 등록할 수 있다.

  • setFilter(new LogFilter) : 등록할 필터를 지정
  • setOrder(1) : 필터는 체인으로 동작하므로 순서 필요, 낮을수록 먼저 동작
  • addUrlPatterns(”/*”) : 필터를 적용할 URL 패턴을 지정한다. 한번에 여러 패턴을 지정할 수 있다.

서블릿 필터 - 인증 체크

@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);
    }
}
  • whitelist = {"/", "/members/add", "/login", "/logout","/css/*"}; : 인증 필터를 적용해도 홈, 회원가입, 로그인 화면은 인증과 무관하게 접근할 수 있어야한다. 따라서 화이트 리스트를 제외한 나머지 경로에만 인증 체크 로직을 적용한다.
  • httpResponse.sendRedirect("/login?redirectURL=" + requestURI) : 미인증 사용자는 로그인 화면으로 redirect한다. 사용자는 로그인하면 바로 접속하려는 화면을 보여주는 것이 좋은 서비스이다. 따라서 requestURI/login에 쿼리 파라미터로 함께 전달한다.
  • return : 필터를 더 이상 진행하지 않고 미인증 사용자는 로그인 화면으로 보낸다.

스프링 인터셉터

스프링 인터셉터도 위에서 사용한 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다.

스프링 인터셉터 흐름 : HTTP 요청 → WAS → 필터 → 서블릿 → 스프링 인터셉터 → 컨트롤러

스프링 인터셉터 제한

  • 로그인 사용자 : HTTP 요청 → WAS → 필터 → 서블릿 → 스프링 인터셉터 → 컨트롤러
  • 비 로그인 사용자 : HTTP 요청 → WAS → 필터 → 서블릿 → 스프링 인터셉터(적절하지 않은 요청이라 판단, 서블릿 호출 x)

스프링 인터셉터 체인 : 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 {

	}
}

스프링 인터셉터 정상 호출

  • preHandle : 컨트롤러 전에 호출됨(preHandle의 응답값이 true면 다음 진행, false면 끝남)
  • postHandle : 컨트롤러 호출 후 호출
  • afterCompletion : 뷰가 렌더링 된 이후에 호출

스프링 인터셉트 예외

  • preHandle : 컨트롤러 전에 호출됨
  • postHandle : 컨트롤러에서 예외 발생 시 postHandle은 호출 x
  • afterCompletion : afterCompletion은 항상 호출. 예외(ex)를 파라미터로 받아 예외 알 수 있음

인터셉트는 스프링 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");

    }
  • registry.addInterceptor(new LoginCheckInterceptor()) : 인터셉터 등록
  • order(2) : 인터셉터의 호출 순서 지정
  • addPathPatterns(”/”)** : 인터셉터를 적용할 URL 패턴 지정
  • excludePathPatterns("/css/", "/*.ico","/error") **: 홈, 회원가입, 로그인 등은 인증 여부를 확인하지 않아도 되므로 제외

결론

서블릿 필터스프링 인터셉터웹과 관련된 공통 관심사를 해결하기 위한 기술이다. 서블릿 필터보다 스프링 인터셉터가 개발자 입장에서 사용하기 편리하므로 인터셉터를 사용하자


📚 참고 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 by 김영한
profile
뒤돌아보면 남는 것은 사진, 그리고 기록 뿐

0개의 댓글