한화시스템 부트캠프 수강생들을 위한 커뮤니티, Boot-up 프로젝트를 진행하면서, 다음과 같이 항상 마주친 '500 에러 문제'가 있다.
에러의 원인은 아래 사진에서 볼 수 있듯, Access Token의 유효시간이 만료되었기 때문이다.
이전까지는 Access Token을 자동으로 재 발급하는 기능을 구현하지 않았다. 그런데, Access Token의 특성상 토큰의 유효시간이 길지 않아 토큰이 만료되는 상황이 빈번히 발생하였고, 토큰이 만료될 때마다 사용자가 수동으로 다시 로그인을 해야하는 번거로움이 있었다.
위와 같은 문제로, 프로젝트 팀에서는 Access Token이 만료되었을 때, Access Token을 자동으로 재 발급해줄 수 있는 Refresh Token 기능이 필요하다고 판단하였다. Refresh Token 기능은 Refresh Token이 유효하다면, Access Token을 자동으로 재 발급할 수 있어, 사용자가 번거롭게 다시 로그인을 해야하는 상황을 줄일 수 있다.
이번 포스팅에서는 Refresh Token 기능을 구현한 과정을 다뤄보려고 한다.
우선, Refresh Token의 구현 방식에 대해 말하기 앞서, 본 프로젝트에서 일반 로그인으로 인증 및 인가를 요청하는 방식은 다음과 같다.
사용자가 로그인을 하면, LoginFilter에서 Access Token을 발급하여 쿠키에 저장한다. 이때, 추가적으로 쿠키에 Secure, HttpOnly 옵션을 설정하여 세션 하이재킹 등의 해킹을 방지하고, Javascript 코드로 직접 쿠키에 접근할 수 없도록 하였다.
Cookie cookie = new Cookie("JwtToken", token);
cookie.setPath("/");
cookie.setHttpOnly(true); //Javascript 로 접근 불가능
cookie.setSecure(true); //세션 하이재킹 등의 해킹을 방지 ( 인증된 https 에 보낼때만 쿠키를 보내게 해준다. -> http에 보내려면, proxy 사용 (같은 주소))
response.addCookie(cookie);
진행하고 있는 프로젝트가 '한화시스템 부트캠프 수강생들을 위한 커뮤니티 서비스' 이기에, 대부분의 기능에서 Access Token(JwtToken)을 이용해 부트캠프 수강생 및 수료생 인지'의 인가 여부를 요청하는 작업이 발생하였다.
그런데, 쿠키에 HttpOnly 설정을 하다보니, 프론트엔드에서 인증 및 인가가 필요한 작업을 요청할 때, javascript 로 인증 쿠키의 값을 직접 가져와 Autorization 헤더에 넣어보낼 수는 없었다. 대신, withCredential 옵션을 사용하여 해당 어플리케이션의 모든 쿠키를 헤더에 넣어 보내는 방식으로 서버에 요청을 보내도록 구현하였다.
다음은 프론트엔드에서 요청을 보내는 예이다.
let response = await axios.post("/api/login", user, { withCredentials: true });
그렇다면, 이제 프로젝트에서 Refresh Token을 통한 인증 및 인가 과정을 어떻게 구현했는지 다뤄보자.
일반적인 경우, Refresh Token을 통한 인증 및 인가 과정은 다음과 같다.
Access Token과 Refresh Token은 Cookie, LocalStorage 등의 브라우저의 저장 공간에 저장된다. 일반적으로 Access Token은 30분~1시간, Refresh Token은 2주 정도의 유효 기간을 가진다.
본 프로젝트에서도 사용자가 로그인을 하게 되면, LoginFilter 에서 Access Token과 함께 Refresh Token을 발급하여 다음과 같이 쿠키로 저장하도록 구현하였다. 이때, Refresh Token은 서버의 DB(MySQL)에 저장된다.
LoginFilter.class
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
private final RefreshTokenService refreshTokenService;
``` 중략 ```
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
CustomUserDetails user = (CustomUserDetails)authResult.getPrincipal();
//권한 받아오기
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
//권한 받아오기
GrantedAuthority auth = authorities.iterator().next();
String role = auth.getAuthority();
String username = user.getUsername();
Long idx = user.getUser().getIdx();
String nickname = user.getUser().getNickname();
String token = jwtUtil.createToken(idx, username,role,nickname);
String refreshToken = jwtUtil.createRefreshToken(idx, username, role, nickname);
synchronized(this) {
refreshTokenService.save(username, refreshToken);
}
Cookie cookie = new Cookie("JwtToken", token);
cookie.setPath("/");
cookie.setHttpOnly(true); //Javascript 로 접근 불가능
cookie.setSecure(true); //세션 하이재킹 등의 해킹을 방지 ( 인증된 https 에 보낼때만 쿠키를 보내게 해준다. -> http에 보내려면, proxy 사용 (같은 주소))
response.addCookie(cookie);
Cookie refreshCookie = new Cookie("RefreshToken", refreshToken);
refreshCookie.setHttpOnly(true);
refreshCookie.setSecure(true);
refreshCookie.setPath("/");
response.addCookie(refreshCookie);
}
``` 중략 ```
}
보통 Refresh Token을 이용한 Access Token 재발급 과정은, 먼저 클라이언트가 Access Token 만을 헤더에 포함시켜 요청을 보내고, 서버로부터 토큰이 만료 되었다는 에러 메시지를 받게 되면, 이후에 만료된 Access Token 과 함께 Refresh 토큰을 넣어 보내는 것이 일반적이다.
그런데, 본 프로젝트에서는 앞서 말했듯이 인가 요청에 대해, withCredential 옵션으로 해당 애플리케이션의 모든 쿠키값을 헤더에 넣어보내기 때문에, 기본적으로 모든 인가 요청을 보낼 때 Access Token과 Refresh Token이 함께 쿠키에 담겨 서버로 전달된다.
따라서, Access Token이 만료되었다는 메시지를 별도로 클라이언트한테 보내는 작업은 구현하지 않았고, 토큰이 만료되었을 경우, 함께 보내진 Refresh Token을 JwtFilter에서 바로 검사하는 방식으로 구현하였다.
JwtFilter.class
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final RefreshTokenService refreshTokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorization = null;
String refreshToken = null;
if(request.getHeader("Authorization") != null){
authorization = request.getHeader("Authorization");
}
if(request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if (cookie.getName().equals("JwtToken")) {
authorization = cookie.getValue();
}
if (cookie.getName().equals("RefreshToken")) {
refreshToken = cookie.getValue();
}
}
}
// 토큰이 없거나, "Bearer "로 시작하지 않으면 다음 필터로 넘기기
if(authorization == null){
System.out.println("authorization 이 null임");
filterChain.doFilter(request,response); //다음 필터로 넘어가기
return;
}
String token = authorization.split(" ")[0];
if(jwtUtil.isExpired(token)){ // 토큰 만료 검증
System.out.println("토큰 만료됨");
if (refreshToken == null) { // refresh 토큰이 없을 때
System.out.println("refresh token이 없음");
filterChain.doFilter(request,response);
return;
}
String reissuedAccessToken = refreshTokenService.reissueAccessToken(refreshToken);
if (reissuedAccessToken == null) { // client의 refresh token이 변조되었거나, 만료되었거나, 서버가 가지고있는 refreshtoken과 다르거나
System.out.println("refresh token이 null임");
filterChain.doFilter(request, response);
return;
}
token = reissuedAccessToken;
Cookie cookie = new Cookie("JwtToken", token);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
response.addCookie(cookie);
refreshToken = refreshTokenService.reissueRefreshToken(refreshToken); //RTR 적용
Cookie reissuedRefreshToken = new Cookie("RefreshToken", refreshToken);
reissuedRefreshToken.setHttpOnly(true);
reissuedRefreshToken.setSecure(true);
reissuedRefreshToken.setPath("/");
response.addCookie(reissuedRefreshToken);
}
//정상 토큰 확인
Long idx = jwtUtil.getIdx(token);
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
//임시적인 멤버 객체 생성
User user = User.builder()
.idx(idx)
.email(username)
.role(role)
.build();
// 직접 CustomDetails 객체로 변환
CustomUserDetails customUserDetails = new CustomUserDetails(user);
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails,null,customUserDetails.getAuthorities());
//ContextHolder 에 미리 심어줌으로서, LoginFilter가 로그인 된 사용자라고 판명
SecurityContextHolder.getContext().setAuthentication(authToken);
``` 중략 ```
filterChain.doFilter(request,response);
}
}
추가적으로, 만료된 Access Token을 Refresh Token으로 재발급할 때, Access Token 뿐만 아니라, Refresh Token도 재발급해주는 RTR(Refresh Token Rotation) 방식을 사용했다.
쉽게 말해, RTR 방식은 Refresh Token이 탈취당하였을 경우를 대비하여, Refresh Token을 최대한 짧게 유지하고, 재사용 불가능하게 만들어 보안을 보장하자는 것이다. 구체적으로는 Refresh Token을 사용할 때마다 매번 갱신해 기존의 토큰 값을 무효화하고, 새로운 값으로 DB에 업데이트한다.
Refresh Token를 이용한 인가 처리를 구현한 코드들은 다음과 같다.
JwtUtil.class
Access Token, Refresh Token 생성 및 검증
@Component
public class JwtUtil {
private SecretKey secretKey;
private CustomUserDetailService customUserDetailService;
private final Long REFRESH_EXPIRE = 1000L * 60 * 60 * 24 * 14; // 14일
public JwtUtil(@Value("${spring.jwt.secret}") String secretKey, UserRepository userRepository) {
this.secretKey = new SecretKeySpec(
secretKey.getBytes(StandardCharsets.UTF_8),
SIG.HS256.key().build().getAlgorithm()
);
this.customUserDetailService = new CustomUserDetailService(userRepository);
}
public String createToken(Long idx, String email, String role,String nickname) {
return Jwts.builder()
.claim("idx",idx)
.claim("email",email)
.claim("role",role)
.claim("nickname",nickname)
.issuedAt(new Date(System.currentTimeMillis())) //생성시간
.expiration(new Date(System.currentTimeMillis()+ 20 * 1000)) //만료시간
.signWith(secretKey) //제일 중요 -> 우리만 알 수 있는 secretKey
.compact();
}
public String createRefreshToken(Long idx, String email, String role,String nickname) {
return Jwts.builder()
.claim("idx",idx)
.claim("email",email)
.claim("role",role)
.claim("nickname",nickname)
.issuedAt(new Date(System.currentTimeMillis())) //생성시간
.expiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRE)) //만료시간
.signWith(secretKey) //제일 중요 -> 우리만 알 수 있는 secretKey
.compact();
}
public String createTokenAllowTranslate(Long idx,String nickname) {
return Jwts.builder()
.claim("idx",idx)
.claim("nickname",nickname)
.issuedAt(new Date(System.currentTimeMillis())) //생성시간
.expiration(new Date(System.currentTimeMillis()+ 200 * 60 * 1000)) //만료시간
.signWith(secretKey) //제일 중요 -> 우리만 알 수 있는 secretKey
.compact();
}
public Long getIdx(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("idx", Long.class);
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public String getNickname(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("nickname", String.class);
}
public Boolean isExpired(String token) {
try {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
public Boolean isValid(String token) {
try{
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().after(new Date());
} catch (ExpiredJwtException | SignatureException jwtException){ // 토큰 만료, 서명 검증 실패
return false;
}
}
public Authentication getAuthentication(String jwtToken) {
UserDetails userDetails = customUserDetailService.loadUserByUsername(getUsername(jwtToken));
return new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
}
}
RefreshToken.class
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long idx;
private String email;
private String refreshToken;
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
RefreshTokenService.class
@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtUtil jwtUtil;
public void save(String username, String refreshToken) {
RefreshToken existingRefreshToken = refreshTokenRepository.findByEmail(username).orElse(null);
RefreshToken refreshTokenEntity;
if (existingRefreshToken != null) {
refreshTokenEntity = RefreshToken.builder()
.idx(existingRefreshToken.getIdx())
.refreshToken(refreshToken)
.email(username)
.build();
} else {
refreshTokenEntity = RefreshToken.builder()
.email(username)
.refreshToken(refreshToken)
.build();
}
refreshTokenRepository.save(refreshTokenEntity);
}
@Transactional
public void delete(String refreshToken) {
String email = jwtUtil.getUsername(refreshToken);
if (email != null) {
refreshTokenRepository.deleteByEmail(email);
}
}
public String reissueAccessToken(String token) { // refresh 토큰으로 access token 재발급
if (jwtUtil.isExpired(token)) {
log.info("refresh token 만료됨");
return null;
}
String email = jwtUtil.getUsername(token);
RefreshToken refreshTokenEntity = refreshTokenRepository.findByEmail(email).orElse(null);
if (refreshTokenEntity != null) {
String refreshToken = refreshTokenEntity.getRefreshToken();
if (jwtUtil.isValid(refreshToken) && refreshToken.equals(refreshToken)){
System.out.println("refresh token으로 재발급");
return jwtUtil.createToken(jwtUtil.getIdx(refreshToken), jwtUtil.getUsername(refreshToken),
jwtUtil.getRole(refreshToken),
jwtUtil.getNickname(refreshToken));
}
}
return null;
}
public String reissueRefreshToken(String token) {
String reissuedRefreshToken = jwtUtil.createRefreshToken(jwtUtil.getIdx(token),jwtUtil.getUsername(token), jwtUtil.getRole(token),
jwtUtil.getNickname(token));
RefreshToken refreshToken = refreshTokenRepository.findByEmail(jwtUtil.getUsername(token)).get(); // 기존의 refresh 토큰
refreshToken.updateRefreshToken(reissuedRefreshToken); // refresh token update
refreshTokenRepository.save(refreshToken);
return reissuedRefreshToken;
}
}
RefreshTokenRepository.class
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByEmail(String email);
void deleteByEmail(String email);
}
SecurityConfig.class
로그아웃 시, DB에 저장된 Refresh Token 삭제
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2AuthenticaitonSuccessHandler oAuth2AuthenticationSuccessHandler;
private final OAuth2Service oAuth2Service;
private final JwtUtil jwtUtil;
private final AuthenticationConfiguration authenticationConfiguration;
private final RefreshTokenService refreshTokenService;
``` 중략 ```
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
``` 중략 ```
http.logout((auth) ->
auth
.logoutUrl("/logout") // 요청 url
.deleteCookies("JwtToken", "AToken", "RefreshToken") // 삭제 시킬 쿠키 이름
.logoutSuccessHandler((request,response,authentication) -> {
String refreshToken = null;
for (Cookie cookie : request.getCookies()) {
if (cookie.getName().equals("RefreshToken")) {
refreshToken = cookie.getValue();
break;
}
}
if (refreshToken != null) {
refreshTokenService.delete(refreshToken); // db에서 refresh token 삭제
}
response.sendRedirect("https://www.dopamines-bootup.kro.kr/"); // 로그아웃 성공시, 메인페이지로 리다이렉트
}));
``` 중략 ```
return http.build();
}
추후 개선 사항
현재는 RDB(MySQL)을 사용하여 Refresh Token을 저장하고 있는데, Access Token의 유효시간이 짧을 수록, DB 입출력으로 발생하는 오버헤드 및 부하가 커질것으로 예상된다. Refresh Token은 사용자가 로그아웃하기 전까지만 필요한 일시적인 데이터이므로, 추후 Redis와 같은 인메모리 DB에 Refresh Token을 저장하는 방식으로 구현하는 것을 고려중이다.
'Project' 카테고리의 다른 글
[Project] Redis를 이용하여 대기열 구현하기 (0) | 2024.10.28 |
---|---|
[Boot-up] 프로젝트 성능 개선 (0) | 2024.07.22 |