[ MindDiary ] 이슈 10. 다중 서버에서 인증 정보는 어디에 저장할까 : 토큰 인증 방식

Dayeon myeong·2021년 12월 4일
1

Mind Diary

목록 보기
10/10

HTTP의 무상태성과 비연결성이라는 특징이 있습니다. 이 특징 때문에 사용자의 인증 정보가 필요할 때마다 매번 로그인을 해야하는 상황이 발생합니다. 예를 들어 한 페이지에서 로그인을 하고 다른 페이지로 가면 재로그인을 해야합니다. 그래서 문제 해결을 위해 쿠키, 세션, 토큰과 같은 곳에 인증 정보를 담아두고 사용자가 누구인지 확인하고 접근 권한이 있는지 확인합니다. 이 세가지 방법 중 토큰을 사용하여 인증 처리를 했습니다.

Scale out

인증 정보를 다루기 전에 생각해야 할 문제가 있습니다. 현재 프로젝트의 사용자가 많아져 가용 중인 서버로는 수많은 클라이언트들을 감당할 수 없는 상황이 발생할 수 있습니다. 그래서 서버가 얼마나 많은 추가적인 트래픽을 처리할 수 있는지와 같은 확장성을 고려하여 문제를 해결해야 합니다.

문제 해결을 위해 Scale up, Scale out이란 방법이 있습니다.

Scale up이란 단일 서버의 성능 자체를 올리는 것을 얘기합니다. 예를 들어 서버의 CPU를 추가하거나 메모리를 추가하는 등을 예로 들 수 있습니다. 서버가 하나이기 때문에 여러대의 서버로 관리하면서 발생하는 데이터 정합성 문제를 해결할 수 있습니다. 하지만 여러 단점이 있습니다. 서버의 성능을 올리는 것에 한계가 있다는 것입니다. 설치가능한 CPU 개수 , 메모리 개수 등의 제한이 있을 수 있습니다. 만약 그럴경우 서버 자체를 교체해야 합니다. 또한, 비용이 많이 들고 트래픽이 몰려 장애가 생길 경우 단일 서버가 복구되기 전까지 서비스가 중단되는 사태까지 일어날 수 있습니다.

Scale out 이란 동일한 사양의 새로운 서버를 추가하는 방식입니다. 여러 대의 서버가 트래픽을 나누어 갖게 되고, 각각의 서버가 이를 처리합니다. 서버를 계속 해서 추가하여 추가적인 트래픽을 유연하게 다룰 수 있습니다. 또한, 로드밸런싱을 도입하여 트래픽을 여러 서버에 적절히 분담할 수도 있습니다. 하지만 데이터 정합성의 이슈가 생길 수 있습니다. 예를 들어 다중 서버 환경에서 각 서버가 세션을 가지고 있을 경우를 생각해봅시다. 세션에는 사용자의 인증 정보가 담겨있습니다. 서버 별로 세션이 존재하기 때문에 만약 인증 정보가 있는 서버가 아닌 다른 서버로 요청이 간다면 인증이 처리되지 않습니다.

Scale up과 Scle out 방식 중 추가적인 트래픽을 유연하게 감당할 수 있도록 Scale out 방식을 선택했습니다. 또한 다수의 처리를 동시 병행적으로 서버가 처리할 수 있게 됩니다. 하지만 데이터 정합성 문제에 대해 고민해야 할 듯 합니다.

쿠키

로그인을 하면 웹 서버는 쿠키에 인증정보를 담아 생성하여 웹 브라우저에 전송합니다. 쿠키는 (키 , 값)의 형태로 웹 브라우저에 있는 쿠키 저장소에 파일로 저장됩니다. 또한 유효기간을 설정하지 않을 경우 브라우저가 종료돼도 계속해서 쿠키 정보가 남아있게 됩니다.

다중 서버의 상황에서도 브라우저에 쿠키가 저장되기 때문에 사용이 가능합니다. 또한 데이터 정합성의 문제도 발생하지 않습니다. 또한 브라우저쪽에 있기 때문에 클라이언트는 빠르게 접근이 가능합니다. 게다가 쿠키가 브라우저에 있게 되면 매 request마다 자동으로 쿠키가 전달이 되어 간편합니다. 하지만 브라우쪽에 인증 정보가 그대로 노출되며 브라우저가 종료되도 계속 남아있기 때문에 탈취의 위험이 있습니다.

