안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

 

백엔드 개발에서 보안은 너무나 중요한 문제입니다. 계정 도용 문제가 발생하거나, 인증되지 않은 사용자가 권한이 없는 서버에 접근한다면, 큰 문제가 발생할 수 있습니다.

저는 개인적으로  개발하는 입장에서 보안 관련한 문제는 가장 피하고 싶은 문제이기도 합니다. 제일 어렵기도 하고 걱정되기도 하고 다루기 부담스러운 주제입니다. 

 

하지만 언제까지 미룰 수만 없듯이 코드를 수정하며 보안적으로 위협이 될 수 있는 부분을 처리하고자 글을 작성하게 되었습니다.

이번 주제는 중복 로그인에 관한 포스팅으로 중복 로그인 요청이 발생했을 때, 어떻게 처리하는 게 효율적인지 고민하고 제가 처리한 방법을 공유하고, 항상 마지막은 로직에 대한 테스트로 마무리하도록 하겠습니다.

 

 

1. 로그인 정책 이해하기

 

현재 저는 개인 프로젝트로 회원 가입 후 로그인을 하면 Jwt를 발급 하는 로직을 구현하고 있습니다.

로그인 시 인증 후 AccessToken, RefreshToken, UserId를 AuthTokenDto에 담아 클라이언트에게 전송하는 방식입니다.

 

Redis에 저장된 토큰 정보가 없다면 발급하고, 값이 있다면 해당 토큰을 클라이언트에게 전송합니다.

로그아웃을 하면, 기존에 있던 토큰이 제거되기에 추후 이전에 발급받은 토큰으로 다른 요청을 수행하게 된다면, 인증 예외를 발생시키는 구조입니다.

 

하지만, 이 코드는 엄청난 보안적 이슈가 있습니다. 만약 중복 로그인 요청이 왔을 때, 여러 세션에서 중복된 로그인이 발생할 수 있습니다. 만약 회원이 도용되었다면, 악의적인 해커가 이용자의 계정 정보를 바탕으로 특정한 활동을 할 수 있습니다.

따라서, 중복 로그인이 발생했을 때, 이전 로그인된 대상을 로그아웃 하되, 중복 로그인으로 인한 로그아웃에 대한 메시지를 전송해야 합니다.

 

물론, 네이버처럼 웹앱 로그인과 웹 로그인, 모바일 웹 로그인 모두 가능해야 하는 상황이 있을 수 있습니다. 하지만 해당 프로젝트는 웹 게임 형태로 구현되기 때문에 동시 로그인은 허용하면 안 되는 문제라고 판단하였습니다.

 

중복 로그인 방지 정책에 반드시 필요하다고 생각한 핵심 비즈니스 로직을 정리한 후, 차근차근 코드를 수정하며 글을 작성하도록 하겠습니다.

 

 

 

2. 공통적인 핵심 비즈니스 로직 정리하기

 

이 프로젝트는 JWT 토큰으로 인증정보를 수행하고 있지만, 로그인한 유저가 여러 명인지 판단할 수 있는 로직이 없으므로 중복 로그인에 대해 구현해야 할 과제를 하나씩 정리하였습니다.

(여기서 "로그인 세션"이라는 단어는 Session 방식의 인증이 아니라, 로그인 한 사용자 정보를 저장하기 위한 세션입니다) 

 

a. 중복 로그인인지 판단할 수 있는 로직 추가하기

 

- 이용자가 로그아웃한 이후 로그인을 요청한 것인지, 아니면 로그인 한 유저가 다시 로그인 요청한 것인지 판단하는 로직을 추가할 필요성이 있습니다.

- 로그인 요청이 오면 로그인 정보를 저장하여 중복 로그인을 체크할 수 있도록 하는 과정을 추가해야 합니다.

 

 

b. 중복 로그인 시 이전 로그인 정보 제거하기

 

- 중복 로그인이 발생한다면, 이전 로그인 정보를 제거해야 합니다. 

- 이전의 로그인 세션과 저장된 accessToken, refreshToken, index 종류를 전부 제거합니다.

- 새로 로그인한 세션을 등록하고 새로운 인증 토큰을 발급합니다.

 

 

c. 중복 로그인으로 인해 로그 아웃된 사용자를 위한 메시지 전송

 

- 만약 정상적인 이용자가 중복 로그인으로 인해 로그아웃된 경우, 어떠한 이유로 로그아웃이 되었는지 판단할 수 있어야 합니다.

하지만, b 번으로 이전 로그인 세션만 제거한다면, 해당 사용자는 유효한 토큰이 아니므로 인증 예외가 발생합니다.

따라서, 중복 로그인으로 인해 로그아웃된 유저가 현재 유효하지 않은 토큰을 가지고 다른 요청을 수행했을 때, 중복 로그인을 알릴 수 있도록 해야 합니다.

- 이에 대한 정책으로, LogoutToken 종류를 생성하여 Redis에 저장할 수 있도록 구현하여 LogoutToken 종류로 인증 요청을 수행하면 DoubleLoginxception을 발생시킵니다.

 

 

d. 동일 아이피로 반복적인 로그인 요청을 수행하는 경우

 

- 계정 도용이 아니라더라도, 동일 ip에서 반복적인 로그인 요청을 수행하는 경우 서버에 많은 부담을 줄 수 있습니다. 이 경우 동일 아이피에 대한 반복적인 요청을 체크하여 마치 크롤링시 발생하는 제한처럼 클라이언트 요청 중지 작업을 수행해야 합니다.

