[프로젝트] 로그인 실패 횟수 트래킹 1편 서비스 구현하기

조찬영·2023년 9월 24일
0

들어가기 앞서


기술 블로그들을 찾아보고 여러 글을 읽던 중에 우연히 [우아한 기술 블로그]에서 백엔드 관련 주의해야 할 사항들에 대해서 정리가 된 글을 보았습니다.

여러 좋은 내용중에서도 저에게 특히나 인상 깊었던 내용은 사용자의 로그인 실패 횟수를 트래킹하고, 일정 횟수 이상 실패시 로그인을 거부하거나 captcah를 요구해야하는 이유에 대한 내용이었는데요.

해당 글을 요약하자면 내용은 이렇습니다.

  • 서비스의 규모가 커짐에 따라 사용자 정보의 가치성이 높아지고 그에 따른 해커의 서버 공격 횟수가 늘어난다.
  • 클라이언트의 저장소(쿠키)는 변조의 위험이 있기에 사용자 입력 검증은 서버에서 해야 한다.
  • 서버에서는 그에 따른 부정 로그인 방지 시스템을 구축할 수 있어야 한다.

현재 개인 프로젝트를 진행하면서 로그인 관련 보안적인 부분에 대해서 고민이 있었던 찰나에 해당 글을 읽게 되었고,
그래서인지 더 관심이가고 흥미롭게 볼 수 있었던 주제였던 것 같습니다.

🤔 내 프로젝트에 도입할 수 있지 않을까?


프로젝트 규모상 조금은 시기상조일 수 있다는 생각이 들었지만 ,
글을 읽고 프로젝트에 바로 도입해보는 경험 자체로서 큰 의미가 있을거라고 생각했습니다. (미리 보는 후기 : 스스로가 성장했다고 느낄만큼 고민하고 생각해야 할 부분들이 굉장히 많았다.)


1. 필요 기술들 및 라이브러리 (예상)


먼저 현재 프로젝트 환경에서 주요하게 사용하게 될 기술들 혹은 앞으로 필요할 기술들을 정리해 보았습니다.

  • Java 17
  • Spring boot 2.7
  • Spring Security
  • Redis

위의 4가지 기술들이 주로 사용될 거라 예상하고 있으며
핵심적인 역할은 로그인의 성공과 실패 여부를 판별하고 인증해줄 Spring Security로그인 실패 횟수를 저장할 수 있는 redis가 될 것 같습니다.

또한 필요한 라이브러리는 다음과 같습니다.

  • JavaMail

일정 횟수 이상 로그인 실패시 이메일로 인증 발송을 할 예정이기에 javaMail 라이브러리를 활용하려 합니다.


2. 로그인 실패를 트래킹하는 2가지 방법

로그인 실패를 트래킹하는 방법에는 여러 가지 방법이 있겠지만
저는 2가지 방법으로 범위를 좁혀봤고 그 기준을 로그인 인증 방식으로 세워봤습니다.

  1. Spring Security 의 FormLogin 을 활용한 로그인 인증 방식
  2. JWT 토큰 방식으로 메서드 내부에서 로그인 인증 직접 구현

1번의 방법같은 경우 Failury Handler 를 통해 실패에 대한 로직을 catch하고 이에 따른 조치를 취할 수 있습니다.

조금 더 자세히 살펴보겠습니다.

2.1 Spring Security Failury Handler

Spring Security 에서 로그인의 성공과 실패 여부를 파악해야 하는데요.
전체적인 흐름도는 다음과 같습니다.

UserDatailsServiceAuthenticatonProivder 으로 사용자를 인증하고 사용자가 성공적으로 인증되면 SuccessHandler를 호출하고 그렇지 않다면 FailureHandler를 호출합니다.

만약 로그인 실패에 대한 대비책을 마련한다면 AuthenticationFailureHandler 인터페이스를 구현하여 원하는 사용자 지정 동작을 직접 구현할 수 있습니다.
FailureHandler에 대해서는 해당 레퍼런스를 참조하실 수 있습니다.