다중 서버의 상황에서 쿠리를 사용할 순 있지만 탈취 가능성으로 인해 쿠키 인증 방식은 사용하지 않겠습니다.

세션

로그인을 하면 웹 서버는 자신의 서버에 있는 세션에 인증 정보를 저장합니다. 클라이언트와는 쿠키를 이용해 세션 ID로 통신합니다. 즉, 쿠키에 세션 ID를 담습니다.

실제로는 쿠키, Form field, URL 등등에 세션 ID를 담습니다. 하지만 대표적으로 쿠키를 얘기하는 이유는 매 요청마다 자동으로 쿠키가 전달되기 때문이라고 생각됩니다.

쿠키 인증 방식에 비해 세션은 서버에 저장되고 세션 ID로만 통신합니다. 그렇기 때문에 쿠키방식보다는 인증 정보가 브라우저에 그대로 노출되지 않습니다. 하지만 세션 ID 탈취만으로도 공격자는 사용자의 권한을 획득할 수 있다고 생각됩니다.

유효기간이 설정되지 않을 경우 브라우저가 종료되면 서버에 세션 데이터는 유지되고 웹 브라우저쪽의 세션 쿠키만 날아갑니다. 즉, 로그인한 클라이언트가 브라우저를 닫고 다시 열어서 그 사이트를 다시 방문하여 로그인하면 새로운 세션과 세션 ID가 생깁니다. 재로그인으로 인해 중복된 인증 정보가 쌓이는 것이기 때문에 메모리에 부하가 생길 수 있습니다. 그래서 메모리 문제로 인해 세션보다는 쿠키를 사용하곤 합니다.

다중 서버의 상황에서는 서버별로 세션이 존재하기 때문에 만약 인증 정보가 있는 서버가 아닌 다른 서버로 요청이 간다면 문제가 발생합니다. 즉, 데이터 정합성 이슈가 발생합니다. 문제를 해결하기 위해 sticky session, session clustering, session storage 방식으로 문제를 해결합니다. 이 내용은 EnjoyDelivery 프로젝트의 이슈 3. 다중 서버에서 인증 정보는 어디에 저장할까 : Session Storage에 다룹니다.

세션 인증 방식은 세션 ID로만 통신하기에 쿠키와 다르게 인증 정보가 그대로 노출되진 않습니다. 또한 다중 서버 환경에서 문제가 있으나 여러 방법을 고려해본다면 해결될 수 있습니다. 그리고 스프링에서는 세션 사용 방식이 간단합니다. 그래서 세션을 다뤄도 된다고 생각합니다.

토큰

로그인을 하면 웹 서버는 인증정보를 토큰에 담아 클라이언트에게 전달합니다. 이후 클라이언트와 서버가 이 토큰을 주고받으면서 인증을 처리합니다.

다중 서버의 상황에서도 토큰을 사용한다면 문제없이 각 서버가 인증을 진행할 수 있습니다. 또한, 현재 JWT 토큰이란 것을 통해 많은 프로그래밍 언어로 구현이 가능합니다.

하지만 토큰에 유효기간이 설정되지 않을 경우 토큰은 영원히 유효합니다. 게다가 토큰 자체에 인증 정보를 가지고 있습니다. 즉, 토큰 탈취 문제가 있습니다.

토큰 인증 방식은 토큰 탈취 문제가 있지만 이 방식 또한 여러 방법을 도입하면 보안 문제를 줄일 수 있습니다. 또한 다중 서버 환경에서도 문제 없이 토큰 사용이 가능합니다. 이 방식도 고려해 볼 수 있습니다.

다중 서버에서 어떤 방식을 사용해야 하나

어떤게 낫다라고 정의를 내릴 순 없었습니다. 어떤 방식이든 탈취 위험이 있고, 다중 서버 환경에서 모두 사용이 가능하다고 판단됬기 때문입니다. 하지만 그대로 인증 정보가 노출되는 쿠키 인증 방식은 선택하지 않았습니다. 그래서 세션 인증 방식과 토큰 인증 방식을 2개의 프로그램에서 각각 사용해봤습니다.