- 이를 구현하기 위해 Controller의 Filter에서 IP 정보를 수집하여 처리하는 과정이 필요했습니다.

 

 

 

3. 중복 로그인 여부 판단할 수 있는 로그인세션  추가하기 

 

로그인 세션은 현재 로그인한 유저의 정보를 저장하는 역할을 수행하고, 중복 로그인을 방지해야 할 책임이 있습니다.

LoginSession 클래스에 userId, remoteAddr, createdAt, expiration, loginStatus를 필드로 설정하여 객체를 생성하였습니다.

 

@Getter
@NoArgsConstructor
public class LoginSession {

    private String userId;
    private String remoteAddr;
    private LoginStatus loginStatus = ON;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;

    @TimeToLive(unit = TimeUnit.MILLISECONDS)
    private Long expiration;

    public LoginSession(String userId, String remoteAddr, Long expiration) {
        this.userId = userId;
        this.remoteAddr = remoteAddr;
        this.createdAt = LocalDateTime.now();
        this.expiration = expiration;
    }

    public static LoginSession of(String userId, String remoteAddr, Long expiration) {
        return new LoginSession(userId, remoteAddr, expiration);
    }
}

 

LocalDateTime은 redis에 저장될 때 Jackson 라이브러리가 인식하지 못하는 에러가 발생할 수 있으므로 @JsonFormat 어노테이션을 설정하여야 합니다.

ObjectMapper를 스프링 빈으로 등록하는 Config에서 registerModule을 추가합니다.

objectMapper.registerModule(new JavaTimeModule());

 

LoginSessionRepositry의 역할은 LoginSession 정보를 Redis에 저장해야 하므로 CRUD 기능을 추가하였습니다.

 

@Slf4j
@Repository
@RequiredArgsConstructor
public class LoginSessionRepositoryImpl implements LoginSessionRepository {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;

    private final static String LOGIN_SESSION = "LoginSession:";

    @Override
    public void saveLoginSession(LoginSession loginSession) throws JsonProcessingException {
        String key = getLoginSessionKey(loginSession.getUserId());

        redisTemplate.opsForValue().set(key,
                objectMapper.writeValueAsString(loginSession));
        redisTemplate.expire(key, loginSession.getExpiration(), TimeUnit.MILLISECONDS);
    }

    @Override
    public boolean existLoginSession(String userId) {
        return redisTemplate.hasKey(getLoginSessionKey(userId));
    }

    @Override
    public void deleteLoginSession(String userId) {
        if (existLoginSession(userId)) redisTemplate.delete(getLoginSessionKey(userId));
    }

    @Override
    public LoginSession findLoginSessionByUserId(String userId) throws JsonProcessingException {
        if (existLoginSession(userId)) {
            String value = (String) redisTemplate.opsForValue().get(getLoginSessionKey(userId));

            if (value == null) return null;
            return objectMapper.readValue(value, LoginSession.class);
        }
        return null;
    }
    private String getLoginSessionKey(String userId) {
        return LOGIN_SESSION + userId;
    }
}

 

repository는 키를 등록하고, 객체를 json으로 저장하면서 TTL을 등록하는 코드입니다.

앞 서 redisTemplate 트랜젝션 처리하는 글에서 작성했기 때문에 간단하게 정리하면,

redisTemplate.opsForValue()로 키를 등록하고 objectMapper.readValue()로 읽어오는 과정을 수행했습니다.

 

이후 로직에 대한 간단한 테스트로 해당 코드가 정상적으로 작동하는지 테스트하였고 성공하였습니다.!

 

@Test
@DisplayName("saveLoginSession")
public void saveLoginSession() throws Exception {
    //given
    LoginSession loginSession = LoginSession.of(userId, remoteAddr, expiration);

    //when
    loginSessionRepository.saveLoginSession(loginSession);
    LoginSession findLoginSession = loginSessionRepository.findLoginSessionByUserId(userId);

    //then
    assertThat(findLoginSession.getUserId()).isEqualTo(userId);
    assertThat(findLoginSession.getRemoteAddr()).isEqualTo(remoteAddr);
    assertThat(findLoginSession.getExpiration()).isEqualTo(expiration);
    assertThat(findLoginSession.getCreatedAt()).isBefore(LocalDateTime.now());
}

@Test
@DisplayName("deleteLoginSession")
public void deleteLoginSession() throws Exception {
    //given
    LoginSession loginSession = LoginSession.of(userId, remoteAddr, expiration);
    loginSessionRepository.saveLoginSession(loginSession);

    //when
    loginSessionRepository.deleteLoginSession(userId);

    //then
    assertThat(loginSessionRepository.findLoginSessionByUserId(userId)).isNull();
}

 

 

 

4. 로그인세션 저장을 위한 IP 정보 추가하기 

 

SpringMvc에서 HttpServletRequest는 header 정보 이외에 ip, hostName 등을 제공하는 역할을 수행합니다.

String remoteAddr = servletRequest.getRemoteAddr();

 

servletRequest.getRemoteAddr()을 호출하면 ip정보를 얻을 수 있지만, 이 로직에는 간편한 만큼 단점이 존재합니다.

 

