WEB 인증 - Cookie

강서진·2024년 2월 14일

쿠키 (HTTP Cookie)
웹 브라우저와 웹 서버 간에 상태 정보를 유지하기 위한 기술
쿠키는 HTTP 헤더에 Set-Cookie와 같은 헤더에 담겨 서버에서 클라이언트로 전송된다. 키-값 쌍으로 이루어져있고, 이름, 값, 유효기간, 도메인, 경로 등의 정보를 포함한다.
서버가 쿠키를 보내면 브라우저는 이를 로컬에 저장하고, 필요할 때마다 서버에 전송하여 사용자 상태 정보를 유지한다.

지난 시간에 보았던 세션도 쿠키를 사용하여 세션 ID를 전달한다. 세션은 서버에서 아이디만 발행하고 해당 값을 관리하는 방식이라, 서버가 여러 개일 경우에는 각각 서블릿에 맞춰 세션을 동일하게 만들 필요가 있다.

예를 들어 서버가 3대라고 하고, 사용자가 1번 서버에 로그인을 해서 세션이 생성되었다고 한다. 그런데 다음 요청은 2번 서버에 들어간다고 하면 2번 서버에는 세션이 없어 또 새로 새션을 맺게 되고, 이는 3번 서버도 동일하다. 이런 상황을 피하기 위해 현업에서는 Redis를 사용해 세션 클러스터링 등을 구현한다고 하는데, 이러면 Redis를 또 따로 운영해야 하는 번거로움이 있다.

이를 보완할 수 있는 방법이 쿠키를 통한 인증이다.


  1. 쿠키는 클라이언트 측에 저장된다.
    : 서버가 클라이언트의 상태정보를 확인하려면 쿠키를 클라이언트에서 전송받아야 한다.
  2. 유효기간이 있다.
    : 유효기간이 지나면 쿠키는 삭제된다.
  3. 쿠키는 보안에 취약하다.
    : 쿠키에 민감한 정보를 저장할 경우에는 HTTPS와 같은 보안 프로토콜을 사용하여 암호화해야 한다(보통 민감한 정보는 쿠키에 저장하지 않는다).
  4. 브라우저에서 관리한다.
    : 따라서 브라우저에서 쿠키를 삭제하거나 다른 브라우저에서 접속하는 경우에는 쿠키를 공유할 수 없다.

쿠키는 왜 쿠키가 되었나?

"The term cookie was coined by web-browser programmer Lou Montulli. It was derived from the term magic cookie, which is a packet of data a program receives and sends back unchanged, used by Unix programmers.(Wikipedia)"
쿠키는 루 몬툴리라는 사람이 처음 만든 단어로, Unix 프로그래머들이 프로그램이 받아 가공하지 않고 전송하는 데이터 패킷을 매직 쿠키라고 부른 데에서 착안하였다.


쿠키를 통한 인증은 많은 곳에서 사용하고 있으며, 보안 문제를 보완하기 위해 세션과 함께 사용하는 편이다,

  1. 사용자가 로그인 정보를 입력한다.
  2. 서버는 사용자 정보를 검증하고, 인증이 성공하면 사용자의 고유 ID와 함께 인증 토큰(쿠키)를 생성한다.
  3. 서버는 생성된 쿠키를 HTTP 응답 헤더에 포함하여 클라이언트에게 전송한다.
  4. 클라이언트는 전송받은 쿠키를 로컬에 저장한다(브라우저).
  5. 클라이언트는 이후 서버에 요청을 보낼 때마다 쿠키를 HTTP 요청 헤더에 포함해 전송한다.
  6. 서버는 전송받은 쿠키를 검증하여, 인증이 성공하면 요청에 대한 응답을 생성한다.

실습

아티팩트를 cookie로 한 새로운 프로젝트를 생성하여 session과 비슷하게 로그인 기능을 구현한다.

// UserDto
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class UserDto {
  private String id;
  private String name;
  private String password;
}

아이디, 이름, 비밀번호 3가지를 가진 UserDto를 만든다.

// UserRepository
@Service
public class UserRepository {
  private final List<UserDto> userList = new ArrayList<>();

  @PostConstruct
  public void start(){
    userList.add(new UserDto(
            UUID.randomUUID().toString(),
            "아무개",
            "1234"));
    userList.add(new UserDto(
            UUID.randomUUID().toString(),
            "아무나",
            "1234"));
    userList.add(new UserDto(
            UUID.randomUUID().toString(),
            "누구나",
            "1234"));
  }

  public Optional<UserDto> findById(String id){
    return userList.
            stream().
            filter( it ->{
              return it.getId().equals(id);
            })
            .findFirst();
  }
  
    public Optional<UserDto> findByName(String name) {
    return userList.
            stream().
            filter( it ->{
              return it.getName().equals(name);
            })
            .findFirst();
  }
}

프로젝트가 처음 생성될 때 기능을 테스트해볼 수 있도록 3개의 데이터를 생성하게 start()를 만들어준다.
추가로, id를 받아 일치하는 id를 가진 UserDto를 반환하게 하는 findById 메서드를 작성해준다.
이때 id는 랜덤하게 생성되는 고유의 식별 id로, 일반적으로 로그인할 때 사용하는 id는 name과 같다. 따라서 findByName도 하나 생성해준다.

