이전 게시글
https://morenow.tistory.com/56
이전 게시글에서 JWT가 어떤 방식으로 동작하는지 어떤 흐름인지 알아보았다.
이제 실제 동작하는 과정을 알아보고, Spring Boot에서의 간단한 코드를 통해 분석해보자.
그리고 JWT의 단점의 절충안인 Refresh Token에 대해서도 알아보자.
Access Token
서버에서 발급한 JWT를 해당 서버에 접근할 수 있다는 의미로 Access Token 이라고 했다. (아, 물론 Access Token이라는 단어가 JWT를 말하는 것은 아니다.)
이 Access Token 에 대한 실제 Spring Boot 에서의 활용을 코드로 살펴보자.
발급 과정 코드
public String generateAccessToken(Long id, String role) {
Date issuedAt = new Date();
Date accessTokenExpiresIn = new Date(issuedAt.getTime() + getAccessTokenTtlMilliSecond());
return buildAccessToken(id, issuedAt, accessTokenExpiresIn, role);
}
private String buildAccessToken(Long id, Date issuedAt, Date accessTokenExpiresIn, String role) {
final Key encodedKey = getSecretKey();
return Jwts.builder()
.setIssuer("bookitlist")
.setIssuedAt(issuedAt)
.setSubject(id.toString())
.claim("type", "ACCESS_TOKEN")
.claim("role", role)
.setExpiration(accessTokenExpiresIn)
.signWith(encodedKey)
.compact();
}
Access Token 을 직접 생성하는 부분의 코드이다.
우선 generateAccessToken 메소드에서 발급 일시, 사용 기한 등 Access Token 에 들어갈 재료들을 구성해 buildAccessToken 메소드를 호출한다.
그리고 buildAccessToken 메소드에서 Issuer, IssuedAt, Subject, type, role, Expiration 의 정보를 payload에 넣어 Access Token 을 만들어낸다.
그렇게 나온 JWT는 다음과 같다.
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJib29raXRsaXN0IiwiaWF0IjoxNzA5ODkwOTIyLCJzdWIiOiIxIiwidHlwZSI6IkFDQ0VTU19UT0tFTiIsInJvbGUiOiJVU0VSIiwiZXhwIjoxNzA5ODk0NTIyfQ.fNqpZip4Erp4qG3Zxl-1m_shA5ehJdTM6Yegk2GJYLM
당연히 내가 Payload 에 넣은 정보가 많으면 길이가 길어진다.
이것을 jwt.io 에서 디코딩해보면 다음과 같이 나온다.
내가 넣은 정보들이 PAYLOAD 부분에 모두 나온다. 또한 HEADER 부분에 내가 사용한 암호화 알고리즘도 나온다.
하지만 SIGNATURE 부분은 디코딩을 하지 못한다. BASE64 단순 인코딩이 아니라 secret 값과 함께 해시 알고리즘으로 해싱한 값이기 때문이다.
발급 후 요청 코드
이후 로그인이 필요한 모든 요청에는 클라이언트가 Header부분의 Authorization 에 Bearer 방식으로 Access Token 을 함께 넣어 보낸다.
서버에서는 해당 Access Token이 서버의 실제 사용자인지, 내가 발급한 JWT인지, expiration 이 지나진 않았는지 등을 검사하고, 옳은 사용자의 요청일 경우에만 Filter를 통과시킨다.
해당 부분의 코드이다. 이 코드는 Controller 에 들어가기 전, Filter 이다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && !authService.isBlocked(token)) {
Authentication authentication = getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String rawHeader = request.getHeader("Authorization");
String bearer = "Bearer ";
return rawHeader != null && rawHeader.length() > bearer.length() && rawHeader.startsWith(bearer) ? rawHeader.substring(bearer.length()) : null;
}
우선, Http 요청에서 Bearer 토큰의 실제 값을 가져오는 resolveToken 메소드를 거친다. 여기서 이상이 발생할 경우 token 값이 null이 된다.
그 후, 이 Access Token 이 Block 되었는지 확인한다. Block 되지 않았다면 토큰으로부터 Authentication 객체를 가져와 SecurityContextHolder 에 담아서 해당 스레드에서 유저의 id를 알 수 있도록 하고 필터를 통과시킨다. (코드는 모두 하단의 github 링크에 있다.)
Access Token 의 단점
Access Token은 서버의 리소스에 직접적으로 접근할 수 있는 권한을 갖고 있기 때문에 유출되면 안되는 정보이다.
하지만 만약 100년짜리 Access Token이 들어왔다고 가정해보자. 보안이 뚫리든,,, 나이가 들어 죽든,,, 해당 JWT는 100년 안에 탈취되어도 이상하지 않을 것이다. 즉, Access Token은 적당히 "짧아야" 한다는 것이다.
그러면 5분짜리 Access Token이 들어왔다고 가정해보자. 5분이 지나면 Access Token의 유효기간이 만료되어 재로그인해서 다시 발급받아야 한다. 이런 비효율적인 짓이 있을까... 따라서 탈취 위험과 잦은 로그인 불편함의 절충안으로 Refresh Token을 생각해낸다.
Refresh Token
Access Token 발급 시, 아래와 같은 Refresh Token 이란 JWT 를 하나 더 만들어 보낸다.
왜 필요할까?
Access Token 은 적당히 짧아야 한다고 했다. 위의 예처럼 5분만에 Access Token 이 만료되면 유저는 로그인을 해야할 것이고, 다시 로그인해야 하는 불편함이 있을 것이다. 제일 안전하지만,, 유저의 입장에선 불편하다!
직접 로그인하지 않고 자동으로 Access Token이 됐으면 좋겠다는 것이다. 이 "Access Token 자동 발급권" 이 바로 Refresh Token 이다.
Access Token을 1시간 정도로 제한하고, 2주짜리 Refresh Token 을 동시에 발급한다.
만약, Access Token 이 만료됐다는 서버의 응답을 받게된다면, 프론트엔드에서는 Refresh Token 을 보내 새로운 Access Token 을 보내달라고 요청한다. Refresh Token이 만료되지 않았다면 새로운 Access Token을 보내줄 수 있다.
한계점
Refresh Token 은 다음과 같은 시나리오에서 이득을 취할 수 있다.
Access Token만 탈취
Refresh Token 을 믿고 Access Token 의 기한을 짧게 했기 때문에, Access Token 을 탈취당해도 그 짧은 시간동안만 서버에 접속할 수 있는 권한을 뺏긴 것이다.
하지만 다음 시나리오에서는 한계점이 존재한다.
Refresh Token 탈취
Access Token 의 탈취 유무와 상관없이, Refresh Token 만 탈취당한다면 큰일이 난다. 이건 Access Token 을 무제한으로 발급받을 수 있는 발급권이기 때문에 2주 동안 마음대로 Access Token 을 발급받아서 서버에 접속할 수 있는 것이다.
이 한계점을 다시 한 번 해결하고자 다음과 같은 방법을 생각해낸다.
Refresh Token Rotation (RTR)
이제 Redis 라는 인메모리 캐시 서버가 도입된다. Refresh Token 을 발급할 때 마다 해당 토큰을 Redis 에 저장한다.
이제 Refresh Token 검증 시, 즉 Access Token 재발급 시 Refresh Token 이 Redis 에 있는지도 함께 검사하게 된다.
그리고 Access Token 을 재발급할 때, 기존 Refresh Token을 Redis 에서 삭제하고 새로운 Refresh Token을 발급해서 Redis에 저장한다. 즉 Access Token 과 Refresh Token 을 함께 재발급하는 것이다.
다음의 일반적인 시나리오에서 이렇게 하는 이유를 찾을 수 있다.
- Trudy 라는 사람이 Alice 라는 사람의 Refresh Token 을 탈취한다.
- Trudy 가 Alice 의 Access Token과 Refresh Token 을 재발급받아가면서 서버에 마음대로 접근한다.
- Alice 는 자신의 Access Token이 만료되었을 때 자동으로 Access Token 과 Refresh Token 을 재발급받게 된다. 이러면 Redis 에는 Alice 자신이 재발급한 Refresh Token 만이 남게 된다.
- Trudy 가 다시 한 번 Access Token 과 Refresh Token 을 재발급받으려고 할 때 Redis 에는 Trudy 가 가지고 있는 Refresh Token 은 없으므로 잘못된 요청으로 간주하고 요청을 무시한다
- Trudy 는 이제 Access Token 을 재발급받지 못해 서버에 접근하지 못한다.
즉, 사용자가 서비스에 자주 접근해서 악용하는 사람의 흔적을 지우듯이 작동하는 것이다.
하지만 이 방법도 한계점은 여전히 많다.
사용자가 서비스에 접근하지 않는다면 악용하는 사람은 계속 서비스에 접근할 수 있게 된다.
또한 사용자가 너무 많아질 경우에 Redis 에 부하가 일어날 수도 있다. 심각한 수준은 아니지만 추가적인 연산이 많이 일어나기 때문에 복잡해질 수 있다는 것이다.
설명한 모든 방법은 다음 프로젝트에 적용되어 있다.
https://github.com/BookitList/BookitList_backend
'Spring Boot' 카테고리의 다른 글
Spring의 새로운 동기식 HTTP Client, RestClient (0) | 2024.03.09 |
---|---|
Oauth 2.0 & JWT 의 두 가지 흐름, 그리고 OIDC (1) | 2023.10.11 |
JWT 총정리 (1) - JWT의 원리와 흐름 (1) | 2023.10.09 |
Git in IntelliJ - 인텔리제이에서 Git의 모든 것 (2) (0) | 2023.02.03 |
Git in IntelliJ - 인텔리제이에서 Git의 모든 것 (1) (0) | 2023.01.27 |