네트워크는 다음의 절차에 따라 Tcp(Http) 요청을 수행할 수 있습니다.

a. 특정 데이터를 분할하여 송신 port와 수신 port로 TCP 세그먼트를 생성합니다.

b. IP 계층에서는 Http 프로토콜을 사용하여 송신 ip와 수신 ip를 입력하여 세그먼트를 패킷으로 감쌉니다. 

c. 이더넷 계층에서 프레임으로 감싸며 송신지 맥주소와 수신지의 게이트웨이 맥주소를 입력합니다.

d. ARP와 게이트웨이 라우팅 기능으로 실제 원하는 수신지 ip까지 도달하게 된다면 송신지의 ip정보를 확인할 수 있습니다.

 

저의 프로젝트에서는 client ->  gateway server -> member server 순서로 요청이 위임되기 때문에, 송신지 ip가 바뀔 수 있습니다. 즉 전달되는 데이터는 같을지라도 ip에 대한 정보가 바뀌므로 이를 인지할 수 있도록 헤더를 추가하는 과정이 필요했습니다.

 

이때, 사용되는 헤더는 Fowarded와 X-Forwarded-For 입니다.
RFC 7239 표준에 의하면, Forwarded 방식이 표준으로 정의되어 있습니다. 

 a. 표준화
- Forwarded: 2014년에 발표된 RFC 7239에 정의된 공식 표준입니다.
- X-Forwarded-For: 비공식 확장 헤더이지만 널리 사용되고 있는 헤더입니다.

b. 구조 및 포맷
- Forwarded: 이 헤더는 구조화된  포맷을 사용하는데 for, by, proto, host 등을 세미콜론으로 구분합니다.
- X-Forwarded-For: 각 전송과정에서 사용된 IP 목록을 쉼표로 구분합니다.

 

현재 이 프로젝트는 로그인 Ip 정보만 요청받는 것이 주목적이고, 추후 SSL/TLS를 적용하더라도 보안상 허점이 존재하므로 보다 많은 정보를 얻는 것은 위험할 수 있다고 판단하였습니다. 따라서 저는 X-Forwarded-For 방식을 사용하기로 결정하였습니다. 

 

(물론, X-forwared-For에서 받은 ip가 실제 client의 ip가 아닐 수 있습니다. 프록시 우회를 사용할 경우 ip가 X-forwared-For 헤더에 실제 ip주소를 담는다 하더라도 변조될 가능성이 있기 때문에 클라이언트의 ip라고 확신할 수는 없습니다. 이 부분은 추후 보안해나가겠습니다.)

 

X-Forwarded-For 방식을 사용하면, 최종 백엔드로 도착하기까지 ip에 대한 정보를 X-Forwarded-For 헤더에 아래와 같은 방식으로 담기게 됩니다.

X-Forwarded-For: 192.168.0.0.1, 192.168.0.0.2.....

 

이를 파싱 하기 위해 ", "로 문자열을 나눈 후 생성된 배열에서 0번째 값을 받도록 하였습니다.
(반드시 gateway server에서는 X-Forwarded-For에 대한 값을 추가하여야 합니다)

 

@PostMapping("/login")
public ResponseEntity login(@Validated @RequestBody LoginRequest request,
                            @RequestHeader("X-Forwarded-For") String remoteAddr) throws JsonProcessingException {

    AuthTokenDto authToken = facadeService
            .login(RequestMapperFactory.mapper(request, remoteAddr.split(",")[0].trim()));
    return ResponseEntity.ok().body(SendSuccessBody.of(authToken));
}

 

 

 

5. LogoutSessionAccessToken, LogoutSessionRefreshToken 추가하기

 

@Getter
@NoArgsConstructor
public class LogoutSessionAccessToken extends Token implements Serializable {

    private LogoutSessionAccessToken(String id, String userId, long expiration) {
        super(id, userId, expiration);
    }
    public static LogoutSessionAccessToken of (String logoutSessionAccessToken, String userId, Long expiration) {
        return new LogoutSessionAccessToken(logoutSessionAccessToken, userId, expiration);
    }
}

 

@Getter
@NoArgsConstructor
public class LogoutSessionRefreshToken extends Token implements Serializable {
    
    private LogoutSessionRefreshToken(String id, String userId, long expiration) {
        super(id, userId, expiration);
    }
    public static LogoutSessionRefreshToken of (String logoutSessionRefreshToken, String userId, Long expiration) {
        return new LogoutSessionRefreshToken(logoutSessionRefreshToken, userId, expiration);
    }

}

 

두 토큰의 역할은 로그아웃된 토큰으로 요청이 오면, 중복 로그인으로 로그아웃되었다는 메시지를 발급해주어야 합니다.

따라서 토큰을 Redis에 저장하기 위한 redisTemplate 로직을 작성하고 Service에서 두 토큰 중 어느 하나라도 사용될 시 예외를 발생시키는 로직이 수행되어야 합니다.

 

LogoutSessionAccessToken과 LogoutSessionRefreshToken은 모두 Token을 상속하고 있기 때문에 토큰 저장에 사용한 TokenRepository를 그대로 사용할 수 있었습니다.

 

<TokenRepository.java>

 

@Repository
public interface TokenRepository<T extends Token> {

    void saveToken(String key, T t) throws JsonProcessingException;

    T findTokenByKey(String key, Class<T> clazz) throws JsonProcessingException;

