Spring/OAuth 2.0

[Spring] OAuth 2.0 로그인 Refresh Token 도입기 (1)

sowith 2025. 2. 27. 16:52

전 글에 기초하여 OAuth 2.0의 로그인 구현을 완료했다.

하지만 Access Token만으로 로그인을 구현했기 때문에 Refresh Token을 도입해보려고한다.

 

 

Refresh Token 이란? 

Access Token을 재발급할 때 사용하는 토큰이다. 기본적으로 Access Token은 API 통신을 할 때 사용하는 키이기 때문에 이를 탈취당한다면 문제가 된다. 따라서 Access Token의 만료기간을 짧게 두어, 주기적으로 Refresh Token을 통해 Access Token을 재발급해주고 피해를 최소화 해주는 것이다. Refresh Token의 만료기간은 보통 Access Token보다 길다.


현재 프로젝트의 구현 

- OAuth 로그인이 성공적으로 완료된다면, 쿠키를 통해 Access Token을 발급해준다.

- 프론트에서는 쿠키와 함께 API 요청을 한다.

- 백엔드에서 쿠키의 Access Token을 검증하고, 원하는 유저의 정보를 응답해준다.

- 프론트에서 응답된 데이터를 통해 화면을 구성해준다.

 

바꿀 부분

1. 쿠키 방식의 Access Token을 헤더 방식으로 바꿔준다.

2. 쿠키 방식으로 Refresh Token을 발급해준다.

3. 해당 유저의 Refresh Token을 DB에 저장해주는 로직을 추가해준다. (후에 재발급 확인용)

4. JWTFilter의 인증 토큰을 쿠키가 아닌 헤더의 토큰으로 검증을 진행해준다. 


 

Refresh Token을 DB에 저장해주기 위한 작업

 

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 refresh);

    @Transactional
    void deleteByRefresh(String refresh);
}

 

 

 

쿠키의 Access Token을 헤더로 옮기는 로직 구상

1. 로그인이 성공한다면 Access Token과 Refresh Token을 쿠키로 발급해준다.

2. 프론트의 특정 페이지로 리다이렉션을 보내준다.

3. 해당 페이지는 쿠키를 통해 API 요청을 보낸다.

4. 해당 요청에서 백엔드는 쿠키의 Access Token을 헤더로 옮겨준다.

5. 프론트의 요청을 보낸 페이지에서 응답이 정상적으로 온다면 홈으로 이동한다.

 

쿠키의 Access Token을 어떻게 헤더로 옮길지에 대한 고민이 많았다.

사실 위의 방식으로 구현했을 때, 해당 API 요청도 JWT Filter를 통해 인증이 필요하지 않을까라는 생각이 있었다.

하지만 JWT Filter는 헤더의 Access Token을 검증하기 때문에 쿠키의 Access Token을 꺼내 검증하는 방식과 맞지 않았다.

쿠키의 Access Token을 한번 헤더로 옮기면 그후 모든 API 요청은 헤더의 Access Token을 검증할테니, JWTFilter에 해당 로직(쿠키 속 토큰 검증)을 추가해주는 것이 굳이 싶기도 했다.

그래서 CookieToHeaderFilter라는 쿠키에 AccessToken이 있을 때만 헤더로 옮겨주는 필터를 JWTFilter 전에 추가해줄까 싶기도 했지만, 생각해보니 쿠키를 헤더로 옮기는 API 요청은 검증이 필요없다고 생각이 들었다.

왜냐하면 쿠키를 헤더로 옮길때 해당 토큰이 인증되지 않더라도, 그후 API 요청은 어차피 모두 JWTFilter를 통해 인증이 필요할테니 잘못된 토큰이더라도 쿠키와 헤더의 Access Token은 동일하니 그후 요청에서 접근이 불가하게 될 것이기 때문이다. 따라서 SecurityConfig에서 해당 API요청 경로는 permitAll()로 설정해주면 된다.

 

 

CustomSuccessHandler 

@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JWTUtil jwtUtil;
    private final RefreshService refreshService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        //OAuth2User
        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

        String username = customUserDetails.getUsername();

        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, 1*60*60*1000L); // 1시간 
        String refresh = jwtUtil.createJwt("refresh", username, role, 3*60*60*1000L); // 3시간

        refreshService.addRefreshEntity(username, refresh, 86400000L);

        response.addCookie(createCookie("Authorization", access));
        response.addCookie(createCookie("refresh", refresh));

        response.sendRedirect("http://localhost:3000/token-setup");
    }

    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(60*60*60);
        cookie.setPath("/"); 
        cookie.setHttpOnly(true);

        return cookie;
    }


}

 

 

JWTController

@RestController
@RequiredArgsConstructor
public class JWTController {
    private final JWTService jwtService;

    @GetMapping("/toHeader")
    public ResponseEntity<String> tokenToHeader(HttpServletRequest request, HttpServletResponse response) throws IOException {

        jwtService.tokenToHeader(request, response);

        return ResponseEntity.ok("토큰이 성공적으로 헤더로 옮겨졌습니다.");
    }

}

 

 

JWTService

@Service
public class JWTService {

    public void tokenToHeader(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String token = null;
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("Authorization")) {
                    token = cookie.getValue();
                    break;
                }
            }
        }

        if(token != null){
            response.addHeader("Authorization", "Bearer " + token);
        }

    }
}

 

 

JWTFilter

@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;


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

        String accessToken = request.getHeader("access");

        if (accessToken == null) {

            filterChain.doFilter(request, response);

            return;
        }

        accessToken = accessToken.split(" ")[1];

        String category = jwtUtil.getCategory(accessToken);
        Boolean isExpired = jwtUtil.isExpired(accessToken);

        if (isExpired || !category.equals("access")) {

            filterChain.doFilter(request, response);

            return;
        }

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

        UserDTO userDTO = new UserDTO();
        userDTO.setUsername(username);
        userDTO.setRole(role);

        //UserDetails에 회원 정보 객체 담기
        CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDTO);

        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
        
        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

 

 

Refrence

개발자 유미님 유튜브