Notice
Recent Posts
Recent Comments
Link
관리 메뉴

look-forest

스프링 JWT 심화 본문

Spring/Spring Security (feat. JWT, OAuth2)

스프링 JWT 심화

studyHub 2024. 11. 14. 22:18

단일 JWT 발급을 넘어, 더 높은 수준의 보안을 위한 JWT 구현 방법을 알아보자.


보안을 위한 JWT의 진화

단일 토큰 사용의 문제점

JWT 탈취 위험

1. 로그인 성공 시 JWT 발급 : 서버 → 클라이언트

2. 권한이 필요한 모든 요청에 JWT 전송 : 클라이언트 → 서버

 

JWT는 수 많은 요청을 위해 클라이언트의 JS 코드로 HTTP 통신을 통해 서버로 전달된다. 이 과정에서 해커는 클라이언트 측에서 XSS를 이용하거나 HTTP 통신을 가로채 토큰을 탈취할 수 있다.

  • XSS(Cross-Site Scripting) : JS와 같은 악성 코드를 삽입하여 브라우저에서 실행되도록 공격.
    쿠키, 세션 등 탈취 가능. 서버에서 쿠키 설정 시 httpOnly 속성을 추가하면 쿠키를 JS로 접근할 수 없게 설정.
  • CSRF(Cross-Site Request Forgery) : 인증된 사용자의 권한을 악용하여 공격자가 의도한 요청을 서버로 전송.

따라서 JWT 탈취 방지 방법과, 탈취 시 대비 방법을 모색해야 한다.


다중 토큰

Access/Refresh 토큰

위와 같은 문제를 방지하기 위해 Access/Refresh 토큰 개념이 등장한다.

토큰 탈취 시 대비를 위해, 자주 사용되는 Access 토큰의 생명주기는 짧게(약 10분)하는 것이다.

그럴 경우 매번 로그인을 진행해야 하는 문제가 생기므로, Access 토큰의 재발급을 위한 Refresh 토큰은 길게(24시간 이상) 발급한다.

 

프로세스

1. 로그인 성공 시 생명주기와 활용도가 다른 토큰 2개 발급

  • Access 토큰 : 권한이 필요한 모든 요청 헤더에 사용될 JWT (탈취 시 대비 → 금방 만료되도록)
  • Refresh 토큰 : Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용 (탈취 방지 → 호출 및 전송 빈도 낮춤)

2. 권한이 필요한 모든 요청 : Access 토큰을 통해 요청

3. 권한이 알맞을 경우 : 데이터 응답 or 토큰 만료 응답

4. 토큰 만료된 경우, Refresh 토큰으로 Access 토큰 발급

  • Access 토큰이 만료 되었다는 응답 받으면 프론트엔드에서 1에서 발급받은 Refresh 토큰을 가지고 Access 토큰 재발급 요청
  • 서버 측에서는 Refresh 토큰을 검증 후 Access 토큰을 새로 발급

구현 포인트

1. 로그인 완료 시 successHandler에서 Access/Refresh 토큰 2개를 발급해 응답

2. Access 토큰을 검증하는 JWT Filter에서 Access 토큰이 만료된 경우 협의된 상태코드와 메시지 응답

3. 프론트에서 Access 토큰 만료 요청을 받으면 Refresh 토큰을 전송하여 Access 토큰 발급 요청

4. 서버 측에서는 Refresh 토큰을 발급 받은 엔트포인트(컨트롤러)를 구성하여 Refresh 토큰 검증 및 Access 토큰 응답

 


토큰 탈취 대비

Access 토큰은 탈취 되더라도 생명주기가 짧아 피해 확률이 줄었다. Refresh 토큰은 사용 빈도를 줄였다.

하지만 여전히 탈취 확률이 존재하므로 Refresh 토큰에 대한 보호 방법도 필요하다.

1. 토큰의 저장 위치