    void saveTokenIdx(String key, T t) throws JsonProcessingException;

    String findTokenIdxValue(String key, Class<T> clazz) throws JsonProcessingException;

    T findTokenByIdx(String key, Class<T> clazz) throws JsonProcessingException;

    void deleteToken(String key, Class<T> clazz) throws JsonProcessingException;

    void deleteTokenIdx(String key, Class<T> clazz) throws JsonProcessingException;

}

 

이후 TokenPolicy에 LogoutSessionTokens라면, 예외를 발생시키는 메서드를 추가하였습니다.

 

public void throwIfLogoutSessionTokens(String accessToken, String refreshToken, String userId)
        throws JsonProcessingException {

    Token logoutAccessToken = tokenRepository.findTokenByKey(accessToken, LogoutSessionAccessToken.class);
    Token logoutRefreshToken = tokenRepository.findTokenByKey(refreshToken, LogoutSessionRefreshToken.class);

    if ((logoutAccessToken != null && logoutAccessToken.getUserId().equals(userId)) ||
            (logoutRefreshToken != null && logoutRefreshToken.getUserId().equals(userId)))
        throw new DoubleLoginException();
}

 

 

 

6. 서비스 로직 작성하기

 

LoginSessionPolicy는 로그인 세션을 어떻게 활용할 것인가에 대한 비즈니스 로직을 작성하였습니다.

앞 서 정리한 계획대로 로그인 세션을 등록하고 중복 로그인이라면 저장된 세션정보와 인증 토큰을 전부 제거합니다. 

그리고 새로운 이용자의 세션 정보와 인증 토큰을 저장합니다.

 

여기서 로그인 세션 등록과 토큰 발급은 객체 지향적으로 분리하는 게 맞는지 아니면 하나의 로직으로 수행해야 하는지 트레이드오프가 발생하였습니다.  이 과정은 단일 트랜젝션 내에서 처리되어야 하므로 코드상 아쉬움이 남지만, TokenPolicy를 의존성 주입받은 LoginSessionPolicy에서 처리하는 것으로 결정하였습니다.

 

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginSessionPolicyImpl implements LoginSessionPolicy {

    private final LoginSessionRepository loginSessionRepository;
    private final TokenPolicy tokenPolicy;

    @Value("${jwt.access-expiration-time}")
    private Long expiration;

    @Override
    public AuthTokenDto loginNewSession(Member member, List<Authority> authorities, String remoteAddr)
            throws JsonProcessingException {

        if (isDoubleLogin(member.getUserId())) {
            logoutSession(member.getUserId());
            tokenPolicy.saveLogoutTokensAndDeleteSavedTokens(member.getUserId());
        }
        loginSession(member.getUserId(), remoteAddr);

        return tokenPolicy.createAuthToken(member.getUserId(), authorities);
    }

    private boolean isDoubleLogin(String userId) {
        return loginSessionRepository.existLoginSession(userId);
    }

    private void loginSession(String userId, String remoteAddr) throws JsonProcessingException {
        loginSessionRepository.saveLoginSession(LoginSession
                .of(userId, remoteAddr, expiration + new Date().getTime()));
    }

    private void logoutSession(String userId) {
        loginSessionRepository.deleteLoginSession(userId);
    }
}

 

<FacadeSerivce.java>

public AuthTokenDto login(LoginFacadeRequest request) throws JsonProcessingException {
    Member member = memberPolicy.findMemberByEmailOrThrow(RequestMapperFactory.mapper(request));
    List<Authority> authorities = memberPolicy.findAuthorityByUserOrThrow(member);

    return loginSessionPolicy.loginNewSession(member, authorities, request.getRemoteAddr());
}

 

faceService는 부가 로직 수행 후, loginSessioPolicy.loginNewSession을 호출하고, AuthTokenDto를 리턴합니다.

 

 

 

7. 서비스 로직 코드 테스트 하기

 

차례대로 LogoutToken 관련 서비스, LoginSessionPolicy, FacadeService 순서로 테스트를 진행하였습니다.

일반적인 순차적 테스트에서는 테스트 성공하였고, 하단은 동시성 테스트 코드입니다.

 

고려해야 할 사항이 많으므로 Assertions 코드가 지저분하지만 확실하게 검증해야 하는 부분이므로 전부 작성하였습니다.

먼저 같은 ip로 몇 초 이내에 반복 요청을 보낸다면 필터가 예외를 발생시키므로 컨트롤러에서 검증을 진행할 수 있습니다.

 

하지만, 만약 예기치 않는 문제가 발생할 수도 있으므로 비즈니스 로직에서도 동시성 문제를 테스트하였습니다.

검증해야 하는 로직은, 마지막(물리적으로 마지막)에 수행되는 익명의 스레드만 로그인 세션을 유지하고 유효한 인증 토큰으로 저장합니다. 물리적으로 이전에 저장된 토큰은 모두 로그아웃된 토큰으로 저장해야 합니다.

 

/**
 * 동시에 로그인 요청이 올 때 (필터에서 처리되지 않는 제약 사항을 넘어서 요청이 수행되었다고 가정)
 * 같은 email, password, 다른 remoteAddr
 * 마지막에 실행되는 익명의 스레드의 로그인 세션만 유지하고 다른 스레드의 로그인 세션은 제거한다.
 * 유효 한인증 토큰은 하나만 유지되고 나머지 토큰은 모두 LogoutSessionToken으로 저장된다.
 */