2.2 자체 메서드에서 인증 구현

JWT 토큰 방식에서는 사용자 로그인 후 JWT 토큰을 생성하고 반환하는 과정을 필요로 하는데 이 과정에서 대개 로그인 요청을 직접적으로 처리하고 있습니다.

즉, 시큐리티에서 제공하는 credential 인증이 아닌 커스텀하게 만든 예외를 가지고 유저를 인증하는 방식입니다.

제 프로젝트 또한 JWT 토큰 방식으로서 로그인 인증을 하고 있었기에
직접 구현한 로그인 메서드 내부에서 로그인 실패 횟수를 트래킹하는 작업을 해보겠습습니다.
(여기서 관건은 "아마도 전보다 규모가 커질 로그인 코드를 어떻게 깔끔하게 만들 수 있을까?" 일 것 같습니다.)


3. 로그인 실패 횟수 트래킹 하기


3.1 LoginFailedRepository 생성

우선 다음과 같이 redisTemplate를 이용하여 LoginFailedRepository 을 생성해 주었습니다.

LoginFailedRepository

@Repository
@RequiredArgsConstructor
@Slf4j
public class LoginFailedRepository {

    private final RedisTemplate<String, Object> redisTemplate;
    private final Long TIME_TO_LIVE = 86400000L;
    private static final String PREFIX_FOR_KEY = "ULF: ";

    public void setValue(String username, Integer failedCount) {
        String key = getKey(username);
        redisTemplate.opsForValue().set(key, failedCount, Duration.ofMillis(TIME_TO_LIVE));
        log.info("[LoginFailedRepository]count login failed for user : {}", username);
    }

    public Integer getValues(String username) {
        Integer failedCount = ClassUtil.castingInstance(
            redisTemplate.opsForValue().get(getKey(username)), Integer.class);
        return failedCount;
    }

    private String getKey(String username) {
        return PREFIX_FOR_KEY + username;
    }

    public Long increment(String username) {
        return redisTemplate.opsForValue().increment(getKey(username));
    }

    public void delete(String username) {
        redisTemplate.delete(getKey(username));
    }
}

3.2 Redis 를 사용한 이유 🤔

  • Redis이벤트 루프 기반 싱글 스레드로 작동하기 때문에 값을 증가시키는 increment 같은 메서드에서도 원자성을 보장받을 수 있다고 판단했습니다.
    ( 하지만 단일 작업이 아닌 그룹 작업이라면 동시성 이슈가 발생할 수 있는데 이 부분은 login 코드를 구현하면서 알아보겠습니다.)

  • 일반적인 RDBMS에 로그인 실패에 대한 데이터를 다이렉트로 넣어 주기에는 비용과 성능적인 면에서 좋지 못하다고 판단하였습니다.

  • RedisTTL 을 활용하면 실패 카운트 초기화 작업에 대해서 조금 더 유연하게 기능을 만들 수 있다고 생각했습니다.


3.3 로그인 서비스 구현

기존의 로그인 서비스에 로그인 실패를 트래킹하는 로직을 추가하였습니다.
흐름을 파악하기 위해서 전체 로그인 코드를 올렸습니다.

LoginService

