[Spring Security] 로그인 시도 기록을 통해 제한된 유저 사용자 처리하기

joowonseo·2025년 3월 6일
0

클라이언트의 요구 사항에 따라 로그인 시도 실패 횟수를 기록하고, 5회 이상 실패한 사용자의 경우 계정을 제한하는 비기능 요구사항이 있었습니다.
이번 포스팅에서는 이러한 기능을 구현하기 위해 어떻게 Spring Security를 활용하였는기 기술해보도록 하겠습니다.

🔒 로그인 시도 실패를 기록 및 제한된 유저를 처리하는 filter 구현

위 비기능 요구사항을 처리하기위해 구현한 LoginAttemptFilterOncePerRequestFilter를 상속받아 모든 요청에 대해 단 한 번만 실행되는 필터입니다.
이 필터는 로그인 시도 시 사용자의 실패 기록을 확인하고, 실패 횟수가 일정 기준을 초과하면 계정을 잠그는 역할을 합니다.

동작 흐름

  • 요청 URL 검증: 필터는 먼저 요청 URI가 인증이 필요 없는(로그인 및 토큰 재발급 등의 익명이 필요한) 엔드포인트인지 확인합니다.
  • 계정 잠금 상태 체크: 로그인 시도 시, 요청 파라미터로 전달되는 사용자 별칭(nickname)을 기반으로 해당 계정의 잠금 여부를 확인합니다.
  • 예외 처리: 계정이 잠겨 있거나, 다른 인증 관련 예외가 발생하면 필터에서 즉시 응답을 반환하여 추가 처리를 차단합니다.
  • Spring Security 인증 설정: 예외가 발생하지 않은 경우, 임시 인증 토큰(UsernamePasswordAuthenticationToken)을 생성해 SecurityContextHolder에 설정합니다.
  • 필터 체인 호출: 이후 요청은 다음 필터로 전달되어 정상적인 처리가 이루어집니다.

주요 Spring Security 개념 설명

이번 구현에서 활용된 Spring Security의 핵심 요소들을 자세히 살펴보겠습니다.

a. OncePerRequestFilter

  • Spring Security의 필터 중 하나로, 한 요청 당 단 한 번만 실행되도록 보장합니다.
  • 여러 번 실행되어 중복 처리되는 것을 방지합니다.

b. UsernamePasswordAuthenticationToken

  • Spring Security에서 인증 정보를 담기 위해 사용되는 토큰 객체입니다.
  • 구성
    • Principal: 사용자 이름이나 ID 등 인증 주체를 나타냅니다.
    • Credentials: 패스워드와 같이 인증에 필요한 정보(여기서는 null로 처리).
    • Authorities: 사용자의 권한 목록.
  • 사용 이유
    다음 필터 체인으로 진행시키기 위해 임시로 인증 객체를 생성하여, 이후 Spring Security의 컨텍스트에 저장하고 인증된 상태를 유지합니다.

c. SecurityContextHolder

  • 애플리케이션 전반에서 현재 인증 정보를 저장하고 접근할 수 있도록 도와주는 컨테이너입니다.
  • 사용 이유
    필터에서 생성된 UsernamePasswordAuthenticationTokenSecurityContextHolder에 설정함으로써, 이후 요청 처리 시 해당 사용자가 인증된 상태로 인식되도록 합니다.

LoginAttemptFilter 구현 코드

아래는 LoginAttemptFilter의 핵심 코드입니다.

@RequiredArgsConstructor
@Slf4j
public class LoginAttemptFilter extends OncePerRequestFilter {