@Test
@DisplayName("로그인이 여러 번 발생하는 문제에 대해 동시성을 체크한다.")
public void loginNewSession_success_beforeLoginSessionExist_mt() throws Exception {
    //given
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(count);
    AuthTokenDto[] authTokens = new AuthTokenDto[count];

    //when
    for (int i = 0; i < count; i++) {
        int fIdx = i;
        executorService.submit(() -> {
            try {
                authTokens[fIdx] = loginSessionPolicy
                        .loginNewSession(member, authorities, "127.0.0." + fIdx);
            }
            catch (Exception e) {
                e.printStackTrace();
            }
            finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    LoginSession nowLoginSession = loginSessionPolicy.findLoginSessionByUserId(member.getUserId());

    //then
    int validRemoteAddr = parseInt(nowLoginSession.getRemoteAddr().split("\\.")[3]);
    System.out.println("validRemoteAddr = " + validRemoteAddr);

    for (int i = 0; i < count; i++) {
        if (i != validRemoteAddr) {
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getAccessToken(), AccessToken.class)).isNull();
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getRefreshToken(), RefreshToken.class)).isNull();
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getAccessToken(), LogoutSessionAccessToken.class)).isNotNull();
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getRefreshToken(), LogoutSessionRefreshToken.class)).isNotNull();
        }

        else {
            Token accessToken = tokenRepository.findTokenByIdx(member.getUserId(), AccessToken.class);
            Token refreshToken = tokenRepository.findTokenByIdx(member.getUserId(), RefreshToken.class);

            assertThat(tokenRepository.findTokenByKey(accessToken.getId(), AccessToken.class)).isNotNull();
            assertThat(tokenRepository.findTokenByKey(refreshToken.getId(), RefreshToken.class)).isNotNull();
        }
    }

}

 

비즈니스 로직에는  문제가 없었지만,  실제 토큰을 발급하는 유저아이디와 발급시간이 동일하면 동일한 키가 생성되는 문제가 발생하였습니다. 즉, 이전 요청에 대한 토큰을 제거하고 로그아웃 토큰을 생성하고 새로운 요청에 대한 토큰이 생성될 때 동일한 시간으로 인해 토큰이 겹치게 된 것입니다.

 

 

이 문제를 극복하기 위해 토큰 발급 시간을 기존에 정해둔 이외에 추가로 0 ~ 10만 사이의 숫자를 발급할 수 있도록 설정하였습니다.

 

@Override
public String createToken(String userId, List<Authority> authorities, long tokenTime) {

    Claims claims = Jwts.claims().setSubject(userId);
    claims.put(AUTHORITIES_KEY, getRoles(authorities));

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + tokenTime + new Random().nextInt(100000))) 
            .signWith(key, SignatureAlgorithm.HS512)
            .compact();
}

 

그 결과 정상적으로 중복 로그인 세션 및 토큰이 될 때, 토큰이 겹치는 문제를 해결할 수 있었습니다.

10만으로 설정한 이유는 토큰 유효 시간이 AccessToken, RefreshToken이 각각 3일, 7일입니다.
100000 / 1000  수행하면 100초로 실제 정의한 목표 토큰 유효시간에 100초 미만 차이를 주면서 유효한 랜덤 발생을 지킬 수 있다고 생각하였습니다. 

 

 

 

마지막 LoginSessionPolicy를 의존 관계 주입받는 FacadeService에서도 테스트를 진행하였고 성공하였습니다.

 

 

 

8. LoginSessionFilter 등록하기

 

이제 거의 마지막 단계입니다.! 실제 요청을 수행할 때 같은 ip 혹은 같은 email로 여러 번 요청하는 로그인에 대해 필터에서 요청을 차단하는 로직을 작성해야 했습니다.  인터셉터가 아닌 필터를 선택한 이유는, Http 요청에 대해 앞단에서 filter가 수행되기 때문에 Controller를 거치지 않고 차단을 수행할 수 있기 때문입니다.

 

필터에서 고려해야 할 사항은 서버의 성능을 고려하여 최대 얼마의 시간 동안 몇 번의 요청을 차단할 것인지 문제입니다. 

또한, 헤더 정보만 파싱 할 것인지 아니면 body를 복사하여 inputStream을 만든 후 다시 body를 생성해서 보내는 작업을 수행할지에 대한 선택이 필요했습니다.

 

먼저, 헤더 정보만 파싱 하면 10초 동안 같은 ip에서 3번의 요청이 오면 차단하는 기능을 생각하였습니다. ip는 헤더 정보로 필터에서 비교적 간단하게 차단 로직을 수행할 수 있습니다.

 

반면 이메일로 중복 요청을 검사하는 로직은 많은 입출력 비용이  발생합니다. HttpServletRequest의 request는 Consumable로 작동합니다. 즉, 한 번 값을 읽으면 body의 스트림이 소비되므로 filter에서 이메일 중복 요청을 수행하려면 값을 복사해서 사용한 후 다시 inputStream을 생성하여 controller로 보내야 합니다.

 

현재 가용 가능한 서버의 성능을 생각하면 I/O를 생성하는 것은 서버에 많은 부담을 줄 수 있을 것이라 판단하여 요청 본문 처리를 컨트롤러 혹은 서비스 단에서 수행하도록 하고 filter에서는 헤더 정보만 파싱 하도록 처리하는 것으로 결정했습니다.

 

 