JWT 저장 위치에 따른 취약점 : 로컬 스토리지와 쿠키

  • 로컬 스토리지 : XSS 공격에 취약 → Access 토큰 저장 (CSRF 공격이 더 위험하므로)
    짧은 생명 주기로 탈취에서 사용까지 기간이 짧고, 에디터 및 업로드에서 XSS 방어 로직을 작성하여 최대한 보호할 수 있으나, CSRF 공격의 경우 클릭 한번으로 단기간 요청이 진행된다. 권한이 필요한 모든 경로에 사용되므로 CSRF 공격 위험보다는 XSS 공격을 받는게 더 나은 선택일 수 있다.
  • httpOnly 쿠키 : CSRF 공격에 취약 → Refresh 토큰 저장 (사용처 제한되어 CSRF 피해가 적다)
    쿠키는 httpOnly 설정을 하면 완벽히 방어할 수 있다. CSRF 공격에 위험할 수 있지만, Refresh 토큰의 사용처는 토큰 재발급 뿐이므로 크게 피해 입을 만한 로직이 없다.

2. Refresh 토큰 Rotate

아무래도 생명주기가 길기 때문에 추가 방어 조치로, Access 토큰 갱신 시 Refresh 토큰도 재발급하여 프론트 측에 응답.

 


Refresh 토큰 주도권

로그아웃과 Refresh 토큰 블랙리스팅

로그아웃 시 프론트 측에 존재하는 Access/Refresh 토큰을 제거해도, 이미 해커가 JWT를 복제했다면 요청이 수행된다.

세션 방식과 달리 JWT를 서버가 주도권을 갖고 있지 않기 때문에.. 정상적인 JWT로 오면 응답해줄 수 밖에 없다. 그저 생명주기가 끝나길 기다릴 뿐..

 

그렇다면 서버측에도 토큰을 저장해서 확인하면 된다.

생명주기가 긴 Refresh 토큰은 발급과 동시에 서버 측 저장소에도 저장하여, 요청이 올때마다 저장소에 존재하는 지 확인하는 방법으로 주도권을 가질 수 있다. 로그아웃이나 탈취 피해가 진행되는 경우 서버 측 저장소에서 해당 JWT를 삭제하면 된다.

 

로그인 시 메일 알림

네이버/구글 등 서비스를 평소에 사용하지 않던 IP나 브라우저에서 접근할 경우,  사용자 계정으로 메일 알림이 발생한다.

이때 '로그인 한 적 없음'을 클릭하면 서버 측 토큰 저장소에서 해당 유저에 대한 Refresh 토큰을 모두 제거하여 앞으로의 인증을 막을 수 있다.

 


구현

1. 다중 토큰 발급 : 로그인 필터 內 로그인 성공 핸들러

- 로그인 성공 후 실행되는 successfulAuthentication 메소드 또는 AuthenticationSuccessHandler에 구현

- 각각의 토큰은 생명주기와 사용처를 고려하여 서로 다른 저장소에 발급. (발급 시 refresh DB 저장)

  • Access : 헤더에 발급 후 프론트에서 로컬 스토리지에 저장
  • Refresh : 쿠키에 발급
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
    //JWT 발급
    CustomUserDetails userDetails = (CustomUserDetails) authResult.getPrincipal();
    String username = userDetails.getUsername();
    String authority = userDetails.getAuthorities().iterator().next().getAuthority();
    long expiredMs = 60 * 60 * 10L;

    String accessToken = jwtUtil.createJwt("access", username, authority, 1000 * 60 * 10L);
    String refreshToken = jwtUtil.createJwt("refresh", username, authority, 1000 * 60 * 60 * 24L);

    //Refresh token 저장
    saveRefreshToken(username, refreshToken, 1000 * 60 * 60 * 24L);

    //발급 토큰 응답
    response.setHeader("access", accessToken); //헤더에 발급 후 프론트에서 로컬 스토리지 저장
    response.addCookie(createCookie("refresh", refreshToken));
    response.setStatus(HttpStatus.OK.value());
}

 

 

2. Access 토큰 검증 : JWT 필터

- 헤더에서 access 토큰을 꺼내 검증하여 user 정보를 SecurityContextHolder에 넣는다.

- access 토큰이 만료된 경우 프론트에 실패 응답 (→reissue)

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    //request 에서 AccessToken 을 찾아 검증
    String accessToken = request.getHeader("access");

    if (accessToken == null) {
        log.info("accessToken null");
        //조건이 해당되면 해당 필터 종료 (필수)
        filterChain.doFilter(request, response);
        return;
    }

    //토큰 만료 검증. 만료 시 프론트에 즉시 응답
    try {
        jwtUtil.isExpired(accessToken);
    } catch (ExpiredJwtException e) {
        response.getWriter().write("access token expired");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return;
    }

    //토큰이 AccessToken 인지 확인
    if(!jwtUtil.getCategory(accessToken).equals("access")){
        response.getWriter().write("invalid access token");
        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);

    CustomUserDetails userDetails = new CustomUserDetails(userEntity);
    Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

    //세션에 사용자 등록
    SecurityContextHolder.getContext().setAuthentication(authToken);

    filterChain.doFilter(request, response);
}

 

 