    private final CheckAccountLockStatusUseCase checkAccountLockStatusUseCase;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            if (Arrays.stream(ANONYMOUS_ENDPOINTS)
                    .anyMatch(endpoint -> new AntPathMatcher().match(endpoint, request.getRequestURI()))) {
                String nickname = request.getParameter("nickname");
                checkAccountLockStatusUseCase.checkAccountIsLocked(nickname);
            }
        } catch (AuthException e) {
            log.warn("Authentication failed for IP: {}. Error: {}", getClientIp(request), e.getMessage());
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write(e.getErrorCode().getCustomCode());
            log.info("Sent error response: {}", e.getErrorCode().getCustomCode());
            return;
        }

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken("user", null, new ArrayList<>());
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        filterChain.doFilter(request, response);
    }
}
  • 익명 엔드포인트에 대해서만 처리
    ANONYMOUS_ENDPOINTS 배열에 포함된 엔드포인트에 대해서만, nickname 파라미터를 통해 계정 잠금 여부를 확인합니다.
  • 예외 처리
    AuthException 발생 시, 로그를 기록하고 클라이언트에 Unauthorized 상태(401)와 함께 커스텀 에러 코드를 응답합니다.
  • 인증 토큰 생성
    인증이 성공하면, UsernamePasswordAuthenticationToken 객체를 생성해 SecurityContextHolder에 설정합니다.
  • 필터 체인 진행
    인증 후, filterChain.doFilter(request, response)를 호출하여 다음 필터나 실제 요청 처리로 흐름을 넘깁니다.

🤔 로그인 시도 식별자는 무엇으로 해야할까?

영구적으로 관리되어야 하는 데이터가 아니며 빠른 조회를 위해 Redis에 로그인 실패 시도에 대한 정보를 기록하였습니다.
redis에 저장할 데이터에 대해 key값을 어떻게 해야 효과적으로 보안 로직을 구현하고 유저를 제한할 수 있을지에 대해 다음과 같이 고민해보았습니다.

a. 사용자 식별용 토큰

Why?

브라우저 환경이나 디바이스에 관계없이 일관된 식별이 가능합니다.

But

  • 토큰 갱신 주기에 따른 식별 연속성 문제가 발생합니다.
  • 인증용 토큰(액세스 토큰) 외에 별도의 통신용 토큰 관리 방식이 필요하여 복잡성이 증가합니다.
  • 토큰 관리 실패 시 정상적인 로그인 시도마저 차단될 위험이 있습니다.

b. IP 주소

Why?

클라이언트 식별의 기본적인 방법으로, 서버 측에서 쉽게 획득 가능합니다.

But

  • QA 기간 동안 테스트를 진행하며, 같은 공간에서 여러 번의 로그인 실패가 동일 IP 대역을 사용하는 다른 정상 사용자들의 로그인까지 차단하는 문제가 발생하였습니다.

c. 로그인 시도 아이디 (최종 선택)

Why?

  • 로그인 시도의 가장 명확한 식별자로, 특정 계정에 대한 무차별 대입 공격 방지에 효과적입니다.
  • 구현이 단순하고 직관적이며, 예측 가능한 동작 방식을 제공하기 때문에 시스템 복잡성을 최소화합니다.

But

  • 하지만 악의적인 공격자가 의도적으로 특정 사용자의 계정을 차단하는 서비스 거부 공격이 가능한 점이 여전히 문제입니다.
  • 추가적으로 이메일 확인, 2FA 등의 2차 확인의 사용자 구제 매커니즘을 사용하면 이러한 문제를 보완할 수 있을 것 같습니다.

마무리하며

보안 시스템에서는 정교함을 추구할수록 예상치 못한 부작용이 발생할 가능성이 높아진다는 깨달음을 얻었습니다. 로그인 제한 시스템의 목적은 무차별 대입 공격 방지이므로, 이 목적에 가장 부합하면서도 사용자 경험을 해치지 않는 식별자로 '로그인 시도 아이디'를 선택하게 된 것 같습니다.

명확한 보안 목표를 설정하여 실용적 구현 방식을 고려하고, 적절하게 적용하는 것이 효과적인 보안 솔루션을 만들 수 있음을 알게 되었습니다 :)!

profile
백엔드 개발자 ˚₊✩‧₊ ໒꒱

0개의 댓글