빙응의 공부 블로그
[Spring REST API]로그인 구현하기 - Refresh Token 보안 강화 본문
📝 개요
저번 포스팅까지 Access Token과 Refresh Token 두개를 발급하고 생명주기가 끝난 후 다시 발급하는 것까지 해보았다.
[Spring REST API]로그인 구현하기 - 2개의 토큰 (tistory.com)
[Spring REST API]로그인 구현하기 - 2개의 토큰
📝 개요 - 단일 토큰을 왜 안쓰는가?바로 전 포스팅에서 Access Token을 이용한 JWT 인증을 성공적으로 사용해보았다.[Spring REST API]로그인 구현하기 - JWT + Security (tistory.com) [Spring REST API]로그인 구현
quddnd.tistory.com
이번에는 보다 안전하게 Refresh Token을 관리하는 법에 대해 알아보자!!
1. Refresh Rotate
2. Refresh Token 서버 주도권
3. 로그아웃
4. 더 강화 기능
📝 Refresh Rotate
Refresh Rotate란?
저번 시간에 우리는 Refresh 토큰을 받아 Access 토큰을 갱신해 보았다.
Refresh Rotate는 Access 토큰 갱신 대 보안을 위해 Refresh 토큰도 갱신하는 것이다.
🚩 왜 그리 번거롭게 함?
그 이유는 크게 2가지가 있다.
- Refresh 토큰 교체로 보안성 강화
- 전에 탈취 당해도 새로 바뀌기 때문에 이것을 해결할 수 있어용
- 로그인 지속 시간이 길어져 사용감 향상
구현 방법은 쉽다 저번에 만들었던 Refresh 토큰을 이용한 Access 토큰 재발급에서 로직만 추가하면 된다.
@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
//쿠키에서 Refresh 토큰 얻기
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
if (refresh == null) {
throw new AppException(AuthErrorCode.REFRESH_TOKEN_NULL);
}
//만료 시간 체크
try {
jwtTokenProvider.isExpired(refresh);
} catch (ExpiredJwtException e) {
throw new AppException(AuthErrorCode.REFRESH_TOKEN_EXPIRED);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtTokenProvider.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
String userName = jwtTokenProvider.getUsername(refresh);
//make new JWT
String newAccess = jwtTokenProvider.createAccessToken("access", userName);
//response
response.setHeader("access", newAccess);
return new ResponseEntity<>(HttpStatus.OK);
}
수정 후
@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
//쿠키에서 Refresh 토큰 얻기
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
if (refresh == null) {
throw new AppException(AuthErrorCode.REFRESH_TOKEN_NULL);
}
//만료 시간 체크
try {
jwtTokenProvider.isExpired(refresh);
} catch (ExpiredJwtException e) {
throw new AppException(AuthErrorCode.REFRESH_TOKEN_EXPIRED);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtTokenProvider.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
String userName = jwtTokenProvider.getUsername(refresh);
//make new JWT
String newAccess = jwtTokenProvider.createAccessToken("access", userName);
String newRefresh = jwtTokenProvider.createRefreshToken("refresh", userName);
//response
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return new ResponseEntity<>(HttpStatus.OK);
}
사실 그냥 Access 토큰만 만들어서 보내줬던 걸 Refresh 토큰도 만들어서 보내주면 된다.
//make new JWT
String newAccess = jwtTokenProvider.createAccessToken("access", userName);
String newRefresh = jwtTokenProvider.createRefreshToken("refresh", userName);
//response
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return new ResponseEntity<>(HttpStatus.OK);
변한 부분은 이 부분 밖에 없으나 이 행위로 인해 더 확실한 보안을 얻게 되는 것이다.
근데 아직 해야할 것이 있다.
Rotate 되기 이전의 토큰을 가지고 서버측으로 가도 인증이 되기 때문에 서버측에서 발급했던 Refresh들을 기억한 뒤 블랙리스트 처리를 진행하는 로직을 작성해야 한다.
이것이 바로 서버측에서 토큰을 관리하기 위해 DB에 저장하는 것이다.
📝 Refresh 토큰 서버 주도권과 서버 측 저장
단순하게 JWT를 발급하여 클라이언트측으로 전송하면 인증/인가에 대한 주도권 자체가 클라이언트측에 맡겨진다.
JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기까지 서버측에서는 아무것도 막을 수 없으며,
프론트 측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제되어 피해를 입을 수 있다.
이러한 문제를 해결하기 위해 생명주기가 긴 Refresh 토큰을 발급 시 서버측에 저장하고 저장된 Refresh 토큰만
사용할 수 있도록 서브측에서 주도권을 가질 수 있게 할 수 있다.
1. 저장을 위한 Refresh Entity 정의
간단하게 정의하자
@Entity
@Getter
@Setter
public class Refresh {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String refresh;
private String expiration;
}
2. JPA를 사용하기 위한 RefreshRepository 구현
public interface RefreshRepository extends JpaRepository<RefreshEntity, Long> {
Boolean existsByRefresh(String refresh); //존재하는지 확인
@Transactional
void deleteByRefresh(String refresh);
}
3. LoginFilter의 successfulAuthentication() 수정하기
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
String userName = authentication.getName();
//토큰 생성
String AccessToken = jwtTokenProvider.createAccessToken("access",userName);
String RefreshToken = jwtTokenProvider.createRefreshToken("refresh",userName);
//Refresh 토큰 저장
addRefresh(userName, RefreshToken, RefreshTokenRemiteTime);
//응답 설정
response.setHeader("access", AccessToken);
response.addCookie(createCookie("refresh", RefreshToken));
response.setStatus(HttpStatus.OK.value());
}
기존 코드에 Refresh 토큰 저장만 해주면 된다.
private void addRefresh(String userName, String refresh, Long expired){
Date date = new Date(System.currentTimeMillis() + expired);
Refresh refreshEntity = Refresh.builder()
.username(userName)
.refresh(refresh)
.expiration(date.toString())
.build();
refreshRepository.save(refreshEntity);
}
4. Refresh 토큰으로 Access 토큰 재발급 시 Refresh도 다시 만들게 수정하기
여기도 2개의 기능만 넣어주면 된다.
- DB에 Refresh 토큰이 있는지 확인하는 것
- Refresh 토큰 재발급 후 저장
@PostMapping("/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
//쿠키에서 Refresh 토큰 얻기
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
if (refresh == null) {
throw new AppException(AuthErrorCode.REFRESH_TOKEN_NULL);
}
//만료 시간 체크
try {
jwtTokenProvider.isExpired(refresh);
} catch (ExpiredJwtException e) {
throw new AppException(AuthErrorCode.REFRESH_TOKEN_EXPIRED);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtTokenProvider.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
//DB에 저장되어 있는지 확인=====================================================
Boolean isExist = refreshService.existsByRefresh(refresh);
if (!isExist) {
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
String userName = jwtTokenProvider.getUsername(refresh);
//=============================================================================
//make new JWT
String newAccess = jwtTokenProvider.createAccessToken("access", userName);
String newRefresh = jwtTokenProvider.createRefreshToken("refresh", userName);
//Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장=========
refreshService.deleteByRefresh(refresh);
refreshService.addRefresh(userName, newRefresh);
//=============================================================================
//response
response.setHeader("access", newAccess);
response.addCookie(cookieStore.createCookie("refresh", newRefresh));
return new ResponseEntity<>(HttpStatus.OK);
}
추가된 부분은 =======으로 줄쳐진 부분이다. 서비스는 간단하게 만들었다.
@Service
@RequiredArgsConstructor
public class RefreshService {
private final RefreshRepository refreshRepository;
private final long RefreshTokenRemiteTime = 1000L * 60 * 60 * 24; // 1일
//새로운 Refresh 토큰 생성해서 저장
public void addRefresh(String userName, String refresh){
Date date = new Date(System.currentTimeMillis() + RefreshTokenRemiteTime);
Refresh refreshEntity = Refresh.builder()
.username(userName)
.refresh(refresh)
.expiration(date.toString())
.build();
refreshRepository.save(refreshEntity);
}
//저장소에서 Refresh 토큰이 있는지 검사
public Boolean existsByRefresh(String refresh) {
return refreshRepository.existsByRefresh(refresh);
}
public void deleteByRefresh(String refresh) {
refreshRepository.deleteByRefresh(refresh);
}
}
🚩 토큰 관리에 좋은 저장소 Redis
JWT 관리의 대표격인 Redis는 생명주기가 지나면 자동으로 삭제해주는 로직이 있어 사용하기 좋다!
현업에서 Redis로 많이 구현하니 공부하도록 하자
📝 로그아웃 시 Refresh 토큰 처리
로그아웃 기능을 통해 추가적인 JWT 탈취 시간을 줄일 수 있다.
- 로그아웃 버튼 클릭시
- 프론트엔드 : 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버 측 로그아웃 경로로 Refresh 토큰 전송
- 백엔드 : 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화 후 Refresh DB에서 해당 Refresh 삭제
백엔드에서 구현해야하는 작업
- DB에 저장하고 있는 Refresh 토큰 삭제
- Refresh 토큰 쿠기 null로 변경
1. 스프링 시큐리티 로그아웃 필터 구현
public class CustomLogoutFilter extends GenericFilterBean
해당 구문처럼 extend를 받으면 필터를 구현할 수 있다.
public class CustomLogoutFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshService refreshService;
public CustomLogoutFilter(JwtTokenProvider jwtTokenProvider, RefreshService refreshService) {
this.jwtTokenProvider = jwtTokenProvider;
this.refreshService = refreshService;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//logout 요청인지 확인
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;
}
//Refresh 토큰 받기
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
//Refresh 토큰 null 체크
if (refresh == null) {
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
//유효 기간 확인
try {
jwtTokenProvider.isExpired(refresh);
} catch (ExpiredJwtException e) {
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtTokenProvider.getCategory(refresh);
if (!category.equals("refresh")) {
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
//DB에 저장되어 있는지 확인
Boolean isExist = refreshService.existsByRefresh(refresh);
if (!isExist) {
throw new AppException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
//로그아웃 진행
//Refresh 토큰 DB에서 제거
refreshService.deleteByRefresh(refresh);
//Refresh 토큰 Cookie 값 0
Cookie cookie = new Cookie("refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
}
}
해당 doFilter는 다음 과정을 진행한다.
- Filter이기 때문에 모든 요청에 대해 Uri가 /logout 인지 검사
- HTTP 메소드가 POST인지 검사
- 해당 요청에서 쿠키로 Refresh 토큰을 얻음
- Refresh 토큰의 null과 유효기간 체크
- 토큰이 Refresh인지 확인
- Refresh 토큰이 DB에 저장되었는지 확인
- 로그아웃 진행(Refresh 토큰 DB에서 제거 -> 쿠키 Null 값으로 만듦)
마지막으로 만든 필터를 SecurityConfig에 올리면 끝이다.
http
.addFilterBefore(new CustomLogoutFilter(jwtTokenProvider, refreshService), LogoutFilter.class);
📝 마무리
이번에는 Refresh 토큰의 보안을 위한 과정을 알아보았다.
Refresh Rotate를 통해 보안을 강화하고 이것을 활용하기 위해 DB에 저장, 또한 logout 시 보안도 강화해보았다.
이정도도 충분한 보안이지만 네이버 같은 사이트처럼 IP와 Refresh를 같이 저장하여 IP가 바뀌면 재로그인하게 만드는 것도 괜찮은 방법이라고 한다.
이제 끝

'Spring > 개인공부_실습' 카테고리의 다른 글
[Spring]OAuth2.0 - 필수 변수와 네이버 요청하기 (0) | 2024.07.30 |
---|---|
[Spring]OAuth2.0 간단한 동작 원리 및 모식도 (0) | 2024.07.30 |
[Spring REST API]로그인 구현하기 - 2개의 토큰 (0) | 2024.06.04 |
[Spring REST API]로그인 구현하기 - JWT + Security (1) | 2024.06.03 |
[Spring REST API]로그인 구현하기 - 기본 (0) | 2024.06.03 |