그래서 Mind-Diary 프로젝트에서는 토큰 인증 방식을, 또다른 프로젝트인 Enjoy-Delivery 에서는 세션 인증 방식을 실제로 경험하는 것이 좋다고 판단했습니다.

JWT 토큰 적용

토큰 인증 방식 중 대표적으로 많이 사용하는 기술인 JWT 토큰은 사용자 인증에 필요한 정보들을 암호화하여 토큰에 담습니다.

하지만 만약 공격자가 암호화 방식을 안다면 디코딩하여 인증 정보를 알 수 있습니다. 또한 유효 시간이 설정 되지 않을 경우 토큰은 계속 유효할 것입니다. 즉 토큰 탈취 문제가 있습니다.

문제해결 방법으로 Access Token과 Refresh Token 두가지 토큰을 사용하여 탈취 문제를 해결합니다. Access Token이란 실제로 인증 시에 사용되는 토큰이며 유효기간이 짧습니다. Refresh Token은 Access Token의 유효기간이 만료되었을 때 Refresh token이 Access Token을 새로 발급해주는 역할을 해줍니다. 토큰은 탈취당할 수 있지만 Access Token의 유효기간은 짧기 때문에 탈취 가능성이 좀 더 적어집니다. 유효기간이 긴 Refresh Token은 좀 더 위험할 수 있습니다. 하지만 Secure HttpOnly 쿠키에 Refresh Token을 담을 경우 자바스크립트를 삽입하여 공격하거나 HTTP로 인한 공격을 막을 수 있습니다.

따라서 Access Token 과 Refresh Token 두가지 토큰 방식을 사용하고, Refresh Token에는 HttpOnly를 도입했습니다.(HTTPS 를 아직 하지 않았기 때문에 Secure 는 나중에 붙여야합니다.)

CSRF , XSS

  • XSS

    • Cross Site Script 공격
    • 웹 사이트에 악의적인 자바스크립트를 삽입하여 이루어진다.
  • CSRF
    - Cross Site Request Forgery 사이트 간 요청 위조

    • 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격
    1. 공격자가 조작된 요청정보를 유도하는 글을 등록해서 DB에 게시물이 저장됨
    2. 희생자는 해당 게시글을 읽음
    3. 희생자의 권하으로 서버에 악의정인 정보를 자신도 모르게 요청하게 됨. 공격자가 의도한 행위를 하게 됨.
    4. 희생자는 요청에 대한 응답을 받음

Secure HttpOnly 쿠키

  • Http Only : Http 통신상에서만 사용되어야한다. 즉, 통신에서만 사용되므로 document.cookie와 같은 자바 스크립트로 쿠키 탈취를 막는다. xss를 막음.

  • secure : https 적용되도록 하는 것. https를 사용하면 https통신만 가능.

적용 코드 : User Controller

  @PostMapping("/login")
  public ResponseEntity login(@RequestBody @Valid LoginUserRequestDTO loginUserRequestDTO,
      HttpServletResponse httpServletResponse) {

    Token token = userService.login(loginUserRequestDTO.getEmail(), loginUserRequestDTO.getPassword());
    Cookie cookie = token.turnRefreshTokenInfoCookie(cookieStrategy);
    httpServletResponse.addCookie(cookie);

    return new ResponseEntity(createAccessTokenResponse(token), HttpStatus.OK);
  }

로그인 API에 대한 Controller 클래스입니다.
user가 로그인하면 Token 클래스를 리턴받습니다. Token 클래스에는 access token과 refresh token이 담깁니다.

Token 객체의 refresh Token을 쿠키로 만들어 쿠키를 클라이언트에게 전달하고, Access Token은 response body로 전달합니다.

적용 코드 : User Service

 @Override
  public Token login(String email, String password) {
    User findUser = userRepository.findByEmail(email);

    validatePassword(password, findUser.getPassword());

    Token token = createTokenAndInputCache(findUser);

    return token;
  }

  public Token createTokenAndInputCache(User user) {
    Token token = Token.create(user, tokenStrategy);
    userDAO.addRefreshToken(token.getRefreshToken(), user.getId());
    return token;
  }

