Spring Security 2단계 인증 적용하기

junto·2024년 6월 30일
0

spring

목록 보기
22/30
post-thumbnail

  • 현재 인증 로직은 위와 같다. 추가로 2차 인증 필터를 적용해서 사용자가 Totp인증을 활성화한 경우 로그인 후 Totp 인증을 진행하고, 새로운 IP에서 로그인했을 때는 이메일 추가 인증을 진행하는 기능을 구현하기로 했다.

Two-Factor-Auth Filter

  • LoginFilter 뒤에 두어 로그인이 완료되고, 추가 인증을 거치도록 한다.
  • 로그인 앤드포인트가 아니라면 해당 필터를 통과하게 한다. Totp 활성화한 유저에게는 Totp 인증을 하는 url로, 새로운 IP로 로그인한 유저에게는 Email 추가 인증을 하는 url로 redirect한다.
 @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
    FilterChain filterChain) throws ServletException, IOException {

    String requestURI = request.getRequestURI();

    if (!requestURI.equals("/api/v1/auth/login")) {
      filterChain.doFilter(request, response);
      return;
    }

    ResSecurityContextHolder sh = getSecurityContextHolder();

    if (sh.totpEnabled()) {

      setRefreshTokenByCookie(response, sh);

      setCookieAndRedirectUrl(response, "https://spacestory.duckdns.org/login/2fa/totp");
      return;
    }

    if (!sh.ipAddresses().contains(request.getRemoteAddr())) {

      emailVerificationService.sendCode(sh.email(), false);

      setRefreshTokenByCookie(response, sh);

      setCookieAndRedirectUrl(response, "https://spacestory.duckdns.org/login/2fa/email");
    }
  }
  • redirect url을 전송 후에 클라이언트는 인증 코드 검증 API를 호출한다. 해당 API에서 인증이 성공하면 토큰까지 발급한다. 그림으로 나타내면 아래와 같다.

1. 왜 response.sendRedirect를 사용하지 않았을까?

  • 클라이언트는 login 요청 결과를 받아 응답 메시지에 온 토큰을 쿠키에 저장한다. 그런데, 추가 필터에서 redirect를 해도 로그인이 성공했다면 백엔드가 redirect한 url로 이동하지 않고, 토큰 발행 후 메인 페이지로 리다이렉트 한다. 기존 인증 방식과 통합하기 위해 응답 바디로 redirectUrl을 보냈고, 클라이언트에서 요청 바디에 어떤 파라미터가 담기는지에 따라 인증 로직을 달리했다.
if (response.data.redirectUrl) {
  window.location.href = response.data.redirectUrl;
} else {
  const { accessToken, refreshToken } = response.data;
  // 쿠키 설정
  navigate("/");
}

2. 2차 인증 전에 로그인 성공했다는 것을 어떻게 보장할까?

  • 예를 들어, Totp 인증 코드를 보내는 url이 /login/totp/verify라고 하자. 악의적인 사용자가 해당 url에서 사용자 이메일과 totp 인증 코드로 인증에 성공하여 토큰을 발행받는 상황을 쉽게 생각할 수 있다. 어떻게 방지할까?
  • 사용자가 로그인에 성공했을 때 쿠키(Http only, Secure)로 임시 RefreshToken을 발행해주었다. 서버는 클라이언트 요청을 처리하기 전에 RefreshToken의 유효성을 검증한다. RefreshToken이 유효하다면 사용자가 성공적으로 로그인했다는 것을 보장할 수 있다.

3. 필터로만 처리하는 경우와 컨트롤러를 섞는 경우

  • 2단계 인증 필터를 적용할 때, 인증 코드를 담은 요청을 보낼 때 이를 컨트롤러에서 처리할 수도 있고, 필터에서 처리할 수도 있다. 직관적으로 컨트롤러를 호출하게 해서 처리했지만, 필터에서도 충분히 처리할 수 있다. 아직 각 방식의 차이점을 크게는 잘 못 느끼겠다.

4. 기타

1. 이벤트 방식

  • gmail로 인증 코드를 보낼 때 이벤트 방식을 사용하지 않으면, 외부 서비스가 실행이 완료될 때까지 메인 쓰레드는 기다리게 된다. 외부 서비스 응답이 길어질 때 사용자는 애플리케이션이 멈췄다고 느낄 수 있다.
  • ApplicationEventPublisher를 적용할 수 있고, Kafka, rabbitmq, redis를 사용할 수 있는데 무엇이 어떤 장점이 있는지 알아봐야 한다.

2. 사용자 공용 IP

  • request.getRemoteAddr() 을 호출하면 사용자 공용 IP가 아니라 개인 IP를 아는 것이다. 개인 IP가 아닌 공용 IP를 저장해야 한다.

3. 시큐리티 필터에서 응답 메시지를 쓴 후 다음 필터로 이동

java.lang.IllegalStateException: getWriter() has already been called for this response
	at org.apache.catalina.connector.Response.getOutputStream(Response.java:502) ~[tomcat-embed-core-10.1.24.jar:10.1.24]
  • 응답 메시지를 기록한 후 다음 필터로 이동하면 response를 이미 호출했다는 에러가 발생한다. 로그인이 성공했고 토큰을 발행했다면 필터를 멈추고 추가 인증을 해야 한다면 다음 필터를 호출한다. 즉, 분기 처리해서 response를 두 번 호출하지 않도록 한다.

4. Axios Credentials

  • 클라이언트가 요청에 쿠키를 보낼 때 withCredentials: true 옵션을 활성화해야 한다.
  • 서버에서 설정한 쿠키를 수신할 때도 마찬가지다. 엄한 곳에서 디버깅을 했었다..

5. 유저가 로그인 실패 시 로그인 횟수 기록 및 계정 잠금

  • 유저가 로그인을 5번 연속으로 실패했을 때 보안상 계정을 1시간 동안 잠그는 기능을 추가했다.
  • 직접 메모리에 유저가 로그인 실패했을 때마다 기록을 해도 되지만, 구글 구아바를 이용하면 더 편리하게 사용할 수 있다. 자동으로 캐시 만료, 캐시 카운팅 기능이 제공되기 때문이다.
  • Security Filter에서 인증에 실패했을 때 인증 실패 카운팅을 하기 위해선 아래 함수에서 유저 이메일을 가져와야 한다. 어떻게 가져올 수 있을까? 로그인 시 body에 담긴 email과 password를 읽을 때 HttpServletRequest에 "email" attribute에 이메일을 저장하고, 필요할 때 불러올 수 있도록 한다.
  @Override
  protected void unsuccessfulAuthentication(
      HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
      throws IOException {
      
     
	  loginAttemptService.loginFailed(request.getAttribute("email").toString());
}
profile
꾸준하게

0개의 댓글