Home JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민.
Post
Cancel

JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민.

이전 포스트에서 Access 토큰 + Refresh 토큰을 사용해 JWT Access 단잁토큰을 이용한 로그인 방식의 보안 문제점을 개선하고자 했다.

하지만 Access 토큰을 요청 헤더에 담으려면 결국 Access 토큰이 클라이언트 사이드에 저장되어야 한다. 즉 프론트 서버에서 토큰을 저장해야 하는데, 쿠키나 LocalStorage에 저장하게 된다.

여기서 문제가 생긴다.

  • localStorage에 저장하는 방식
    • XSS 공격에 취약하다.
  • Access 토큰을 httpOnly 쿠키에 저장한다.
    • XSS 공격에는 비교적 안전하지만, CSRF 공격에 취약하다.

이러한 공격의 위험이 생긴다.

이러한 위험을 개발자가 알게 되었다면, 개발자는 이를 반드시 개선해야 한다고 생각한다.

개선 방법

쿠키에 Refresh 토큰을 넣는 방법이다.

대신 클라이언트 측에서는 브라우저 렌더링 시 Refresh 토큰을 이용해 Access 토큰을 발급받아 사용하는 것이다.

이렇게 하는 이유는 Access 토큰을 공격에 안전하게 하기 위해 메모리 변수에 저장하는데, 메모리 변수에 저장하게 되면 페이지를 새로고침했을 때 Access 토큰이 사라지기 때문이다.

따라서 Access 토큰을 새롭게 발급받기 위해 Refresh 토큰을 쿠키에 담아 Access 토큰의 발급 용도로 사용한다.

이렇게 한다면 공격을 당한다 하더라도 Refresh 토큰만 탈취되고, Refresh 토큰만으로는 권한을 얻어 서버에 위조된 요청을 보내는 공격을 할 수 없다.

그리고 서비스 사용 중 인증이 만료되지 않도록 Access Token 재발급 시 Refresh 토큰의 만료 기간이 임박하면 Refresh 토큰도 쿠키를 통해 재발급 해주어야 한다.

결론적으로 Access 토큰을 메모리에 저장하는 것이 핵심이다.

  • 메모리 변수를 사용하면 페이지를 새로고침하거나, 애플리케이션을 재시작하면 토큰 정보가 사라진다.
    • 따라서 localStorage에 저장하는 방식에 비해 XSS 공격으로부터 토큰을 보호할 수 있게 된다.

Refresh 토큰이 탈취당하면 Access 토큰 발급받을 수 있는거 아닌가?

  • 쿠키에 HttpOnly 옵션을 적용해 XSS 공격을 통한 탈취를 방지한다.
  • 쿠키가 HTTPS를 통해서만 전송되도록 강제한다. 네트워크를 통한 쿠키 탈취 위험에서 벗어날 수 있다.
  • CSRF 공격을 피하기 위해 CORS 설정을 한다.

탈취의 위험은 이러한 방법들로 막을 수 있다.

그리고 Access 토큰은 탈취되면 바로 권한을 얻을 수 있는 반면, Refresh 토큰은 사용자의 로그아웃 등의 행위를 하면 유효한 토큰이 아니기 때문에 비교적 안전하다고 볼 수 있다.

구현 요약

  • 사용자 로그인
  • Access 토큰 및 Refresh 토큰 생성
  • Refresh 토큰은 쿠키에 저장하여 전달
    • 로그인을 통해 얻는 첫 Access 토큰은 HTTP Response를 통해 클라이언트에 전달.
  • 클라이언트는 전달 받은 Access 토큰을 메모리 변수로 저장해 필요한 서버 요청 시에 사용한다.
  • 그리고 브라우저 렌더링 시 쿠키의 Refresh 토큰을 통해 Access 토큰을 재발급 받는다.
  • 클라이언트에서 Access 토큰 재발급 요청이 오면 서버에서는 쿠키의 Refresh 토큰을 추출해 유효성을 확인하고, Access 토큰을 재발급하여 HTTP Response를 통해 다시 전달한다.
    • HttpOnly 쿠키로 Refresh 토큰을 제공하지만, 유효성 체크를 위해 Redis에도 Refresh 토큰을 저장해둔다.
    • 만료되거나 로그아웃 시 Refresh 토큰이 Redis에서 삭제될 것이고, 이는 더이상 유효하지 않은 Refresh 토큰이라는 것이다.

문제점

개선된 방식으로 보안 문제는 해결되었다. 하지만 렌더링 시 마다 새로운 Access 토큰을 발급하는 방식은 서버에 부하가 올 수 있다.

따라서 Access 토큰을 계속해서 재발급하는 대신 캐싱하는 방법을 통해 개선해보고자 한다.

  • Refresh 토큰이 유효한 경우, 캐싱된 값이 있다면 꺼내고 없다면 새로 생성해 HTTP Response를 통해 전달한다.
  • Refresh 토큰이 만료된 경우, 캐시에서 만료된 Refresh 토큰과 매칭되는 Access 토큰도 삭제해주어야 한다.
    • 이후 Refresh 토큰과 Access 토큰을 새로 생성해 캐싱하고, 다시 Refresh 토큰은 쿠키, Access 토큰은 HTTP Response를 통해 발급한다.

구현

LoginFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override  
  protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,  
                                            Authentication authResult) throws IOException, ServletException {  
	    CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();  
  
        String username = customUserDetails.getUsername();  
  
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();  
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();  
        GrantedAuthority auth = iterator.next();  
  
        String role = auth.getAuthority();  
  
        String accessToken = jwtUtil.createAccessToken(username, role, 60 * 60 * 10L);  
        String refreshToken = jwtUtil.createRefreshToken(username, 60 * 60 * 10000L);  
  
        Cookie cookie = new Cookie("refreshToken", refreshToken);  
        cookie.setHttpOnly(true);  
        cookie.setPath("/");  
        cookie.setMaxAge(60 * 60 * 24 * 30);  
        cookie.setSecure(true);  
	    response.addCookie(cookie);  
  
        response.addHeader("Authorization", "Bearer " + accessToken);  
  
        redisUtil.saveRefreshToken(username, refreshToken, 60 * 60 * 10000L);  
    }

우선 시나리오대로 사용자가 로그인에 성공하면 Access 토큰과 Refresh 토큰을 생성하고 Access 토큰은 Response 헤더에, Refresh 토큰은 cookie에 담아 전달한다.

그리고 유효성을 검증하기 위한 Refresh 토큰을 redis에 저장한다.

AuthController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@RestController  
@RequestMapping("/api/auth")  
@RequiredArgsConstructor  
public class AuthController {  
  
    private final JwtUtil jwtUtil;  
    private final RedisUtil redisUtil;  
  
    @PostMapping("/refresh")  
    public ResponseEntity<?> refreshAccessToken(HttpServletRequest request) {  
	    String refreshToken = null;  
  
        if (request.getCookies() != null) {  
		    for (Cookie cookie : request.getCookies()) {  
			    if ("refreshToken".equals(cookie.getName())) {  
				    refreshToken = cookie.getValue();  
                }  
		    }  
	    }  
  
	    if (refreshToken == null) {  
		    return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Refresh 토큰이 유효하지 않습니다.");  
        }  
  
	    String username = jwtUtil.getUsername(refreshToken);  
        String storedRefreshToken = redisUtil.getRefreshToken(username);  
  
        if (!refreshToken.equals(storedRefreshToken) || jwtUtil.isExpired(refreshToken)) {  
		    return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Refresh 토큰이 유효하지 않습니다.");  
        }  
  
	    HttpHeaders responseHeaders = new HttpHeaders();  
        String cachedAccessToken = redisUtil.getAccessToken(username);  
  
        if (cachedAccessToken == null || jwtUtil.isExpired(cachedAccessToken)) {  
		    String newAccessToken = jwtUtil.createAccessToken(username, "USER", 60 * 60 * 10L);  
            redisUtil.saveAccessToken(username, newAccessToken, 60 * 60 * 10L); // 새 Access 토큰 캐싱  
		    responseHeaders.set("Authorization", "Bearer " + newAccessToken);  
        } else {  
		    responseHeaders.set("Authorization", "Bearer " + cachedAccessToken);  
        }  
  
		return ResponseEntity.ok().headers(responseHeaders).body("Access 토큰이 갱신되었습니다.");  
    }  
  
    @PostMapping("/logout")  
    public ResponseEntity<?> logout(HttpServletRequest request) {  
	    String authHeader = request.getHeader("Authorization");  
  
        if (authHeader != null && authHeader.startsWith("Bearer ")) {  
		    String token = authHeader.split("  ")[1];  
            String username = jwtUtil.getUsername(token);  
  
            redisUtil.deleteRefreshToken(username);
            redisUtil.deleteAccessToken(username);  
            return ResponseEntity.ok().body("로그아웃이 완료되었습니다.");  
        }  
  
	    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("로그인이 되어있지 않습니다.");  
    }  
}

그리고 AuthController를 구현한다.

이 컨트롤러에서는 로그아웃과 Refresh 토큰을 이용한 Access 토큰의 갱신의 역할을 담당한다.

각 부분은 코드를 보면 이해할 수 있다. 다만 캐싱된 부분을 보자.

SpringBoot + Redis를 사용할 경우 @EnableCaching, @Cacheable 등의 애노테이션을 통해 비교적 캐싱 로직을 간소화할 수 있다.

하지만 JWT 토큰처럼 보안과 관련된 중요한 정보를 캐싱하기 때문에 명시적으로 캐싱 로직을 구현했다. (Spring의 캐싱 추상화는 주로 데이터 검색 로직에 적용된다고 한다.)

  • refresh
    • 쿠키에서 refresh 헤더를 가져와 기존에 redis에 저장한 refresh 토큰과 비교하여 유효성을 검증한다.
    • 그리고 그 refresh 토큰으로 user정보를 얻어, access 토큰에 접근한다.
      • access 토큰이 캐싱되어 있다면 해당 토큰을 사용하고, 그렇지 않으면 새로 발급한다.
      • 새로 발급한 토큰은 redis에 캐싱해주어야 한다.
  • logout
    • 로그아웃 시 저장되어 있는 Refresh 토큰과 Access 토큰을 삭제해주어야 한다.

정리

백엔드 서버에서의 구현은 이걸로 끝이다.

로그인에 성공했을 경우 각 토큰을 담아 전달하는 과정, 토큰의 갱신, 로그아웃의 기능을 한다.

그리고 해당 토큰들을 이용하는 부분은 프론트 서버에서 해주어야 한다. 어떤 경우에 /api/auth/refresh 엔드포인트를 호출할 지, Access 토큰을 메모리 변수에 어떻게 저장하는 지 등의 부분이다.

이 부분은 다음 포스트에서 다루겠다.

참고

위의 코드들은 상당히 지저분하다. 리팩토링이 필요하다.

위 코드는 우선 작동 구조와 과정을 조금 더 쉽게 이해할 수 있도록 리팩토링을 하지 않았다.

This post is licensed under CC BY 4.0 by the author.

Spring Security + JWT를 이용한 회원가입 및 로그인 (2)

JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민(2) - 프론트 서버