Web/Spring Security

Spring Security 6.x.x JWT 보안 요소 추가

조용우 2025. 2. 20. 17:12

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