코드는 아래와 같습니다. 하나씩 분석해 보겠습니다.

 

@Slf4j
public class LoginSessionFilter extends OncePerRequestFilter {

    private final ConcurrentHashMap<String, LoginSessionCheck> sessionFilter = new ConcurrentHashMap<>();

    @Qualifier("defaultObjectMapper")
    private final ObjectMapper defaultObjectMapper;

    public LoginSessionFilter(@Qualifier("defaultObjectMapper") ObjectMapper defaultObjectMapper) {
        this.defaultObjectMapper = defaultObjectMapper;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
        String xForHeader = request.getHeader("X-Forwarded-For");
        if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();

        String remoteAddr = xForHeader.split(",")[0].trim();
        clearCheckStatus();
        LocalDateTime now = LocalDateTime.now();
        LoginSessionCheck addrCheck = sessionFilter.get(remoteAddr);

        if (addrCheck != null) {
            setCheckStatus(remoteAddr, addrCheck, now);
        } else {
            sessionFilter.put(remoteAddr, new LoginSessionCheck(now));
        }

        filterChain.doFilter(request, response);
    }

    private void setCheckStatus(String key, LoginSessionCheck addrCheck, LocalDateTime now) {
        if (addrCheck.isBlock()) {
            if (between(addrCheck.getLast(), now).toSeconds() > 20) { // 차단 해제 조건
                addrCheck.setInit(); 
            } else {
                throw new TooManyRequestException(); // 차단 유지
            }
        }
        
        if (addrCheck.getFirst() == null) { // first가 null 인경우
            addrCheck.setFirst(now);
            addrCheck.setLast(now);
        } 
        
        else {
            if (addrCheck.getCount() > 3) { 
                addrCheck.setBlock(true); 
                throw new TooManyRequestException();
            }
            
            if (between(addrCheck.getLast(), now).toSeconds() > 10) { //마지막 요청 후 10초가 지났다면 메모리 낭비를 제거하기 위해 체크 제거
                sessionFilter.remove(key);
            } else {
                addrCheck.incrementAndGetCount();
                addrCheck.setLast(now);
            }
        }
    }

    private void clearCheckStatus() {
        if (sessionFilter.size() > 300) {
            sessionFilter.entrySet().removeIf(entry -> !entry.getValue().isBlock());
        }
    }
    

    @Setter
    @Getter
    @NoArgsConstructor
    static class LoginSessionCheck {
        private LocalDateTime first;
        private LocalDateTime last;
        private AtomicInteger count = new AtomicInteger(0);
        private volatile boolean block;

        public LoginSessionCheck(LocalDateTime now) {
            this.first = now;
            this.last = now;
        }
        public int incrementAndGetCount() {
            return count.incrementAndGet();
        }
        public int getCount() {
            return count.get();
        }

        public void setInit() {
            this.first = null;
            this.last = null;
            this.count = new AtomicInteger(0);
            this.block = false;
        }
    }
}

 

 

ConcurrentHashMap은 스레드에 안전한 hashMap을 제공하는 기능입니다.

ConcurrentHashMap에서 static class인 LoginSessionCheck 인스턴스를 저장하고 있습니다. 
LoginSessionCheck는 first, last, count, block으로 구성되어 있습니다.

first: 최초 요청 시간

last: 마지막 요청 시간

count: 인스턴스가 유효하게 저장되는 기간 동안 요청 수

block: 특정 클라이언트의 ip에 대한 차단 여부

입니다.

 

앞 서 비즈니스 로직에서 정리한 X-Forwarded-For의 헤더로부터 remoteAddr을 가져옵니다. 만약 이 값이 null이라면 
제가 정의한 BadRequestException()을 발생시켜, ControllerAdvice에서 예외를 처리합니다.

 

String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();

String remoteAddr = xForHeader.split(",")[0].trim();

clearCheckStatus();
LocalDateTime now = LocalDateTime.now();
LoginSessionCheck addrCheck = sessionFilter.get(remoteAddr);

if (addrCheck != null) {
    setCheckStatus(remoteAddr, addrCheck, now);
} else {
    sessionFilter.put(remoteAddr, new LoginSessionCheck(now));
}

 

이 로직은, setCheckStatus를 적용하여, ip에 대한 유효성을 체크합니다.

 

private void setCheckStatus(String key, LoginSessionCheck addrCheck, LocalDateTime now) {
        if (addrCheck.isBlock()) {
            if (between(addrCheck.getLast(), now).toSeconds() > 20) { // 차단 해제 조건
                addrCheck.setInit();
            } else {
                throw new TooManyRequestException(); // 차단 유지
            }
        }

        if (addrCheck.getFirst() == null) { // first가 null 인경우
            addrCheck.setFirst(now);
            addrCheck.setLast(now);
        }

        else {
            if (addrCheck.getCount() > 3) {
                addrCheck.setBlock(true); 
                throw new TooManyRequestException();
            }

            if (between(addrCheck.getLast(), now).toSeconds() > 10) { //마지막 요청 후 10초가 지났다면 메모리 낭비를 제거하기 위해 체크 제거
                sessionFilter.remove(key);
            } else {
                addrCheck.incrementAndGetCount();
                addrCheck.setLast(now);
            }
        }
    }

 

