본문 바로가기
# Study/Spring

[Spring] Spring Security + JWT 구현 해보기 (다중 토큰) (2)

by Jiy0ung 2024. 11. 27.

Spring Security + JWT 다중토큰 코드를 작성하려고 한다.

우선, 이전 Srping Security + JWT 단일토큰 포스팅의 코드를 가져와 수정하여 작성할 것이기 때문에 코드를 보려면 링크를 따라가면 확인 할 수 있다.

https://jiy0ung.tistory.com/26

 

[Spring] 스프링 시큐리티 JWT 구현 해보기 (단일 토큰)

스프링 시큐리티를 활용하여 JWT 기반의 인증/인가를 구현하고, MySQL 데이터베이스를 활용하여 회원 정보 저장을 해볼 것이다.서버는 웹 페이지를 응답하는 것이 아닌 API 클라이언트 요청을 통해

jiy0ung.tistory.com

 

이 코드에서 다중 토큰으로 변경을 위해 수정 할 부분은 로그인 성공 시 처리 부분과 JWT 검증 필터 부분이다.


로그인 성공 시 다중 토큰 발급과 발급 위치

로그인이 성공하면 기존에 단일 토큰만 발급했지만 Access/Refresh에 해당하는 다중 토큰을 발급할 것이다.

따라서 로그인이 성공한 이후 실행되는 SuccessfulAuthentication()메소드 또는 AuthenticationSuccessHandler를 구현한 클래스에서 2개의 토큰을 발급한다.

 

AccessToken은 헤더에 발급 후 프론트에서 로컬스토리지에 저장하는 방식으로 할 것이고, RefreshToken은 쿠키에 발급할 예정이다.


다중 토큰 발급 코드 작성

  • LoginFilter의 SuccessfulAuthentication() 메서드
@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("access", access);
    response.addCookie(createCookie("refresh", refresh));
    response.setStatus(HttpStatus.OK.value());
}
  • LoginFilter의 쿠키 생성 메소드 추가
    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의 createjwt() 메소드
    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();
    }
  • JWTUtil의 getCategory 메소드 추가 : 토큰 판단용
    public String getCategory(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
    }

프론트에서 데이터 요청

프론트의 API Client로 서버측에 요청을 보낸 후 데이터를 획득한다. 이때 권한이 필요한 경우 AccessToken을 요청헤더에 첨부하는데 AccessToken 검증은 서버측 JWTFilter에 의해 진행된다.

이때 AccessToken이 만료된 경우 특정한 상태 코드 및 메시지를 응답해야 한다.

 


토큰 필터 작성

  • JWTFilter의 doFilterInternal 수정

토큰 기간이 만료되었는지 확인한다.

@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);
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        // 일시적 세션 생성
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }

 


RefreshToken으로 AccessToken 재발급

 

 

서버측 JWTFilter에서 AccessToken 만료로 인한 특정 상태코드가 응답되면 프론트측 Axios Interceptor와 같은 예외 핸들러에서 AccessToken 재발급을 위한 RefreshToken을 서버측으로 전송한다.

 

이때, 서버에서는 RefreshToken을 받아 새로운 AccessToken을 응답하는 코드를 작성하면 된다.

 

Reissue 코드 작성

  • ReissueController 작성
@Controller
@ResponseBody
//@RestController = @Controller + @ResponseBody
@RequiredArgsConstructor
public class ReissueController {

    private final ReissueService reissueService;

    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
        return reissueService.reissue(request, response);
    }
}
  • ReissueService 작성
@Service
@RequiredArgsConstructor
public class ReissueService {

    private final JWTUtil jwtUtil;

    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        // 요청에서 RefreshToken을 가져오기
        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);
        }

        // refreshToken 기간 만료 확인
        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);

        // accessToken 재발급
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);

        // response 헤더에 accessToken 넣어주기
        response.setHeader("access", newAccess);

        return new ResponseEntity<>(HttpStatus.OK);

    }
}

 

  • SecurityConfig "/reissue" 경로 permitAll
.requestMatchers("/reissue").permitAll()

Refresh Rotate

Refresh Rotate Reissue 엔드포인트에서 RefreshToken을 받아 AccessToken 갱신 시 RefreshToken도 함께 갱신하는 방법이다.

 

  • 장점
    • RefreshToken 교체로 보안성 강화
    • 로그인 지속시간 길어짐
  • 추가 구현 작업
    • 발급했던 RefreshToken 모두 기억한 뒤, Rotate 이전의 RefreshToken은 사용하지 못하도록 해야 함.

 

Reissue Service 코드 수정

  • reissue 메소드에 RefreshToken 발급 추가 및 쿠키 생성 메소드 추가
@Service
@RequiredArgsConstructor
public class ReissueService {

    private final JWTUtil jwtUtil;

    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        // 요청에서 RefreshToken을 가져오기
        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);
        }

        // refreshToken 기간 만료 확인
        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);

        // accessToken 재발급
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
        // refreshToken도 재발급 해준다.
        String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);

        // response 헤더에 accessToken 넣어주기
        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.setSecure(true);
        //cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }
}

 

주의할점

Rotate 되기 이전의 토큰을 가지고 서버측으로 가도 인증이 되기 때문에 서버측에서 발급했던 Refresh들을 기억한 뒤 블랙리스트 처리를 진행하는 로직을 작성해야 한다.


RefreshToken 서버측 저장

