https://developer-yong.tistory.com/43
JWT 다중 토큰 발급
JWT를 이용한 인증/인가는, 토큰이 XSS, HTTP통신 가로채기 등으로 탈취되었을 때 해커가 서비스를 악용할 수 있다. 따라서, 토큰 탈취 방지 및 탈취되었을 때 대비하는 기술이 존재한다. 다중 토큰
developer-yong.tistory.com
JWT를 단일 토큰 방식으로 사용할 경우, 위와같은 문제점이 존재한다. 따라서, 단일 토큰 방식에서 다중 토큰 발급 방식으로 변경이 필요하다.
LoginFilter.java
@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);
//응답 설정
response.setHeader("Authorization", "Bearer " + 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); // https 적용할 경우
//cookie.setPath("/"); // 쿠키 적용 범위
cookie.setHttpOnly(true); // 자바스크립트로 쿠키 접근 금지
return cookie;
}
JWTUtil.java
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
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();
}
로그인 성공 시 실행되는 successfulAuthentication 메소드에서 access 토큰, refresh 토큰이 발급되도록 변경. 또한, 토큰 구별을 위해 토큰을 생성하는 JWTUtil.java 클래스 내부 createJwt 메소드에 category 항목 추가.
JWTFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//request에서 Authorization 헤더를 찾음
String authorization= request.getHeader("Authorization");
//Authorization 헤더 검증
if (authorization == null || !authorization.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료
return;
}
//Bearer 부분 제거 후 순수 토큰만 획득
String accessToken = authorization.split(" ")[1];
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;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(accessToken);
String role = jwtUtil.getRole(accessToken);
//user Entity를 생성하여 값 set
User user = new User();
user.setUsername(username);
user.setRole(role);
//UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(user);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
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;
}
Access 토큰이 만료되었는지, 토큰이 Access 토큰인지 확인하는 절차 추가
Access 토큰이 만료됐을 때 Refresh 토큰을 이용한 재발급 요청 추가
ReissueController.java
@RestController
@RequiredArgsConstructor
public class ReissueController {
private final ReissueService reissueService;
@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
return reissueService.reissueToken(request, response);
}
}
ReissueService.java
@Service
@RequiredArgsConstructor
public class ReissueService {
private final JWTUtil jwtUtil;
public ResponseEntity<?> reissueToken(HttpServletRequest request, HttpServletResponse response) {
// Refresh Token 가져오기
String refreshToken = extractRefreshToken(request);
if (refreshToken == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Refresh token is null");
}
// Refresh Token 만료 여부 확인
try {
jwtUtil.isExpired(refreshToken);
} catch (ExpiredJwtException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Refresh token expired");
}
// Refresh Token 유효성 검사
if (!"refresh".equals(jwtUtil.getCategory(refreshToken))) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid refresh token");
}
// 새로운 Access Token 발급
String newAccessToken = jwtUtil.createJwt("access", jwtUtil.getUsername(refreshToken), jwtUtil.getRole(refreshToken), 600000L);
// 헤더에 새 Access Token 추가
response.setHeader("Authentication", "Bearer " + newAccessToken);
return ResponseEntity.ok().build();
}
private String extractRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
for (Cookie cookie : cookies) {
if ("Refresh".equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
}
SecurityConfig.java
.requestMatchers("/login", "/", "/join", "/reissue").permitAll()
/reissue 경로 접근 허용
Refresh Rotate 적용
ReissueService.java
@Service
@RequiredArgsConstructor
public class ReissueService {
private final JWTUtil jwtUtil;
public ResponseEntity<?> reissueToken(HttpServletRequest request, HttpServletResponse response) {
...
String username = jwtUtil.getUsername(refresh
String role = jwtUtil.getRole(refreshToken);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
// 헤더에 새 Access Token 추가
response.addCookie(createCookie("Refresh", newRefresh));
return ResponseEntity.ok().build();
}
...
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;
}
}
로그아웃
@RequiredArgsConstructor
public class CustomLogoutFilter extends GenericFilterBean {
private final JWTUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;
@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인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//DB에 저장되어 있는지 확인
Boolean isExist = refreshTokenRepository.existsByRefresh(refresh);
if (!isExist) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//로그아웃 진행
//Refresh 토큰 DB에서 제거
refreshTokenRepository.deleteByRefresh(refresh);
//Refresh 토큰 Cookie 값 0
Cookie cookie = new Cookie("Refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
}
}
GenericFilterBean vs OncePerRequestFilter
URI vs URL
SecurityConfig.java
http
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshTokenRepository), LogoutFilter.class);
필터 작동 순서
org.springframework.security.web.session.DisableEncodeUrlFilter
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextHolderFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.authentication.www.BasicAuthenticationFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.AuthorizationFilter
'Web > Spring Security' 카테고리의 다른 글
Spring Security 6.x.x OAuth2.0 JWT 기반 인증 인가 (0) | 2025.02.26 |
---|---|
JWT 방식 OAuth2.0 클라이언트 동작 원리 (0) | 2025.02.26 |
Spring Security 6.x.x JWT 기반 인증 인가 (0) | 2025.02.15 |
Spring Security JWT 인증 인가 순서 (0) | 2025.02.14 |
Spring Security 6.x.x 세션 기반 인증 인가 (0) | 2025.02.13 |