저는 10초 이내에 요청이 4번 이상 발생하면 addr에 대해 block을 설정하고 TooManyRequestException()을 발생시켰습니다. 그리고 그 이외의 요청에 대해서, 마지막(last) 요청보다 시간이 10초 지난 이후에 다시 요청이 온 경우에는  sessionFilter를 remove 하였습니다. 그 이외의 경우에는 count를 증가시키고, 마지막 요청 시간을 현재로 설정하였습니다.

 

만약, block 된 사용자가 접근할 때, 블락당한 지 20초가 안 지났다면 블락을 유효화하고 TooManyRequestException()을 발생시킵니다. 마지막 요청 기준 20초가 지났다면 블락을 해제하고 값을 초기화합니다.

 

중간에 있는 clearCheckStatus()는  key가 300개 이상 초과하면, block 되지 않은 entry를 제거하여 메모리 관리할 수 있도록 하였습니다.

 

private void clearCheckStatus() {
    if (sessionFilter.size() > 300) {
        sessionFilter.entrySet().removeIf(entry -> !entry.getValue().isBlock());
    }
}

 

이 코드를 사용하기 위해 Configuration에서는 필터를 등록하여야 합니다. 필터를 FilterRegistrationBean<LoginSessionFilter>로 빈을 등록하는 이유는 생성자 주입에 필요한 objectMapper의 Qualifier를 명시하여, 여러 개가 bean으로 등록되어 있는 objectMapper에서 defaultObjectMapper를 선택할 수 있도록 하였습니다.

 

또한, FilterRegistrationBean을 활용하면 특정 uri에 매핑되어야 하는 필터를 선정할 때 효율적입니다.

 

@Configuration
public class FilterRegisterConfig {

    @Qualifier("defaultObjectMapper")
    private final ObjectMapper objectMapper;

    public FilterRegisterConfig(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    
    @Bean
    public FilterRegistrationBean<LoginSessionFilter> loginSessionFilter(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper) {
        FilterRegistrationBean<LoginSessionFilter> registrationBean = new FilterRegistrationBean<>();

        registrationBean.setFilter(new LoginSessionFilter(objectMapper));
        registrationBean.addUrlPatterns("/member-service/login/**");

        return registrationBean;
    }

}

 

만약 필터 내부에 uri패턴을 매칭시키려면  PatternMatchUtils를 사용해야 합니다. 

하지만 이 경우 비즈니스로직에 uri 패턴 매칭 코드로 if - else를 추가해야 하므로 복잡성이 가해질 수 있습니다. 

 

 

    private boolean isLoginCheckPath(String requestURI) {
        return PatternMatchUtils.simpleMatch(blackList, requestURI);
    }

 

이제, SecurityConfig의 filterChain에 LoginSessionFilter를 등록하도록 하겠습니다.

 

private final FilterRegistrationBean<LoginSessionFilter> loginSessionFilter;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
            --- 중략 ---
            
            .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(authenticationGiveFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(loginSessionFilter.getFilter(), UsernamePasswordAuthenticationFilter.class)
            .httpBasic().disable()
            
            --- 중략 ---
            
            return http.build();
}

 

먼저, CorsFilter -> AuthenticationGiveFilter -> LoginSessionFilter -> UsernamePasswordAuthenticationFilter (spring)

순으로 필터가 적용될 수 있도록 filterChain에서 등록하여야 필터가 정상적으로 역할을 수행할 수 있습니다.

filterRegistrationBean을 의존 관계 주입을 받으면 설정한 필터를. getFilter로 호출할 수 있습니다.

 

 

 

9. LoginSessionFilter 검증

 

코드의 마무리이자 코드의 꽃은 테스트인 것 같습니다. 항상 겁이 나고 피하고 싶은 부분이지만, 코드가 검증되어야 
믿을 수 있는 코드이므로 검증을 진행하겠습니다.

 

Filter는 테스트하기 난감한 부분이 있습니다. 어떻게 HttpServletRequest를 설정해야 하는지부터 막막할 수 있습니다.

위대한 스프링은 이 부분까지 모두 고려하여 MockMvc, RestTemplate를 사용할 수 있습니다.

 

저는 MockMvc를 적용하여 테스트를 진행하였습니다.

필요한 의존성을 주입받고, filter를 등록해야하므로 mockMvc에 addFilter()를 설정합니다.

 

@SpringBootTest
class LoginSessionFilterTest {


    @Autowired FacadeService facadeService;
    @Autowired MemberRepository memberRepository;
    @Autowired RedisTemplate redisTemplate;
    @Autowired FilterRegistrationBean<LoginSessionFilter> loginSessionFilter;
    MockMvc mockMvc;

    String email = "kose@naver.com";
    String password = "abcdefg123456";
    String username = "gosekose";
    String remoteAddr = "127.0.0.1";

    @BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders
                .standaloneSetup(new AuthController(facadeService))
                .addFilter(loginSessionFilter.getFilter())
                .build();
    }

    @AfterEach
    public void tearDown() {
        redisTemplate.delete(redisTemplate.keys("*"));
    }