발급시 RefreshToken을 서버측 저장소에 저장하고, 갱신 시 (Refresh Roate) 기존 RefreshToken은 삭제하고 새로 발급한 RefreshToken을 저장한다.

이렇게, 서버측에 저장함으로써 로그아웃만 구현 되어 있는 경우 이미 토큰 탈취가 되어 있어 피해보는 것을 예방할 수 있다.

 

토큰 저장소 구현

RDB(MySQL 등) 또는 Redis와 같은 데이터베이스를 통해 RefreshToken을 저장한다. Redis의 경우에는 TTL 기능이 있어 설정해두면 생명주기가 끝이난 토큰은 자동으로 삭제할 수 있는 장점이 있고, RDB의 경우 직접 스케줄러 등과같은 기능을 추가하여 생명주기가 끝난 토큰을 삭제 하는 로직을 추가해야한다.

 

RefreshToken Entity 및 레포지토리 작성

  • RefreshEntity
@Entity
@Getter
@Setter
public class RefreshEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String refresh;
    private String expiration;
}

 

  • RefreshRepository
@Repository
public interface RefreshRepository extends JpaRepository<RefreshEntity, Long> {

    Boolean existsByRefresh(String token);

    // 데이터 변경 작업이기 때문에 Transactional 어노테이션을 붙인다.
    @Transactional
    void deleteByRefresh(String token);
}

 

 

로그인 시 : LoginSuccessHandler 수정

  • LoginFilter에서 successfulAuthentication() 수정
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        // 유저 정보
        String username = authentication.getName();

        // role 값 알아내기
        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);

        //RefreshToken 저장
        addRefreshEntity(username, refresh, 86400000L);

        //응답 설정
        response.setHeader("access", access);
        response.addCookie(createCookie("refresh", refresh));
        response.setStatus(HttpStatus.OK.value());
    }
  • LoginFilter에 addRefreshEntity 메소드 추가
    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);
    }

SecurityConfig 설정

  • securityConfig

RefreshRepository 의존성 주입 후 LoginFilter 등록시 refreshRepository 의존성 주입

http
		.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,
                                refreshRepository), UsernamePasswordAuthenticationFilter.class)

 

Reissue시 : ReissueService

  • RefreshService에서 reissue() 메소드 수정

RefreshRepository 의존성 주입 후 reissue() 메서드 안에 RefreshToken이 DB에 저장되어 있는지 확인하는 코드 추가

 

    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        // 요청에서 RefreshToken을 가져오기
        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);
        }

        // refreshToken 기간 만료 확인
        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);
        }

        //DB에 저장되어 있는지 확인
        Boolean isExist = refreshRepository.existsByRefresh(refresh);
        if (!isExist) {

            //response body
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }

        String username = jwtUtil.getUsername(refresh);
        String role = jwtUtil.getRole(refresh);

        // accessToken 재발급
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
        // refreshToken도 재발급 해준다.
        String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);

        //RefreshToken 저장 DB에 기존 RefreshToken 삭제 후 새 RefreshToken 저장
        refreshRepository.deleteByRefresh(refresh);
        addRefreshEntity(username, newRefresh, 86400000L);

        // response 헤더에 accessToken 넣어주기
        response.setHeader("access", newAccess);
        response.addCookie(createCookie("refresh", newRefresh));

        return new ResponseEntity<>(HttpStatus.OK);
    }
  • reissueService에 addRefreshEntity 메소드 추가
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);
}

 

 

TTL 설정을 통해 자동으로 RefrehToken이 삭제되면 무방하지만 계속해서 토큰이 쌓일 경우 용량 문제가 발생할 수 있다.

따라서 스케줄 작업을 통해 만료시간이 지난 토큰은 주기적으로 삭제하는 것이 올바르다.


로그아웃 기능

로그아웃 버튼 클릭시 프론트엔드측에서는 로컬 스토리지에 존재하는 AccessToken 삭제 및 서버측 로그아웃 경로로 RefreshToken을 전송하고, 백엔드측에서는 로그아웃 로직을 추가하여 RefreshToken을 받아 쿠키 초기화 후 Refresh DB에서 해당 RefreshToken을 삭제 하면 된다.

 

백엔드 로그아웃 수행 작업

  • DB에 저장하고 있는 RefreshToken 삭제
  • RefreshToken 쿠키 null로 변경

로그아웃 필터 구현

jwt 패키지 내에 CustomLogoutFilter 파일을 만들어 작성한다.

  • CustomLogoutFilter
@RequiredArgsConstructor
public class CustomLogoutFilter extends GenericFilterBean {

    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    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 = 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("/");

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

 

  • SecurityConfig 추가
// logoutFilter 등록
http
		.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class)

이렇게 회원가입, 로그인, 로그아웃, 재발급 엔드포인트를 만들어보았다.

Postman을 통해 테스트 해보면 잘되는 것을 알 수 있다.

 

최종 코드는 링크에 첨부되어있습니다! security_jwt_token 프로젝트에서 확인해주세요

https://github.com/Zy0ung/login_study

 

GitHub - Zy0ung/login_study

Contribute to Zy0ung/login_study development by creating an account on GitHub.

github.com

 

 

 

Spring Security, JWT 단일/다중 토큰에 대해서 자세히 알아 볼 수 있어서 좋은 시간이었다.

 

참고

https://www.devyummi.com/page?id=66937e102991346fee18ea37