클라이언트의 요구 사항에 따라 로그인 시도 실패 횟수를 기록하고, 5회 이상 실패한 사용자의 경우 계정을 제한하는 비기능 요구사항이 있었습니다.
이번 포스팅에서는 이러한 기능을 구현하기 위해 어떻게 Spring Security를 활용하였는기 기술해보도록 하겠습니다.
위 비기능 요구사항을 처리하기위해 구현한 LoginAttemptFilter는 OncePerRequestFilter를 상속받아 모든 요청에 대해 단 한 번만 실행되는 필터입니다.
이 필터는 로그인 시도 시 사용자의 실패 기록을 확인하고, 실패 횟수가 일정 기준을 초과하면 계정을 잠그는 역할을 합니다.
UsernamePasswordAuthenticationToken)을 생성해 SecurityContextHolder에 설정합니다.이번 구현에서 활용된 Spring Security의 핵심 요소들을 자세히 살펴보겠습니다.
UsernamePasswordAuthenticationToken을 SecurityContextHolder에 설정함으로써, 이후 요청 처리 시 해당 사용자가 인증된 상태로 인식되도록 합니다.아래는 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값을 어떻게 해야 효과적으로 보안 로직을 구현하고 유저를 제한할 수 있을지에 대해 다음과 같이 고민해보았습니다.
브라우저 환경이나 디바이스에 관계없이 일관된 식별이 가능합니다.
클라이언트 식별의 기본적인 방법으로, 서버 측에서 쉽게 획득 가능합니다.
보안 시스템에서는 정교함을 추구할수록 예상치 못한 부작용이 발생할 가능성이 높아진다는 깨달음을 얻었습니다. 로그인 제한 시스템의 목적은 무차별 대입 공격 방지이므로, 이 목적에 가장 부합하면서도 사용자 경험을 해치지 않는 식별자로 '로그인 시도 아이디'를 선택하게 된 것 같습니다.
명확한 보안 목표를 설정하여 실용적 구현 방식을 고려하고, 적절하게 적용하는 것이 효과적인 보안 솔루션을 만들 수 있음을 알게 되었습니다 :)!