    @Test
    @DisplayName("정상 요청에 대해 1회 실행 테스트를 진행")
    public void doNormalRequest() throws Exception {
        //given

        facadeService.register(FormRegisterRequest.builder()
                .email(email)
                .password(password)
                .username(username)
                .build());

        //when

        ResultActions actions = mockMvc.perform(post("/member-service/login")
                .contentType(MediaType.APPLICATION_JSON)
                .characterEncoding("UTF-8")
                .accept(MediaType.APPLICATION_JSON)
                .content(new Gson().toJson(new LoginRequest(email, password)))
                .header("X-Forwarded-For", remoteAddr));

        //then
        actions
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("200"))
                .andExpect(jsonPath("$.message").value("성공"))
                .andExpect(jsonPath("$.body.accessToken").isNotEmpty())
                .andExpect(jsonPath("$.body.refreshToken").isNotEmpty())
                .andExpect(jsonPath("$.body.userId").isNotEmpty());
    }

}

 

먼저 첫번째 테스트에서는 필터가 정상 작동하는 것을 확인할 수 있습니다.

 

이제 필터가 예외를 발생시켰을 때 블락이 되는 코드를 테스트하면 다음과 같습니다.

 

사실 제가 작성한 필터는 명확하게 4번부터 블락이 되어야 한다라는 조건을 수행하기 어렵습니다.

모든 요청에 대해 동기화와 동시성을 충족하면 좋지만, 여러 가지 동시성 및 동기화를 제어하기 위한 부가 기능을 필터에 추가하려면 서버에 많은 부담이 될 수밖에 없습니다.

 

따라서, 필터는 인증처리와 같은 개별적인 인증이 되어야 하는 로직이 아닌, 서로 다른 요청 간 데이터 동기화가 되어야 하는 필터는 반드시 모든 요청에 동기화가 필요하다고 생각하지 않았습니다.

즉 핵심 비즈니스 로직을 수행해야 하는 controller나 service에서 남아 있는 보안적 이슈를 해결하도록 한 번 더 검증하고 필터는 검증 가능한 수준에서 필터링하고 빠르게 컨트롤러로 넘겨주어야 한다고 생각했습니다.

 

따라서, try - catch로 잡은 후 예외가 발생해야 하는 4번째보다 큰 시점에 블록이 한번 걸리기만 하면 서버에 큰 부담이 되지 않으면서 차단을 수행할 수 있다고 판단하였습니다.

 

@Test
@DisplayName("같은 ip로 10초동안 여러번 요청한다. -> TooManyRequest 예외가 발생한다.")
public void requestSameAddr_faile_TooManyRequest() throws Exception {
    //given
    register();

	//when
    int firstErrorCount = 0;
    boolean isFirst = true;
    for (int i = 0; i < 6; i++) {

        try {
            ResultActions actions = mockMvc.perform(post("/member-service/login")
                    .contentType(MediaType.APPLICATION_JSON)
                    .characterEncoding("UTF-8")
                    .accept(MediaType.APPLICATION_JSON)
                    .content(new Gson().toJson(new LoginRequest(email, password)))
                    .header("X-Forwarded-For", remoteAddr));
        } catch (Exception e) {
            if (isFirst) {
                firstErrorCount = i;
                isFirst = false;
            }
        }
    }

	//then
    assertThat(firstErrorCount).isGreaterThanOrEqualTo(4);
}

 

 

테스트 결과 성공하였습니다.!

드디어 정말 길었던 중복 로그인을 위한 비즈니스 로직 변경, 필터 등록이 끝이 났습니다.

 

 

 

10. 정리하며...

 

중복 로그인과 같은 기능은 보안에서 너무나 중요한 과제입니다.

하나의 비즈니스 로직을 수행하기 위해 -> 토큰 저장, 삭제, 중복 로그인 토큰 저장, 예외 알리기 -> 동시성 잡기 -> 필터로 ip 차단하기 등 정말 많은 기능을 추가해야 했습니다.

 

이 과정을 적용하면서 redisTemplate 간의 트랜잭션을 유지하기 위해 loginSessionPolicy에서 락을 걸고 트랜잭션을 잡았습니다.

loginSessionPolicy.loginNewSession(member, authorities, request.getRemoteAddr());

 

또한, 서버의 안정화를 위해 filter에서 ip를 차단하되 생길 수 있는 ip 우회를 방지하기 위해 X-Forwarded-For 헤더를 추가하여 ip 차단 기능을 추가하였습니다.

 

String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();

String remoteAddr = xForHeader.split(",")[0].trim();

 

filter를 설정하며 request의 Consumable 문제를 해결하며 이메일까지 검증을 할지, 아니면 이 부분은 다른 필터를 적용하거나 컨트롤러에서 처리할 지 많은 고민을 하였습니다. 그 과정에서 Controller가 값을 받지 못하는 에러도 발생하였고 어떻게 처리해야할 지 수많은 코드를 쓰고 지웠던 것 같습니다.

 

filter를 등록할 때, 이전 필터는 PathMatcher 방식을 사용했는데, 스프링이 제공해주는 FilterRegsitrationBean 기능으로 보다 기능을 분리하며 가독성 좋게 작성할 수 있었습니다.

남은 부분도 꾸준히 수정하고 문제를 검토하고 고민하고 코드로 작성하며 해결하도록 하겠습니다.

 

 

이번 글은 정말 길었는데, 읽어주셔서 감사드립니다.

부족한 부분 피드백 주시면 열심히 배우겠습니다.!

감사합니다.!!!

+ Recent posts