로그인 서비스 코드입니다.
이메일을 가지고 유저를 DB에서 찾은 후, 입력한 패스워드와 DB에 저장된 hashed password를 검증합니다.
이후, user의 정보로 refresh token과 access token을 만듭니다.
userDAO는 유저의 refresh token이나 email token이 저장된 캐시 입니다. refresh token을 넣음으로써 이후 토큰 재발급시에 같은 refresh token인지 비교할 수 있습니다.

적용 코드 : User 클래스

//User.class
public class User {

  private int id;

  private String email;

  private String password;

...

// Access Token 생성
  public String turnUserinfoToAccessToken(TokenStrategy tokenStrategy) {
    return tokenStrategy.createAccessToken(id, role.toString(), email);
  }

// Refresh Token 생성
  public String turnUserinfoToRefreshToken(TokenStrategy tokenStrategy) {
    return tokenStrategy.createRefreshToken(id);
  }
 }

User 클래스입니다. user 객체의 id, role, email 같은 정보를 외부로 노출시키지 않고 토큰을 생성하도록 합니다. setter / getter가 무분별하게 여러 곳에서 사용되면 이후 코드 수정 시에 하나하나 setter getter를 찾아다녀야합니다. 그래서 user 클래스에 메서드를 만들었습니다.
또한, 현재는 JWT 토큰 을 사용한 방식이지만 이후 토큰 방식이 변경될 수 있습니다. 변경이 일어날 수 있는 부분인 토큰 생성 로직을 TokenStoragy라는 인터페이스로 통째로 외부로 분리시켰습니다. 이러한 방법을 전략 패턴이라고 합니다.

적용 코드 : JWT 클래스

@Service
public class JwtStrategy implements TokenStrategy {

  @Value("${jwt.secret}")
  private String secret;

  @Value("${jwt.access-token-validity-in-seconds}")
  private long accessTokenValidityInSeconds;

  @Value("${jwt.refresh-token-validity-in-seconds}")
  private long refreshTokenValidityInSeconds;

  private static final String USER_ID = "userId";
  private static final String USER_ROLE = "userRole";
  private static final String USER_EMAIL = "userEmail";
  private static final String INVALID_VALUE_EXCEPTION = "토큰을 발급할 수 없습니다";


  private Key getSigningKey(String secretKey) {
    byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
    return Keys.hmacShaKeyFor(keyBytes);
  }


  @Override
  public String createAccessToken(int id, String role, String email) {

    Claims claims = Jwts.claims();
    claims.put(USER_ID, id);
    claims.put(USER_ROLE, role);
    claims.put(USER_EMAIL, email);

    return createToken(claims, accessTokenValidityInSeconds);
  }

  @Override
  public String createRefreshToken(int id) {

    Claims claims = Jwts.claims();
    claims.put(USER_ID, id);

    return createToken(claims, refreshTokenValidityInSeconds);
  }

  public String createToken(Claims claims, long validityInSeconds) {

    validateClaims(claims);
    validateSeconds(validityInSeconds);

    long now = System.currentTimeMillis();
    Date nowDate = new Date(now);
    Date validity = new Date(now + (validityInSeconds * 1000));

    String jwt = Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(nowDate)
        .setExpiration(validity)
        .signWith(getSigningKey(secret), SignatureAlgorithm.HS256)
        .compact();
    return jwt;
  }

  private void validateSeconds(long validityInSeconds) {
    if (validityInSeconds <= 0) {
      throw new InvalidJwtException(INVALID_VALUE_EXCEPTION);
    }
  }

  private void validateClaims(Claims claims) {
    if (claims == null || claims.isEmpty() || claims.size() == 0) {
      throw new InvalidJwtException(INVALID_VALUE_EXCEPTION);
    }
  }

  private Claims extractAllClaims(String token) {
    return Jwts.parser()
        .setSigningKey(getSigningKey(secret))
        .parseClaimsJws(token)
        .getBody();
  }

