단일 토큰의 사용처를 추적하면 다음과 같다
1. 로그인 성공 후 JWT 발급: 서버측 -> 클라이언트로 JWT 발급
2. 권한이 필요한 모든 요청: 클라이언트측 -> 서버로 JWT 전송
권한이 필요한 요청은 보통 서비스에서 이루어짐(회원 CRUD, 게시글/댓글 CRUD, 주문 서비스 등)
따라서 JWT는 수많은 요청을 위해 클라이언트의 JS코드로 HTTP 통신을 통해 서버로 전달됨
해커는 클라이언트 측에서 XSS를 이용하거나 HTTP 통신을 가로채서 토큰을 훔칠 수 있기 때문에 여러 기술을 도입하여 탈취를 방지하고 탈취되었을 경우 대비 로직이 존재함
위와 같은 문제가 발생하지 않도록 Access/Refresh 토큰의 개념이 등장
자주 사용 되는 토큰의 생명주기는 짧게(약10분), 이 토큰이 만료 되었을 때 함께 받은 Refresh 토큰(24시간 이상)으로 토큰을 재발급
(생명주기가 짧으면 만료시 매번 로그인을 해야하는 문제 발생, 생명 주기가 긴 Refresh 토큰도 함께 발급)
1. 로그인 성공 시 생명주기와 활용도가 다른 토큰 2개 발급: Access/Refresh
2. 권한이 필요한 모든 요청: Access 토큰을 통해 요청
3. 권한이 알맞다는 가정 하에 2가지 상황: 데이터 응답, 토큰 만료 응답
4. 토큰이 만료된 경우 Refresh 토큰으로 Access 토큰을 발급
5. 서버 측에서는 Refresh 토큰을 검증 후 Access 토큰을 새로 발급
단일 -> 다중 토큰으로 바뀌면서 자주 사용되는 Access 토큰이 탈취되어도 생명주기가 짧아 피해 확률이 줄었음
하지만, Refresh 토큰이 사용되는 빈도만 적을 뿐 탈취 될 확률이 존재함. 따라서 Refresh 토큰을 보호하는 방법도 필요함
각 스토리지에 따른 취약점
위 방법이 필수는 아님
위와 같이 저장소 특징에 맞게 수행해도 탈취 당할 수 있음. 따라서 생명주기가 긴 Refresh 토큰에 대한 추가적인 조치가 있음.
Access 토큰이 만료되어 Refresh 토큰을 가지고 서버 특정 엔드포인트에 재발급을 진행하면 Refresh 토큰 또한 재발급 하여 프론트측으로 응답하는 방식이 Refresh Rotate임
네이버 서비스를 이용하다보면 평소와 다른 IP나 브라우저로 접속했을 시 사용자 계정으로 메일 알림이 옴
이 때 본인이 아닐 경우 "아니오"를 클릭하면 서버측 토큰 저장소에서 해당 유저에 대한 Refresh 토큰을 모두 제거하여 앞으로의 인증을 막을 수 있음
로그인이 성공하면 Access/Refresh 다중 토큰을 발급해야함
따라서 로그인이 성공한 후 실행되는 메소드 또는 핸들러에서 2개의 토큰을 발급해야함
각각의 토큰은 생명주기가 다르기 때문에 서로 다른 저장소에 저장함
//로그인 성공 시
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
//유저 정보
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);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24*60*60);
// cookie.setSecure(true);
// cookie.setPath("/");
cookie.setHttpOnly(true);
return cookie;
}
//토큰 생성
public String createJwt(String category, String username, String role, Long expiredMs) {
return Jwts.builder()
//키에 대한 데이터
.claim("category", category)
.claim("username", username)
.claim("role", role)
//현재 발행 시간
.issuedAt(new Date(System.currentTimeMillis()))
//언제 소멸 될 것인지
.expiration(new Date(System.currentTimeMillis() + expiredMs))
//키를 통해 암호화 진행
.signWith(secretKey)
.compact();
}
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
프론트측 API Client로 서버측에 요청을 보낸 후 데이터를 획득. 이 때 권한이 필요한 경우 서버측에 Access 토큰을 요청 헤더에 첨부하는데, Access 토큰 검증은 서버측 JWTFilter에 의해 진행됨
이 때 Access 토큰이 만료된 경우 특정한 상태 코드 및 메시지를 응답해야함
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//access 토큰 값을 꺼냄
String accessToken = request.getHeader("access");
//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 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);
User user = new User();
user.setUsername(username);
user.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(user);
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
서버측 JWTFilter에서 Access 토큰의 만료로 인해 특정 상태 코드가 응답되면 프론트측 Axios Interceptor와 같은 예외 핸들러에서 Access 토큰 재발급을 위한 Refresh를 서버측에 전송
이 때 서버측은 Refresh 토큰을 받아 새로운 Access 토큰을 응답하는 코드 작성
@Service
public class ReissueService {
private final JwtUtil jwtUtil;
public ReissueService(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
public ResponseEntity<?> reissueAccessToken(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 refresh
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
//response status code
return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
}
//토큰이 refresh인지 확인 (발급 시 payload에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return new ResponseEntity<>("refresh token not valid", HttpStatus.BAD_REQUEST);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
//response
response.setHeader("access", newAccess);
return new ResponseEntity<>(HttpStatus.OK);
}
}
@RestController
public class ReissueController {
private final ReissueService reissueService;
public ReissueController(ReissueService reissueService) {
this.reissueService = reissueService;
}
@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
return reissueService.reissueAccessToken(request, response);
}
}
.requestMatchers("/reissue").permitAll()
Reissue 엔드포인트에서 Refresh 토큰을 받아 Access 토큰 갱신시 Refresh 토큰도 갱신하는 방법
@Service
public class ReissueService {
private final JwtUtil jwtUtil;
public ReissueService(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
public ResponseEntity<?> reissueAccessToken(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 refresh
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
//response status code
return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
}
//토큰이 refresh인지 확인 (발급 시 payload에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return new ResponseEntity<>("refresh token not valid", HttpStatus.BAD_REQUEST);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//response
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return new ResponseEntity<>(HttpStatus.OK);
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24 * 60 * 60);
// cookie.setPath("/");
// cookie.setSecure(true);
cookie.setHttpOnly(true);
return cookie;
}
}
Rotate 되기 이전의 토큰을 가지고 서버측으로 가도 인증이 되기 때문에 서버측에서 발급했던 Refresh를 모두 기억한 뒤 블랙리스트 처리를 진행하는 로직 필요
단순하게 JWT를 발급하여 전송하면 인증/인가의 주도권이 클라이언트에게 있음
JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기 전까지 서버측에서는 방어가 불가능하며 프론트측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제되었다면 피해를 입을 수 있음
문제를 해결하기 위해 생명주기가 긴 Refresh 토큰은 발급시 서버측 저장소에 저장 후 저장되어 있는 Refresh 토큰만 사용할 수 있도록 서버측이 주도권을 가짐
토큰 저장소는 RDB 또는 redis와 같은 데이터베이스를 통해 Refresh 토큰을 저장. redis의 경우 TTL 설정을 통해 생명주기가 끝난 토큰은 자동으로 삭제할 수 있는 장점이 있음
@Entity
@Getter
@Setter
public class Refresh {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String refresh;
private String expiration;
}
public interface RefreshRepository extends JpaRepository<Refresh, Long> {
Boolean existsByRefresh(String refresh);
@Transactional
void deleteByRefresh(String refresh);
}
//로그인 성공 시
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
//유저 정보
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 토큰 DB 저장
addRefresh(username, refresh, 86400000L);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
private void addRefresh(String username, String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
Refresh refreshEntity = new Refresh();
refreshEntity.setUsername(username);
refreshEntity.setRefresh(refresh);
refreshEntity.setExpiration(date.toString());
refreshRepository.save(refreshEntity);
}
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository),
UsernamePasswordAuthenticationFilter.class);
@Service
public class ReissueService {
private final JwtUtil jwtUtil;
private final RefreshRepository refreshRepository;
public ReissueService(JwtUtil jwtUtil, RefreshRepository refreshRepository) {
this.jwtUtil = jwtUtil;
this.refreshRepository = refreshRepository;
}
public ResponseEntity<?> reissueAccessToken(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 refresh
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
//response status code
return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
}
//토큰이 refresh인지 확인 (발급 시 payload에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return new ResponseEntity<>("refresh token not valid", HttpStatus.BAD_REQUEST);
}
//토큰이 DB에 저장되어있는지 확인
Boolean isExist = refreshRepository.existsByRefresh(refresh);
if (!isExist) {
//response status code
return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//Refresh 토큰 저장, DB에 저장되어 있는 기존 Refresh 토큰은 삭제 후 새로운 Refresh 토큰 저장
refreshRepository.deleteByRefresh(refresh);
addRefresh(username, newRefresh, 86400000L);
//response
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return new ResponseEntity<>(HttpStatus.OK);
}
private void addRefresh(String username, String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
Refresh refreshEntity = new Refresh();
refreshEntity.setUsername(username);
refreshEntity.setRefresh(refresh);
refreshEntity.setExpiration(date.toString());
refreshRepository.save(refreshEntity);
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24 * 60 * 60);
// cookie.setPath("/");
// cookie.setSecure(true);
cookie.setHttpOnly(true);
return cookie;
}
}
TTL 설정을 통해 자동으로 Refresh 토큰이 삭제되면 무방하지만, 계속해서 토큰이 쌓일 경우 문제가 될 수 있음.
따라서 스케줄 작업을 통해 만료시간이 지난 토큰은 주기적으로 삭제하는 것이 좋음
로그아웃 기능을 통해 JWT 탈취 시간을 줄일 수 있음
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]
프로젝트의 커스텀 필터 또한 시큐리티 필터단에 구현 예정
public class CustomLogoutFilter extends GenericFilterBean {
private final JwtUtil jwtUtil;
private final RefreshRepository refreshRepository;
public CustomLogoutFilter(JwtUtil jwtUtil, RefreshRepository refreshRepository) {
this.jwtUtil = jwtUtil;
this.refreshRepository = refreshRepository;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, filterChain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
//path and method verify
String requestURI = request.getRequestURI();
if (!requestURI.matches("\\/logout$")) {
filterChain.doFilter(request, response);
return;
}
String requestMethod = request.getMethod();
if (!requestMethod.equals("POST")) {
filterChain.doFilter(request, response);
return;
}
//get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
//refresh null check
if (refresh == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//expired check
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//토큰이 refresh인지 확인 (발급시 payload에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//DB에 저장되어 있는지 확인
Boolean isExist = refreshRepository.existsByRefresh(refresh);
if (!isExist) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//로그아웃 진행
//Refresh 토큰을 DB에서 제거
refreshRepository.deleteByRefresh(refresh);
//Refresh 토큰 Cookie 값 0
Cookie cookie = new Cookie("refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setHttpOnly(true);
response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
}
}
http
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository),
LogoutFilter.class);
PC의 경우 IP주소가 변경될 일이 적음. IP주소가 변경되는 경우 요청이 거부되도록 설정 가능
참조링크
개발자 유미 - 스프링 JWT 심화