6 분 소요

이번 스터디 주제로 쿠키와 세션, 토큰에 대해 정리해봤습니다.

HTTP

HTTP는 무상태 프로토콜입니다.

Connectionless, Stateless한 특징을 가지고 있기 때문에 서버 자원을 매우 효율적으로 사용할 수 있습니다.

하지만 위의 특징으로 인해 고려해야 하는 경우가 생기는 데 바로 상태 유지입니다.

서버가 사용자에 대한 정보를 얻으려면 사용자가 요청 시에 데이터를 보내는 방법밖에 없는데, 모든 요청에

사용자 정보를 포함하는 것은 적합하지 않습니다.

  1. 모든 요청에 사용자 정보가 포함되도록 개발해야 합니다.
  2. 사용자의 민감한 정보를 보호할 수 없습니다.

위와 같은 문제를 해결하기 위해 쿠키, 세션, 토큰이 사용됩니다.


쿠키

브라우저에 저장되는 최대 크기가 4kb인 작은 크기의 문자열로 클라이언트에 저장되는 데이터입니다.

서버에서 생성된 쿠키는 클라이언트가 저장하고 있다가, 동일한 서버에 재 요청 시 요청 데이터와 쿠키를 함께 전송합니다.

쿠키는 브라우저에서 저장되기 때문에 보안 옵션이 있다고 해도 민감한 정보(비밀번호 등)은 저장하지 않는 것이 적절합니다.


쿠키의 목적

쿠키는 위와 같은 문제를 해결하기 위해 고안되었습니다.

서버에서 쿠키를 전송하면 클라이언트 브라우저에 저장되고, 요청할 때마다 자동으로 포함됩니다.

때문에 서버는 쿠키의 데이터를 확인해서 클라이언트에 대한 정보를 확인하고 적절한 서비스를 제공할 수 있게 됩니다.

쿠키는 주로 세 가지 목적을 위해 사용됩니다.

  1. 세션 관리 : 로그인 상태, 장바구니 등의 정보 관리
  2. 개인화 : 사용자 선호, 테마 등의 설정 정보
  3. 트래킹 : 사용자 행동을 기록하고 분석하는 용도

쿠키 생성

HTTP 요청을 수신할 때 서버는 Set-Cookie 헤더를 전송할 수 있습니다.

Set-Cookie: key=value

쿠키에는 key=value 형태로 다양한 데이터를 저장할 수 있습니다.

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

[page content]

쿠키를 설정하면 응답으로 위와 같은 HTTP 메시지가 전송됩니다.

응답으로 전송된 쿠키는 브라우저의 쿠키 저장소에 저장됩니다.

GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

이후에 클라이언트의 모든 요청은 Cookie 헤더에 해당 쿠키를 자동으로 포함하게 됩니다.


쿠키 설정

1. 라이프타임 설정

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;

쿠키는 라이프타임에 따라 세션 쿠키, 영속적인 쿠키로 나뉠 수 있습니다.

세션 쿠키

  • 브라우저가 닫힐 때 쿠키도 함께 삭제됩니다.

Expires

  • 쿠키의 유효 일자
  • GMT 포맷으로 설정해야 합니다.
  • 브라우저는 유효 일자까지 쿠키를 유지하다가 해당 일자에 도달하면 쿠키를 자동으로 삭제합니다.

Max-age

  • expires 의 대안으로 쿠키 만료 기간을 설정합니다.
  • 만료 일시까지의 시간을 초로 환산하여 설정됩니다.

만료 기간, 유효 일자의 기준은 쿠키가 설정되는 클라이언트와 관련되어 있습니다.


2. 보안

쿠키 헤더에는 Secure, HttpOnly 를 설정할 수 있습니다.

Secure

  • HTTPS 프로토콜 상에서 암호화된 요청일 경우에만 전송됩니다.

HttpOnly

  • Document.cookie API의 접근을 막아 XSS 공격을 방지합니다.
  • 자바스크립트로 쿠키를 확인하거나 조작할 수 없습니다.

SameSite

  • XSRF 공격을 막기 위한 옵션으로 strict 값은 생략 가능합니다.
  • 사용자가 사이트 외부에서 요청을 보낼 때 해당 옵션을 설정했다면 쿠키가 전송되지 않습니다.
  • lax 로 설정하면 기본값과 유사하지만 안전한 HTTP 메서드 또는 최상위 레벨 탐색에서 작업이 일어날 때는 쿠키가 전송된다는 차이점이 있습니다.

3. 스코프