@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {

   ...(생략)

    private void validatePassword(UserLoginRequest userLoginRequest, User user) {
        if (!encoder.matches(userLoginRequest.getPassword(), user.getPassword())) {
            countLoginFailed(user);

            throw new AppException(ErrorCode.HAS_NOT_AUTHENTICATION,
                "Your account requires email verification.");
        }
    }

    private void countLoginFailed(User user) {

        Long attemptCount = incrementFailedCount(user);

        log.info("attemptCount : {}", attemptCount);

        if (attemptCount >= MAX_ATTEMPT_COUNT) {
            accountLockService.lockUserAccount(user);
            loginFailedRepository.delete(user.getUsername()); // 계정 잠금 후 실패 횟수 초기화
        }
    }

    private Long incrementFailedCount(User user) {
        if (loginFailedRepository.getValues(user.getUsername()) == null) {
            loginFailedRepository.setValue(user.getUsername(), INIT_LOGIN_TRIAL_COUNT);
        }
        return loginFailedRepository.increment(user.getUsername());
    }

다음 코드는 로그인 코드중 비밀번호를 검증하는 로직이며 요악하자면 이렇습니다.

  • 비밀번호 일치 확인
  • 비밀번호가 불일치 한다면 만들어 두었던 LoginFailedRepository에 실패 횟수 카운트
    ( 만약 key 값이 존재하지 않는다면 key 값 생성후 카운트)
  • 로그인 5회이상 실패시(비밀번호 불일치) 계정 잠금

그리고 계정을 잠금하는 accountLockService 내부 코드를 확인해 보겠습니다.

accountLockService

public class AccountLockService {

    private final UserRepository userRepository;
    private final JavaMailSender javaMailSender;
    private static final String USER_AUTHENTICATION_MESSAGE = "Enter the following verification code";

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void lockUserAccount(User user) {

        /*
            계정 잠금 조치
                - 계정 activity 값을 locked 로 변경 (추가적인 인증 필요한 상태)
                - 계정 가입한 이메일로 보안 코드 발송
         */

        UserEntity userEntity = userRepository.findByUsername(user.getUsername())
            .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND));

        userEntity.changeActivity(UserActivity.LOCKED);
        userEntity.toUnVerified();
        javaMailSender.send(EmailForms.createEmailForm(user.getEmail(), USER_AUTHENTICATION_MESSAGE,
            RandomCodeGenerator.createRandomCodeNumber()));
    }
}

다음의 코드를 요약한다면 이렇습니다.

  • user 의 계정을 잠금시킨다 (UserActivity.LOCKED)
  • user 를 이메일 인증 필요 상태로 만든다.
  • javaMailSender로 인증 코드를 발송한다.

해당 코드에서 트랜잭션의 전파레벨을 REQUIRES_NEW 로 선언한 이유는
LoginService 에서 패스워드에 대한 예외를 발생시켜도 AccountLockService 는 개별적인 트랜잭션을 수행시켜 계정을 잠금시키는 상태의 변화를 만들어주기 위함입니다.

차후 user가 인증 코드를 입력할시 원상태로 복구되며 로그인을 정상수행할 수 있습니다.
(이메일로 발송된 인증 코드 입력 부분은 생략하겠습니다.)


4. Redis 도 동시성에서 완전하지 않았다.

사실 안일한 생각으로 위의 코드를 끝마친뒤 기능 수행을 완료했다고 판단했었습니다.
그리고 곰곰히 이런 생각들을 했습니다.

"만약 Redis에서 동시성을 보장 받지 못한다면 어떤 일이 벌어질까..
"어떤 악의적인 공격자가 동시에 100번의 로그인 시도를 하는데 카운트가 잘 되지 않는다면 ...

그래서 가장 이슈가 될 부분인 로그인 실패 횟수 트래킹에 대해서 다시 한번 살펴보도록 했고 결국 동시성이 일어난다는 것을 알 수 있었습니다.
redis 는 싱글 스레드 기반인데 왜 동시성이 일어나게 된걸까요?
만약 동시성이 일어났다면 어떻게 이를 해결할 수 있을까요?

이와 관련해서는 내용이 길어진 것 같아 다음 편에서 다루도록 하겠습니다.


다음 편에서는 위의 코드에서 동시성 이슈가 발생하는 구체적인 시나리오와 적합한 최선의 방법 그리고 발견된 새로운 문제점에 대해서 다뤄보도록 하겠습니다.
2편에 대한 링크와 깃 허브 주소는 하단의 링크에 첨부했습니다.
감사합니다 :)

profile
보안/응용 소프트웨어 개발자

0개의 댓글