이번시간부터는 JWT버전을 Upgrade해서 진행해보도록하겠다.
반드시 저의 이전 포스트 JWT 최신버전 글을 읽고 오시길 부탁드리겠습니다.
토큰사용추적
토큰이 어디에서 사용될까?
권한이 필요한 요청은 서비스에서 많이 발생한다. -> 회원 CRUD,댓글 CRUD, 주문서비스, 등등
따라서 JWT가 매시간 수많은 요청을 위해 클라이언트의 JS코드로 HTTP통신을 통해서 서버로 전달된다.
그래서 이 수많은 요청중에서 해커가 xss를 이용하거나 HTTP통신을 가로채서 토큰을 훔칠 수 있기 때문에 토큰 탈취를 방지하거나, 혹은 탈취되었을때 대비 로직이 존재한다.
다중토큰: Refresh 토큰과 생명주기
위와같은 문제가 발생하지 않도록 Access/Refresh토큰 두가지 개념이 등장한다.
Access 토큰이 탈취되더라도 생명주기가 짧아 피해 확률이 줄었다.
그러나 Refresh토큰 또한 사용되는 빈도가 적을 뿐이지 충분히 탈취될 가능성이 있다.
그래서 Access,Refresh토큰의 저장 공간에 대해서 고려를 해야하는데,
로컬 스토리지 -> xss공격에 취약 : Access토큰 저장
httpOnly 쿠키 -> CSRF공격에 취약 : Refresh토큰 저장
고려
JWT탈취는 보통 xss공격으로 로컬 스토리지에 있는 Access토큰을 가져간다. 그러면 쿠키는 안전하냐? 그것도 아니다. 쿠키에다 저장하면 CSRF공격에 취약하다.
Access토큰을 로컬 스토리지에 두는 이유
xss로 Access토큰을 탈취하더라도, 토큰의 유효기간이 10분이라면 토큰을 불러오는데 2분 30초 에디터 및 업로드에서 2분30초 정도 걸리면 xss로 Access토큰을 탈취하더라도 5분 밖에 사용하지 못한다.
그러나 CSRF공격의 경우에는 한번의 클릭으로 단시간에 탈취가 가능해서 차라리 CSRF공격을 받을 바에는 xss공격을 당한다는 차선책입니다.
Refresh토큰을 httpOnly 쿠키에 두는 이유
쿠키는 xss공격을 받을 수 있지만 httpOnly를 설정하면 완벽히 방어가 가능합니다. 그러면 CSRF는 방어하냐? 이건 아닙니다.
다만 Refresh토큰을 CSRF공격으로 인해 탈취당하더라도, Refresh토큰의 사용처는 단 하나, 토큰 재발급 경로 이므로, CSRF는 Access토큰이 접근하는 회원정보, 수정, 게시글 CRUD같은 로직을 수행하지 못하고, 재발급경로에서는 크게 피해를 입힐만한 로직이 없기에 쿠키에 두는것입니다.
Access토큰이 만료되어 Refresh토큰을 가지고 서버 특정 엔드포인트에 재발급을 진행하면, Refresh토큰 또한 재발급하여 프론트 쪽으로 응답하는 방식이 Refresh Rotate 입니다.
이문제가 생기는 이유가 애초에 JWT가 탈취를 당한 시점에서는 서버측 주도권이 없기 때무에 피해를 막을 방법이 없습니다.
왜그럴까요? 저의 이전 JWT포스트를 읽으셧다면 아시겠지만, 다시 설명드리자면,
JWT의 쓰임새는 딱 하나입니다. 세션처럼 Username,Passsword로 올바른 인가방식인가를 확인하는게 아니라, 지금 들어오고있는 요청이 정상적인 사람이 보내는게 맞는가? 이걸 확인하는겁니다.
자 그래서 JWT를 서명된 토큰이라 하는데
마치 JWT를 봤을때는 영어로 어쩌구 되어있어서 암호화가 된거 같지만,
Header와 Payload의 a.b.c에서 a,b에 해당하는 부분은 특별한 암호화가 걸려있는게 아니라서 누구나 base64 디코딩을 하면 이 값을 알 수 있습니다.
그래서 우리가 a,b에는 누구나 봐도 되는 password같은경우에는 이상한 값을 넣고, 누구나 봐도 되는 username을 넣는것입니다.
그러면이걸 도대체 왜쓰냐? 바로 c부분에 해당하는 Signature부분입니다.
그림을 보면 헤더와 페이로 그리고 서버가 가지고 있는 개인키를 가지고 암호화가 되어있습니다. 따라서 signature부분은 서버에 있는 개인키로만 암호화를 풀 수 있으므로 다른 클라이언트는 임의로 Signature를 복호화 할 수 없습니다.
그러니까 해커가 임의로 JWT토큰을 만들어서 요청을 보낼라 해도, Signature부분에서 서버의 시크릿키를 모르니까 서버에서 인증을한 JWT 토큰을 만들 수 가 없다는 말입니다.
그래서 웹사잍트에서 보안을 할때 누구나 유추 가능한 이름 홍길동이나, 김철수, 김영희로 만들지 말라는겁니다. 이러면 JWT토큰 만들어서 보내면 되니깐요.
심지어 시크릿키도 멍청한 개발자가 누구나 유추하기 쉽게 asdf로 만들었다고 칩시다.
그럼 해커가 홍길동이라는 username은 있겠지 그리고 혹시나 설마 시크릿키로 asdf로 했겠어? 하고 요청을 보냈는데 우연치 않게 이게 맞아버리면 바로 대참사가 나버리는겁니다.
근데 해커입장에서 굳이 이렇게 JWT토큰을 만들어서 보낼 필요가 없겠죠? 왜? 그냥 있는 토큰을 탈취하면 되니까.
앞에서 토큰을 탈취당한 시점에서는 서버는 아무것도 할 수 없다는게 바로 이말입니다. 왜? Jwt는 세션과 다르게 클라이언트의 상태를 저장하지 않는 StateLess로 설계하기 때문에, 그냥 Refresh토큰이 만료 되길 눈 꼭 감고 기도하느 수밖에 없습니다.
이전에는 로그인 성공시 access 토큰만 발급하였지만 이제는 Refresh토큰 또한 같이 발급할것이다.
위에서 설명했듯이 Refresh토큰은 쿠키에다가 저장할것이다.
로그인 성공핸들러
이전의 successfulAuthentication메서드와 다른건 없는데 이제 jwtutil.createJwt메서드에서 category를 추가해준다. access토큰은 생존시간이 짧게 발급하고, refresh토큰은 생존시간이 길게 발급한다.
또한 그렇다면 JwtUtil에 가서 category입력란을 만들어줘야하는데
이 토큰이 access토큰으로는 refresh기능은 하지 못하게 토큰 검증을 하기위해서 카테고리를 가져올 수 있게 getCategory메서드를 만들고
createJwt빌더에 category도 추가해준다.
그다음에 이 refresh토큰을 addCookie메서드로 넣어줬는데
createCookie메서드를 보면
refresh토큰은 생명주기가 기므로 24시간으로 설정하였습니다.
그래서 key값이 refresh가 되고 value에 넘어오는값이 바로 String refresh로 refresh토큰이 됩니다.
https로 통신을 하는경우에는 setSecure(true) 주석을 풀어주고
범위도 setPath로 설정도 가능합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 헤더에서 access키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");
// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
// 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
try {
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
//response body
PrintWriter writer = response.getWriter();
writer.print("access token expired");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 토큰이 access인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(accessToken);
if (!category.equals("access")) {
//response body
PrintWriter writer = response.getWriter();
writer.print("invalid access token");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// username, role 값을 획득
String username = jwtUtil.getUsername(accessToken);
String role = jwtUtil.getRole(accessToken);
UserEntity userEntity = new UserEntity();
userEntity.setUsername(username);
userEntity.setRole(role);
userEntity.setPassword("nothingImportant");
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
이제 그렇다면 filterInternal에서 검증하는 과정을 거처야한다.
이전처럼 단일 토큰이 아니기 때문에, token을 가져온다음에 우리가 이전에 JWTUtils에서 만들었던 getCategory를 활용하여서, access인지 확인을 하였다.
그다음에 username,role값을 획득해서 엔티티를 만들고, 그 엔티티를 일시적인 세션에다가 저장하기 위해서 컨텍스트 홀더에 저장한다.
일단 일시적인 세션에다가 저장해야하니까 password를 아무것도 아닌 이상한 값으로 설정하고, 그다음에 이건 검증된거니까 authToken을 인자가 3개인걸로 검증된 authentication발급후에 세션에 저장했다.
이말은 이전 시큐리티 JWT 버전에서 자세히 설명해 두었다.
그니까 결국 여기서 한게, 토큰을 가지고 접근해야하는 경로라면, 그렇다면 토큰값을 가지고 검증을 해야하는데, 이 검증을 하는과정에서, access라는 헤더 명으로 토큰이 있으니까 가져와서 확인하는 것이다.
여기서 중요한게,
//response status code response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
이부분이다. 프론트단과 협업하여 이런 오류가 발생하였을때는 refresh토큰으로 재발급해야해서 보내줘야하는 약속을 하는것이다.
@Controller
@ResponseBody
public class ReissueController {
private final JWTUtil jwtUtil;
public ReissueController(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
//get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
if (refresh == null) {
//response status code
return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
}
//expired check
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
//response status code
return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make new JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
//response
response.setHeader("access", newAccess);
return new ResponseEntity<>(HttpStatus.OK);
}
}
우선 토큰 재발급에 필요한 reissue컨트롤러를 만들어주자.
그러니까 프론트단에서 access 토큰으로 접근했을때 expired시에 우리는 이전에 프론트와 협의된 오류 코드를 내보냈었다.
그러면 프론트는 엔드포인트를 reissue로 Refresh토큰을 가지고 다시 요청을 보내면 서버는 이 Refresh토큰을 검증후에 Access토큰을 새로 만들어서 반환한다.
Refresh토큰은 쿠키에 있으므로, 쿠키를 돌면서 refresh라고 있는 토큰을 찾는다.
토큰이 null인지 expired인지 refresh인지 전부 확인을 하고,
맞으면, username과 role을 가지고 다시 access토큰을 만들어서 헤더에 넣어서 반환한다.
여기서 내가 궁금증을 느낀게? 어? 근데 refresh토큰도 애초에 같이 보내면, 여기에도 username,role이 있는데 탈취당하면 똑같이 위험한거 아닌가 싶었는데 맨 앞에서 설명했듯이,
일단 요청을 보낼때는 access토큰 만 보낸다. 그리고, access토큰이 만료가 되면, 그떄서야 쿠키에 저장해놓은 refresh토큰을 보낸다.
또한 refresh 토큰을 보내는 과정에서도 탈취가 발생할 수 있지만, 요청 주기가 매우 기므로, 탈취 가능성이 낮아진다는 것이다.
마지막으로 securityfonfig에서 permitAll을 해줘야하는데
왜냐하면 이미 access토큰이 expired한 상태에서 요청을 하는 것이므로 다시 access토큰 발급을 위해서는 permitAll로 모든 user가 요청이 가능하게 해야한다.
참고) 질문을 올렸었는데
안녕하세요 이전 시큐리티 강의에서는 split을 사용해서 Bearer를 떼어낸 후에 사용했는데 왜 이번에는 그 과정이 없는것일 까요? 혹시 떼어내지 않아도 자동적으로 Bearer을 떼어주나요?
그 이유가 cookie를 추가하면서 이다. 왜냐하면 이전에 토큰 발급을
여기서 Bearer를 붙여서 반환해줬는데
이제는 쿠키에다가도 Refresh토큰을 넣어줘야하는데,
최신 쿠키 스펙에는 공백 (“ ”)을 포함 할 수 없어 ”Bearer 토큰값“ 형태 대신 “토큰값” 형태로 발급을 했다.
따라서 Bearer 접두사를 보내지 않았기 때문에 해당 구현을 없앴고, 만약 존재하다면 split으로 분리 후 제거 작업이 필요하다.
그래서 처음에 postman으로 실행할때, 자꾸 헤더를 Authorization으로 설정해서 토큰값을 보내니까 자꾸 토큰이 안먹었는데
내가 access라는 카테고리로 발급하고, access헤더에서 가져오는거라.
이런식으로 포스트맨을 설정해줘야한다.
삽질을 3시간동안했다.
refresh 토큰이 만료되면 어떻게 할까? 당연히 재발급 받아야한다.
그러므로, reissue컨트롤러에서 access토큰을 재발급할때, refresh토큰도 재발급 해주자.
이렇게 Access토큰을 갱신할때 Refresh토큰도 함께 갱신되는방법이 Refresh Rotate기법이라고 한다.
장점
Refresh 토큰 교체로 보안성 강화
로그인 지속시간 길어짐 -> 왜냐면, access토큰만 사용시 만료시간이 10분 이내이므로, 계속 로그인을 다시 해야하지만, Refresh토큰이 있을때는, 만료시간이 24시간이므로 이 Refresh토큰이 만료되기 전에 요청을 한다면 계속 access토큰과 Refresh토큰을 반복해서 갱신해주기 때문이다.
단점
발급했던 Refresh토큰을 모두 기억한뒤에 재발급했다면 이전 Refresh토큰을 기억하지 못하게 해야한다.
그 이유가
12:00분에 access Refresh 첫발급
12:20분에 접근 but access만료 Refresh,access재발급
그런데 문제가 12:00에 발급한 access는 만료되었는데 Refresh토큰은 생명주기가 24시간이라 아직 살아있다.
그러므로, 12:10분에 만약 해커가 첫번째 Refresh토큰을 탈취해서
12:20분 이후에 access,Refresh토큰이 재발급되었지만 탈취한 Refresh토큰으로 요청을 할시 허가가 된다. 왜냐 expired안됐은니까. 다시한번 말하지만 토큰은 session처럼 id,password를 통해서 검증하는게 아니라, 이 토큰이 내 서버에서 발급됐느냐 안됐느냐 이것을 검증하는것이다.
그러므로, 탈취된 토큰은 우리 서버에서 발급한것이 맞으므로, 이 Refresh토큰이 만료될때까지 인가를 허가하게 되는것이다.
고로, 만약에 서버측에서 이 Refresh토큰을 기억하고 갱신하기 전 토큰으로 접근하는 요청을 막는 기능을 구현하지 않는다면, 그저 개발자는 Refresh토큰이 만료되길 손발을 싹싹 빌면서 기도하며 기다리는 수밖에 없다.
refresh토큰을 만들고 쿠키에 추가
앞에 문제를 해결하기 위해서 Refresh토큰을 저장하고 갱신하기 전 토큰을 삭제하여 갱신전 Refresh토큰으로 오는 요청을 막아보는 과정을 해보도록 하겠습니다.
리포지토리만들기
로그인 성공시 LoginSuccessHandler에서
발급한 Refresh토큰을 RDB에 저장해줘야한다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
//유저 정보
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
//토큰 생성
String access = jwtUtil.createJwt("access", username, role, 600000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//Refresh 토큰 저장
addRefreshEntity(username, refresh, 86400000L);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
private void addRefreshEntity(String username, String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
RefreshEntity refreshEntity = new RefreshEntity();
refreshEntity.setUsername(username);
refreshEntity.setRefresh(refresh);
refreshEntity.setExpiration(date.toString());
refreshRepository.save(refreshEntity);
}
addRefresh엔티티 메서드에서 Date로 만료 날짜를 만들고,
그다음에 save를 해준다.
또한 LoginFilter등록시 refreshRepository DI를 해줘야한다.
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration),jwtUtil,refreshRepository), UsernamePasswordAuthenticationFilter.class);
그렇게 하기위해서 생성자 주입을 해줬다.
이제 Reissue에서도 DB에 실제 클라이언트가 요청하는 Refresh토큰이 있는지 확인하고 있다면, 지우고 새로발급해야한다.
참고로 Refresh토큰 저장소에서 기한이 계속 지나면 이 Refresh토큰들이 쌓이기 때문에 스케쥴 작업을 통해 만료시간이 지난 토큰은 주기적으로 삭제해야한다.
왜냐하면 우리는 만료된 토큰으로 재요청을해야 지우고 새로 발급하는데
만약 만명이 한번 로그인하고 다시는 돌아오지 않는다면 만개의 토큰이 DB에 아직 남아있기 때문이다.
로그아웃 기능을 사용하면 추가적인 JWT탈취시간을 줄일수있다.
로그아웃버튼클릭시 해야할일
스프링 시큐리티는 기본적으로 로그아웃필터를 통해 로그아웃 기능이 활성화 되는데
해당 로그아웃을 수행하는 클래스 위치가 필터단이다.
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710
여기서 doFIlter부분을 커스텀해서 쓰면된다.
CustomLogoutFilter
우리도 GenericFilterBean을 상속받고 doFilter를 구현할것이다.
여기서 HttpServletRequest로 캐스팅하고 doFilter를 호출한다.
우선 이 필터는 /logout 요청에만 filter작용이 되야하므로, requestUri를 가져와서 /logout이 아니면 나머지 filterchain을 수행하도록한다.
또한, 이 Logout이 Post요청에서만 수행되도록한다. 왜냐면,
User나 해커가 일부로 uri get방식으로 요청을 보냈는데 가능하도록하면 의도적으로 로그아웃이 수행될 수 있기 때문이다.
쿠키에서 Refresh토큰을 가져와서 null인지 아닌지 검증한다.
만료된 토큰인지 검증하고, 발급시 우리는 Access와 refresh로 카테고리를 정해놨으므로, category가 refresh가 맞는지 확인한다.
실제 삭제하는 코드는 아래와같다.
우선 Refresh토큰이 DB에 존재하는지 확인한다.
그다음에 토큰을 DB에서 제거를하고
refresh name에 value를 null로해서 쿠키를 넣어준다.
그다음에 maxage와 path를 설정하고 addCookie를 통해 넣어준다.
그다음에 ok를 내보낸다.
마찬가지로 이 필터를 등록해야하는데
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class);
스프링시큐리티에 이미 존재하는 LogoutFIlter.class앞에 우리 커스텀 LogoutFilter가 실행되게 하였다.
마지막으로 추가적인 고안방법을 알아보겠다. 구글이나 네이버에서 IP주소가 비정상적인곳에서 접속하면 자신이 맞냐고 물어본다. 만약 아니면, 아니요를 누르면 Refresh토큰과 access토큰을 모두 없앤다.
즉, 처음 Join을할때 회원가입한 회원의 국가를 지정해놓고,
요청이 들어올때 IP를 따서 회원가입한 국가와 다르다면 요청을 거부하고, 메일로 비정상적인 접근이 발생했다 보내고 자신이 아닙니다를 누르면 Refresh,access토큰을 모두 삭제하는 방식도 구현하면 좋겠다.
지금까지 수고하셨습니다.