Domain

  • 쿠키가 전송될 호스트를 명시하는 설정입니다.
  • 도메인이 명시되면 서브 도메인들은 항상 포함됩니다.
  • 명시되지 않으면 쿠키를 설정한 도메인에서만 쿠키에 접근할 수 있습니다.

Path

  • URL 경로의 접두사로 이 경로와 이 경로의 하위 경로에 있는 페이지만 쿠키에 접근할 수 있습니다.
  • 보통은 / 루트로 설정하여 웹 사이트의 모든 페이지에서 쿠키 접근을 허용하게 설정합니다.

세션

세션은 쿠키와 다르게 서버에 저장되는 데이터입니다.

서버는 일정 시간동안 동일한 브라우저에서 보낸 요청을 하나의 상태로 보고 유지시키기 위해 세션을 사용합니다.

브라우저에서 관리되는 쿠키와 다르게 서버에서 관리되기 때문에 보안 측면에서 더 안정적이라는 특징이 있습니다.


세션 동작 방식

  1. 클라이언트가 서버에 로그인 요청을 보냄
  2. 서버는 세션 ID를 생성하고 세션 저장소에 저장
  3. 쿠키에 생성한 세션 ID를 담아서 전달
  4. 이후 요청에서는 세션 ID를 확인하고 적절한 인증/인가 처리 실행

스프링 세션으로 로그인 처리

@PostMapping("/login")
public String login(
		@Valid @ModelAttribute LoginForm form,
		BindingResult bindingResult,
    @RequestParam(defaultValue = "/") String redirectURL,
    HttpServletRequest request
) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 로그인 성공 처리
    // 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
    HttpSession session = request.getSession();
    // 세션에 로그인 회원 정보 보관
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

    return "redirect:" + redirectURL;

}

스프링의 ServletRequest 를 상속받은 HttpServeltRequest 클래스로 서블릿이 제공하는 HttpSession 을 조작할 수 있습니다.

@Override
public HttpSession getSession() {
    Session session = doGetSession(true);
    if (session == null) {
        return null;
    }

    return session.getSession();
}

protected Session doGetSession(boolean create) {

		// ...

		session = manager.createSession(sessionId);

    // Creating a new session cookie based on that session
    if (session != null && trackModesIncludesCookie) {
        Cookie cookie = ApplicationSessionCookieConfig.createSessionCookie(
                context, session.getIdInternal(), isSecure());

        response.addSessionCookieInternal(cookie);
    }

    if (session == null) {
        return null;
    }

    session.access();
    return session;
} 

위 코드는 HttpServletRequest 의 구현체인 Request 클래스의 코드입니다.

doGetSession 메서드는 컨텍스트에서 세션을 먼저 조회하고 Manager 클래스의 createSession 메서드를 호출하여 세션을 생성합니다.

만약 트랙킹 모드에 쿠키가 포함된다면 세션 정보를 쿠키에 담아 응답 정보에 저장하고 세션을 반환합니다.


세션 만료 처리

Spring Boot를 사용할 경우 내장 톰캣 서버를 사용하는데, 세션 만료 설정은 application.yml 파일에서 할 수 있습니다.

server:
  servlet:
    session:
      timeout: 1m

timeout 값은 분 단위로 계산하며 최소 시간은 1분입니다.

@Override
public void backgroundProcess() {
    count = (count + 1) % processExpiresFrequency;
    if (count == 0) {
        processExpires();
    }
}

public void processExpires() {

    long timeNow = System.currentTimeMillis();
    Session sessions[] = findSessions();
    int expireHere = 0 ;

    if(log.isDebugEnabled()) {
        log.debug("Start expire sessions " + getName() + " at " + timeNow + " sessioncount " + sessions.length);
    }
    for (Session session : sessions) {
        if (session != null && !session.isValid()) {
            expireHere++;
        }
    }
    long timeEnd = System.currentTimeMillis();
    if(log.isDebugEnabled()) {
        log.debug("End expire sessions " + getName() + " processingTime " + (timeEnd - timeNow) + " expired sessions: " + expireHere);
    }
    processingTime += ( timeEnd - timeNow );

}

세션의 만료 처리는 백그라운드 스레드에 의해 수행됩니다.

Manager 클래스의 backgroundProcess 메서드가 호출되면 자신이 관리하는 세션들 중 만료 처리가 필요한 세션을 확인하고 만료 시간이 지난 세션들은 모두 만료 처리 후 세션 풀에서 제거됩니다.


세션 주의사항