다음으로 로그인 로직을 작성한다. session때와 유사하지만, cookie를 반환하므로 session이 아닌 HttpServletResponse를 받는다.

@Service
@RequiredArgsConstructor
public class UserService {

  private final UserRepository repository;
  public void login(
          LoginRequest loginRequest,
          HttpServletResponse httpServletResponse
  ){
    var id = loginRequest.getId();
    var pw = loginRequest.getPassword();

    var optionalUser = repository.findByName(id);

        if (optionalUser.isPresent()){
      var userDto = optionalUser.get();
      if (userDto.getPassword().equals(pw)){
        var cookie = new Cookie("authorization-cookie", userDto.getId());
        cookie.setDomain("localhost");
        cookie.setPath("/");
        cookie.setMaxAge(-1); // session
        httpServletResponse.addCookie(cookie);
      } else {
        throw new RuntimeException("Password does not match");
      }
    } else {
      throw new RuntimeException("User not found");
      }
   }
}

로그인에 성공하면 쿠키를 만들어 랜덤하게 생성된 고유 식별 ID를 첨부하고, 도메인과 패스, 유효기간을 설정하여 HttpServletResponse에 쿠키를 추가한다.

컨트롤러도 작성한다.

@RestController
@RequestMapping("/api/account")
@RequiredArgsConstructor
public class AccountApiController {

  @Autowired
  private final UserService service;

  @PostMapping("/login")
  public void login(
          @RequestBody LoginRequest loginRequest,
          HttpServletResponse httpServletResponse
          ){
    service.login(loginRequest,httpServletResponse);
  }
}

지난 시간에 사용했던 index.html을 그대로 static에 붙여넣기하고 서버를 켜서 로그인을 실행해본다.
로그인에 성공하면 쿠키가 저장된 것을 확인할 수 있다. 이때 쿠키에 저장된 값은 랜덤하게 생성된 값으로 입력했던 id나 pw값은 포함되어있지 않다.


쿠키가 잘 보내지는 것은 확인했다. 그러면 사용자의 정보를 요청했을 때 해당 유저를 어떻게 식별할 수 있을지 새로운 컨트롤러를 만들어 확인해본다.

@Slf4j
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserApiController {

  private final UserRepository repository;

  @GetMapping("/me")
  public UserDto me(
          HttpServletRequest httpServletRequest
  ){
    var cookies = httpServletRequest.getCookies();

    if (cookies != null){
      for (Cookie cookie: cookies){
        log.info("key: {}, value: {}", cookie.getName(), cookie.getValue());
      }
    }
    return null;
  }
}

새로운 url로 get요청이 날아오면, request에 담긴 쿠키의 내용을 로그에 출력해본다.
다음과 같이 key값, value값이 잘 찍힌다.

위처럼 쿠키를 찾아낼 수도 있지만, 쿠키를 받는 법도 있다. @CookieValue라는 애너테이션을 사용하면 된다.
쿠키 이름이 authorization-cookie인데 자바에서는 카멜케이스를 사용하므로, 애너테이션에 name 값을 줘서 매치시켜준다.
이 애너테이션은 required 값을 boolean으로 받는데, true면 쿠키가 없을 때 자동으로 예외가 발생한다. false로 설정하면 null 값이 발생해 직접 예외처리를 할 수 있다.

  @GetMapping("/me")
  public UserDto me(
          HttpServletRequest httpServletRequest,
          @CookieValue(name = "authorization-cookie",
                  required = false)
          String authorizationCookie
  ){
    log.info("authorizationCookie: {}", authorizationCookie);

    var optionalUserDto = repository.findById(authorizationCookie);
    return optionalUserDto.get();
  }

쿠키에서 고유 ID 값을 받아, 이 ID로 UserDto를 찾아 반환하게 한다. 실행하면 UserDto가 잘 반환되는 것을 확인할 수 있다. 쿠키를 사용하여 서버와 통신할 때는 이러한 방식으로 사용자가 누구인지 확인할 수 있다.


쿠키의 취약점

쿠키는 중간에 탈취당할 수 있는 위험을 가지고 있다. 또, 프론트엔드에서 쿠키의 내용을 읽어갈 수도 있다.
브라우저에서 개발자 도구를 켜놓고, 콘솔에 자바스크립트로 다음과 같이 작성만 해도 쿠키 내용을 읽어올 수 있다.

var cookies = document.cookie.split(": ");
cookies

브라우저에서 쿠키가 탈취되어 다른 곳에 보내지거나, 내용이 변조되어 사용되기도 한다. 이런 일을 막기 위해 존재하는 옵션이 바로 setHttpOnly이다. 이 값을 true로 주면 http 통신을 할 때 외에는 자바스크립트에서 쿠키 값을 읽을 수 없도록 보안처리를 한다.

cookie.setHttpOnly(true);

추가로 보안과 관련한 옵션은 쿠키의 setSecure이다.

cookie.setSecure(true);

이 옵션은 쿠키가 https에서만 전송 및 사용되도록 설정한다. 뿐만 아니라 같은 도메인에서만 동작할 수 있어 외부 사이트에서는 쿠키값을 건드리거나 변조할 수 없게 된다. 따라서 실제 현업에서는 이 두 옵션을 꼭 챙겨야 한다. (로컬에서는 http에서 작동하므로 적용되지 않는다.)

0개의 댓글