  public Integer getUserId(String token) {
    return extractAllClaims(token).get(USER_ID, Integer.class);
  }

  public String getUserRole(String token) {
    return extractAllClaims(token).get(USER_ROLE, String.class);
  }

  public String getUserEmail(String token) {
    return extractAllClaims(token).get(USER_EMAIL, String.class);
  }

  public boolean validateToken(String token) {
    try {
      Jwts.parser()
          .setSigningKey(getSigningKey(secret))
          .parseClaimsJws(token);
      return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
      //잘못된 JWT 서명
      throw new InvalidJwtException(e);
    } catch (ExpiredJwtException e) {
      //만료된 JWT 토큰
      throw new InvalidJwtException(e);
    } catch (UnsupportedJwtException e) {
      //지원되지 않는 JWT 토큰
      throw new InvalidJwtException(e);
    } catch (IllegalArgumentException e) {
      //JWT 토큰이 잘못되었습니다.
      throw new InvalidJwtException(e);
    }
  }
}

JWT 사용 코드 입니다.


@Service
public class CreateCookieStrategy implements CookieStrategy {

  @Value("${jwt.refresh-token-validity-in-seconds}")
  private long refreshTokenValidityInSeconds;

  @Value("${cookie.key.refresh-token}")
  private String refreshTokenCookieKey;

  private static final String PATH = "/";
  private static final int ZERO = 0;

  public Cookie createCookie(String key, String value, int duration) {
    Cookie cookie = new Cookie(key, value);
    cookie.setHttpOnly(true);
    cookie.setPath(PATH);
    cookie.setMaxAge(duration);
    return cookie;
  }

  public Cookie createRefreshTokenCookie(String value) {
    return createCookie(refreshTokenCookieKey, value, (int) refreshTokenValidityInSeconds);
  }

  @Override
  public Cookie getCookie(String key, HttpServletRequest httpServletRequest) {
    return Arrays.stream(httpServletRequest.getCookies())
        .filter(c -> c.getName().equals(key))
        .findFirst()
        .get();
  }

  @Override
  public void deleteCookie(String key, HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse) {
    Arrays.stream(httpServletRequest.getCookies())
        .filter(c -> c.getName().equals(key))
        .findFirst()
        .map(c -> {
          c.setValue(null);
          c.setMaxAge(ZERO);
          c.setPath(PATH);
          httpServletResponse.addCookie(c);
          return c;
        });
  }

  @Override
  public Cookie getRefreshTokenCookie(HttpServletRequest httpServletRequest) {
    return getCookie(refreshTokenCookieKey, httpServletRequest);
  }

  @Override
  public void deleteRefreshTokenCookie(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse) {
    deleteCookie(refreshTokenCookieKey, httpServletRequest, httpServletResponse);
  }
}

쿠키를 생성하는 클래스입니다. 쿠키 생성 로직도 변경이 일어날 수 있다고 생각하여 cookieStrategy라는 인터페이스로 분리했습니다.

적용 코드 : Token 클래스

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Token {

  private String accessToken;

  private String refreshToken;

  public static Token create(User user, TokenStrategy tokenStrategy) {

    return new Token(
        user.turnUserinfoToAccessToken(tokenStrategy),
        user.turnUserinfoToRefreshToken(tokenStrategy));
  }

  public Cookie turnRefreshTokenInfoCookie(CookieStrategy cookieStrategy) {
    return cookieStrategy.createRefreshTokenCookie(refreshToken);
  }

}

Token 클래스를 따로 만들어 Access Token과 Refresh Token을 관리했습니다.

JWT 토큰 방식을 사용해보니 작성할 코드가 많아 개발하는데 편한 방법은 아니었습니다. 하지만 다중 서버 환경에서 문제 없이 사용할 수 있고 데이터 정합성 문제를 해결 할 수 있었고 탈취 위험도 줄일 수 있게 되었습니다.

참고 문헌

Sopt 동아리 PPT 자료

확장성 있는 웹 아키텍처와 분산 시스템

다중 서버 환경에서 Session은 어떻게 공유하고 관리할까?

프론트에서-안전하게-로그인-처리하기

profile
부족함을 당당히 마주하는 용기

0개의 댓글