세션은 결국 서버에 저장됩니다. 이는 서버의 메모리를 사용하게 된다는 것이며 서버가 관리하는 요청이 많을수록

세션에 사용되는 리소스도 많아지게 됩니다.

또한 서버가 종료되거나 서버가 늘어날 경우 세션을 공유하고 있지 않다면 불쾌한 사용자 경험을 줄 수 있습니다.

이 문제는 세션을 공유하기 위한 별도의 DB를 가져가는 전략을 세우거나, 토큰을 사용하는 방법이 있습니다.


토큰

토큰은 특수한 알고리즘을 적용하여 변조 불가능한 데이터입니다.

JWT는 대표적으로 사용되는 토큰으로 디지털 서명되어있기 때문에 인증/인가 처리에 주로 사용됩니다.


JWT

JWT는 Header, Payload, Signautre로 이루어져있습니다.

{
  "alg": "HS256",
  "typ": "JWT"
}

Header는 토큰 유형과 서명 알고리즘으로 구성됩니다.

Payload

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
	"iss": "chicori3",                   // 등록된 클레임
	"https://chicori3.github.io/": true, // 공개 클레임
	"hello": "world",                    // 개인 클레임
}

토큰에 담을 정보가 들어가는 영역이며 정보의 한 덩어리를 클레임이라고 합니다.

클레임은 key-value 한 쌍으로 이루어져 있습니다.

등록된 클레임

  • 이미 이름이 등록되어 있는 클레임
  • iss(발급자), exp(만료 시간), sub(주제), aud(대상) 등이 있습니다.

공개 클레임

  • JWT를 사용하는 사람들이 마음대로 정의가 가능한 클레임
  • 충돌을 방지하기 위한 이름을 가져야 하며 보통 URI로 작성합니다.

개인 클레임

  • 사용에 동의한 당사자 간의 정보 공유를 위한 맞춤형 클레임

Signature

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

서명은 해당 토큰이 조작되지 않았음을 확인하는 용도로 사용됩니다.

인코딩된 Header, Payload의 값을 합친 후 비밀 키와 Header의 지정된 알고리즘을 통해 해쉬 값을 생성합니다.


JWT 생성과 사용

public String createToken(Authentication authentication) {
    String authorities = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

    long now = (new Date()).getTime();
    Date validity = new Date(now + this.tokenValidityInMilliseconds);

    return Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .signWith(key, SignatureAlgorithm.HS512)
            .setExpiration(validity)
            .compact();
}

Authentication 객체에서 권한 정보를 얻고 Jwts.builder() 를 통해 JWT를 생성할 수 있습니다.

@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {

    // username, password 를 이용해 authenticationToken 생성
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
            loginDto.getUsername(), loginDto.getPassword());

    // authenticationToken 으로 Authentication 객체를 생성할 때
    // authenticate 메서드가 실행되며 loadUserByUsername 메서드가 실행된다
    // 생성된 Authentication 객체를 SecurityContext 에 저장하고 JWT Token 생성
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    SecurityContextHolder.getContext().setAuthentication(authentication);

    String jwt = tokenProvider.createToken(authentication);

    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);

    return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}

인증을 위한 AuthController 의 메서드입니다.

로그인한 정보로 authenticationToken 을 생성하고 Authentication 객체로 JWT를 생성합니다.

생성된 토큰은 Authorization Header에 Bearer + JWT 값을 합쳐 HTTP Header로 전송하게 됩니다.

public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token);

        return true;
    } catch (SecurityException | MalformedJwtException e) {
        logger.info("잘못된 JWT 서명입니다.");
    } catch (ExpiredJwtException e) {
        logger.info("만료된 JWT 토큰입니다.");
    } catch (UnsupportedJwtException e) {
        logger.info("지원되지 않는 JWT 토큰입니다.");
    } catch (IllegalArgumentException e) {
        logger.info("JWT 토큰이 잘못되었습니다.");
    }
    return false;
}

반대로 요청에 날라온 JWT는 미리 지정해놓은 비밀 키로 해석하여 유효한 토큰인지 검증하게 됩니다.


토큰 방식의 특징

토큰 방식을 사용하면 기존 세션 방식에서의 메모리 문제를 해결할 수 있습니다.

하지만 토큰 또한 탈취당할 가능성이 있고, 한 번 발행한 토큰은 유효기간이 끝날 때 까지 통제할 수 없기 때문에

access token과 refresh token 전략에 대해 공부하는 것이 중요하겠습니다.

태그:

카테고리:

업데이트:

댓글남기기