3. Refresh 토큰으로 Access 토큰 재발급 : ReissueController

- 프론트에서 요청할 전용 엔드포인트(컨트롤러) 구현

- SecurityConfig에 해당 엔드포인트 permitAll

- access 토큰 재발급 시 Refresh Rotate (보안성 강화, 로그인 지속시간 길어짐)

- DB에 refresh 토큰 update

access 토큰 만료 시 실패 응답 받으면 예외 핸들링으로 토큰 reissue

@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
    String refreshToken = null;

    for (Cookie cookie : request.getCookies()) {
        if (cookie.getName().equals("refresh"))
            refreshToken = cookie.getValue();
    }

    if (refreshToken == null) {
        return new ResponseEntity<>("refreshToken token null", HttpStatus.BAD_REQUEST);
    }

    //expired check
    try {
        jwtUtil.isExpired(refreshToken);
    } catch (ExpiredJwtException e) {
        return new ResponseEntity<>("refreshToken token expired", HttpStatus.BAD_REQUEST);
    }

    //토큰이 refresh인지 확인 (발급시 페이로드에 명시)
    if (!jwtUtil.getCategory(refreshToken).equals("refresh"))
        return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);


    //DB에 저장되어 있는지 확인
    if (!refreshTokenRepository.existsByRefreshToken(refreshToken))
        return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);

    //새로운 토큰 생성
    String username = jwtUtil.getUsername(refreshToken);
    String role = jwtUtil.getRole(refreshToken);

    String newAccessToken = jwtUtil.createJwt("access", username, role, 100 * 60 * 10L);
    String newRefreshToken = jwtUtil.createJwt("refresh", username, role, 100 * 60 * 60 * 24L);

    //Refresh 토큰 저장
    refreshTokenRepository.deleteByRefreshToken(refreshToken);
    saveRefreshToken(username, newRefreshToken, 100 * 60 * 60 * 24L);

    response.setHeader("access", newAccessToken);
    response.addCookie(createCookie("refresh", newRefreshToken));
    return new ResponseEntity<>(HttpStatus.OK);
}

 

4. 로그아웃 : 로그아웃 필터 커스텀

- genericFilterBean 상속받아 커스텀 로그아웃 필터 구현

- 필터 등록(기본 로그아웃 필터 앞에)

- 로그아웃 버튼 클릭 시

  • 프론트엔드 측 : 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버 측 로그아웃 경로로 Refresh 토큰 전송
  • 백엔드 측 : Refresh 토큰을 받아 DB에서 삭제 후 쿠키 null로 초기화하여 응답 (모든 기기에서 로그아웃 시 username 기반으로 모든 refresh 토큰 삭제)
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
    //path and method verify
    String requestUri = request.getRequestURI();
    String requestMethod = request.getMethod();
    if (!requestUri.matches("^\\/logout$") || !requestMethod.equals("POST")) {
        filterChain.doFilter(request, response);
        return;
    }

    //get refresh token
    String refresh = null;
    for (Cookie cookie : request.getCookies()) {
        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.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
    if (!jwtUtil.getCategory(refresh).equals("refresh")) {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    //DB에 저장되어 있는지 확인
    if (!refreshTokenRepository.existsByRefreshToken(refresh)) {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    //로그아웃 진행
    //Refresh 토큰 DB에서 제거
    refreshTokenRepository.deleteByRefreshToken(refresh);

    //Refresh 토큰 Cookie 값 0
    Cookie cookie = new Cookie("refresh", null);
    cookie.setMaxAge(0);
    cookie.setPath("/");

    response.addCookie(cookie);
    response.setStatus(HttpServletResponse.SC_OK);
}

 

http
        .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

http
        .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshTokenRepository)
                , UsernamePasswordAuthenticationFilter.class);
http
        .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshTokenRepository), LogoutFilter.class);

 

참고 자료 & 이미지 출처
https://www.devyummi.com/page?id=66937e102991346fee18ea37
 

개발자 유미 | 커뮤니티

 

www.devyummi.com