Redis - refreshToken 저장하기

조대훈·2024년 9월 20일
post-thumbnail

AccessToken 을 단독으로 사용할 경우

장점

  • 단 하나의 토큰만 사용하기 때문에 구현과 관리가 간편하다.
  • 서버 리소스를 적게 사용하기에 부하가 적다
  • 서버에서 별도의 세션 정보를 유지할 필요가 없다
    단점
  • 긴 유효기간을 가진 토큰을 발급시 탈취되어 악용될 가능성이 높다
  • 짧은 유효기간을 가진 토큰을 발급시 잦은 로그인을 해야한다.
  • 긴급 상황시 토큰을 폐기할 수 없다.

AccessToken 과 RefreshToken 을 병용할 경우

장점

  • accessToken 유효기간을 짧게 설정 하여 토큰 탈취 위험을 줄일 수 있다.
  • refreshToken 을 길게 설정해 accessToken 을 발급 받아 잦은 로그인 이 필요가 없다
  • refreshToken 을 길게 설정해 장시간 사용자 인증 상태를 유지할 수 있다.
  • 긴급상황시 refreshToken을 폐기할 수 있다.

refreshToken 을 사용해야 하는 이유

  1. 보안 강화. 토큰 탈취시 피해 최소화, refreshToken 서버 측 관리.
  2. 사용자 경험 개선. 잦은 로그인을 할 필요가 없다.
  3. 세션 관리 용이성. 특정 사용자의 refreshToken 을 무효화해 즉시 로그아웃 처리가 가능하다.
  4. 다중 기기 지원.
  5. 토큰 갱신 프로세스 최적화.
  6. 규정 준수. 일부 세션은 짧은 지속 기간을 규정 준수를 해야한다.

AccessToken RefreshToken 을 모두 쿠키에 저장할 경우

간단하게 구현하기 위해 두 토큰을 모두 Cookie 에 저장 했었는데 이에 따른 보안에 끼칠 수 있는 영향을 자세히 알아보자.

  1. XSS 공격에 취약하다.
  2. CSRF 공격의 위험이 있다.
  3. 쿠키 탈취 시 두 토큰 모두 노출될 수 있다.

XSS가 사용자 -> 특정 사이트를 신뢰하기 때문에 발생하는 문제라면,
CSRF 는 특정 사이트 -> 사용자를 신뢰하기 때문에 발생하는 문제이다.

CSRF 크로스 사이트 리퀘스트 포지

다른 웹사이트에 로그인한 사용자의 권한을 이용 하여 웹사이트에 악성 요청을 보내는 행위.
사용자가 자신의 의지와는 무관하게 침입자가 의도한 행위를 서버에 요청하게 만드는 공격.
1. 사용자 A 악성 웹사이트 방문
2. 악성 스크립트 실행
3. 은행 웹사이트로 부터 받은 사용자 A 세션,쿠키를 이용 원치 않는 요청 발생
4. 은행 웹사이트에서는 유효한 요청으로 인식

사용자가 사이트에 로그인한 상태여야하며, 조작된 페이지에 접속 해야한다

XSS 크로스 사이트 스크립트

웹사이트에 의도치 않은 스크립트를 넣어서 실행 시키는 기법
웹 애플리케이션이 사용자로부터 입력 받은 값을 제대로 검사하지 않고 사용할 경우 발생.
1. 침입자가 사이트의 취약점 발견
2. 취약점을 찾아 세션 쿠키를 탈취하는 스크립트를 사이트에 삽입
3. 사용자가 웹사이트 접근시 스크립트 작동
4. 작동된 스크립트로 세션 쿠키 탈취

CSRF 와 XSS 비교

XSSCSRF
방법악성 스크립트가 클라이언트에서 실행권한을 도용당한 클라이언트가 가짜 요청을 서버에 전송
원인사용자가 특정 사이트를 신뢰특정 사이트가 사용자를 신뢰
공격대상클라이언트서버
목적쿠키, 세션 갈취, 웹사이트 변조권한 도용

RefreshToken 을 Redis 에 저장시

장점
1. 서버 측의 세션 관리 용이
2. Redis 의 빠른 읽기/쓰기 속도로 인한 부가적인 성능 향상
보안적 측면 장점
1. 세션 스토리지에 저장하는 것은 XSS 공격의 취약성을 갖고 있다.
2. 쿠키는 CSRF 공격에 취약하다.
3. 토큰 탈취로 부터 보호된다. 하나의 토큰 (accessToken) 이 탈취 되어도 refreshToken은 Redis에서 관리 하므로 다른 토큰은 안전하다.

  1. 로그인 성공 시 accessToken 과 refrhesToken 생성
  2. refreshToken 은 Redis 에 저장, accessToken 은 클라이언트에 전송
  3. 토큰 재발급 요청시, 클라이언트가 보낸 accessToken의 유효성 검사
  4. Redis 에 저장된 refreshToken 을 검증 후, 유효시 새로운 accessToken 발급
  5. refreshToken 의 만료 시간이 12시간 이내라면 새로운 refreshToken 도 생성 하여 redis 에 저장
  6. 새로 생성된 토큰을 클라이언트에 반환

코드 부분

1.로그인 성공시 accessToken 과 refreshToken 생성
APILoginSuccessHandler

public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authenticion authenitcaion) throws IOExeption, ServletExeption{

UserDTO userDTo = (UserDTO)authentication.getPrincipal();
Map<String, Object> claims = userDTO.getClaim();

String accessToken = jwtUtil.generateAccessToekn(claims,10);
String refresthToken = jwtUtil.generateRefreshToken(claims,60*24);

... (후략)
}

JWTUtil

public String generateAccessToken(Map<String,Object>claims,int min){
	return generateToken(claims,min);
}

public String generateRefreshToken(Map<String,Object>claims,int min){
	String refreshToken = generateToken(claims,min);
	String email= claims.get("email").toString();
	refreshTokenRepository.saveRefreshToken(email,refreshToken,min*60*1000L);
	return refreshToken;
}

2.refreshToken 을 Redis 에 저장, accessToken 만 클라이언트에 전송
APIRefreshController


( ... 위 후략에 이어서 )
String email = claims.get("email").toString();

// refresh 토큰은 Redis 에 저장 
refreshTokenRepository.saveRefreshToken(email,refreshToken,60*24*60*1000);

claims.put("accessToken",accessToken);

// 응답 생성 및 전송 - userDTO 에서 추출한 유저 정보와 accessToken 을 동봉
Gson gson = new Gson();
String jsonStr = gson.toJson(claims);
response.setContentType("application.json;charset=UTF-8");

PrintWriter printWriter = response.getWriter();
printWriter.println(jsonStr);
printWriter.close();

3. 토큰 재발급 요청시, 클라이언트가 보낸 accessToken 의 유효성 검사
APIRefreshController

@RequestMapping("/api/member/refresh") 
//accessToken 만료시 클라이언트에서 위 경로로 요청 
public Map<String,Object> refresh(@RequestHeader("Authorization") String authHeader) throws CustomJWTException {

if(authHeader == null || authHeader.length() < 7 ){
	throw new CustomJWTExeption("INVALID_STRING");
}
String accessToken = authHeader.subString(7);
Map<String,Object> claims;
try{
	claims=jwtUtil.validToken(accessToken); 
}catch(CustomJWTException e){
	if(!"Expired".equals(e.getMessage())){
	throw e;
	}
	claims = jwtUtil.getClaims(accessToken);
}

//...
}

4. Redis 에 저장된 refreshToken 검증 및 새로운 accessToken 발급
APIRefreshController

String email= claims.get("email").toString();
String storeRefreshToken = refreshTokenRepository.getRefreshToken(email);

if(storedRefreshToken == null){
	 throw new CustomJWTExeption("INVALID_REFRESH_TOKEN");
}

	try{
		jwtUtil.validToken(stroedRefreshToken);
		
	}catch(CustoJWTException e){
		throw new CustoJWTException("Invalid refresh token");
	}

String newAccessToken = jwtUtil.generateAccessToken(claims,10);

5. refreshToken 의 만료 시간이 12시간 이내일 시 새로운 refreshToken 생성 및 Redis 에 저장
APIRefreshController

String newRefreshToken = storeRefreshToken;
	if(sholudRefreshToken(sotredRefreshToken)){
		newRefreshToken= jwtUtil.generateRefrshToken(claims, 60*24);
		refrshTokenRepository.saveRefreshToken(email,newRefreshToken,60*24*60*1000);
	}
private boolean shouldRefreshToken(String token){

try{
	Map<String,Object> claims = jwtUtil.validToken(token);
	Number expNumber = (Number)claims.get("exp");
	long exp = expNumber.longValue();
	long currentTimeInSeconeds =System.currentTimeMills()/1000; 

	return (exp-currentTimeInSeconeds) < (12*60*60);
	
}catch (CustomJWTExeption e){
	return true;
	
}
}

6. 새로 생성된 토큰들을 클라이언트에게 반환
APIRefreshController

...
return Map.of("accessToken", newAccessToken, "refreshToken", newRefreshToken);

Redis 저장 관련 코드

@Repository  
@RequiredArgsConstructor  
public class RefreshTokenRepository {  
  
  
    private final StringRedisTemplate stringRedisTemplate;  
  
    public void saveRefreshToken(String email, String refreshToken, long expirationTime) {  
        stringRedisTemplate.opsForValue().set(  
                "refresh_token:"+email,  
                refreshToken,  
                expirationTime,  
                TimeUnit.MILLISECONDS  
        );  
    }  
  
  
    public String getRefreshToken(String email) {  
        return stringRedisTemplate.opsForValue().get("refresh_token:" + email);  
    }  
  
    public void deleteRefreshToken(String email) {  
        stringRedisTemplate.delete("refresh_token:" + email);  
    }  
  
}

이전의 쿠키 밸류

모든 토큰이 쿠키에 담겨져 있는 모습.

리팩토링 이후의 쿠키 밸류
AccessToken 만 담고 있는 모습.

레디스에 저장한 refreshToken

저장된 refreshToken 의 값

토큰 재발급 성공시

클라이언트 측 코드
JWTUtil

const refreshJWT = async (accessToken) => {  
    const header = {headers: {"Authorization": `Bearer ${accessToken}`}}  
    const res = await axiosInstance.get(`/member/refresh`, header)  
    return res.data  
}

...

const beforeRes = async (res) => {  
    console.log("before return response...........")  
  
    //console.log(res)  
  
    //'ERROR_ACCESS_TOKEN'    const data = res.data  
  
    if (data && data.error === 'ERROR_ACCESS_TOKEN') {  
  
        const memberCookieValue = getCookie("member")  
  
        const result = await refreshJWT(memberCookieValue.accessToken)  
        console.log("refreshJWT RESULT", result)  
  
        memberCookieValue.accessToken = result.accessToken  
        // memberCookieValue.refreshToken = result.refreshToken  
  
        setCookie("member", JSON.stringify(memberCookieValue), 1)  
  
        //원래의 호출  
        const originalRequest = res.config  
  
        originalRequest.headers.Authorization = `Bearer ${result.accessToken}`  
  
        return axiosInstance(originalRequest)  
  
    }  
    return res  
}

...

서버 측 컨트롤러 코드

@RestController  
@Log4j2  
@RequiredArgsConstructor  
public class APIRefreshController {  
  
    private final JWTUtil jwtUtil;  
    private final RefreshTokenRepository refreshTokenRepository;  
  
    @RequestMapping("/api/member/refresh")  
    public Map<String, Object> refresh(@RequestHeader("Authorization") String authHeader ) throws CustomJWTException {  
  
  
        log.info("Refresh Token Request Received");  
  
  
        if (authHeader == null || authHeader.length() <7 ) {  
            throw new CustomJWTException("INVALID_STRING");  
        }  
  
        String accessToken = authHeader.substring(7);  
  
  
        Map<String,Object> claims;  
  
        try {  
           claims  = jwtUtil.validToken(accessToken);  
        } catch (CustomJWTException e) {  
            if(!"Expired".equals(e.getMessage())) {  
                throw e;  
  
            }  
            claims = jwtUtil.getClaims(accessToken);  
        }  
  
        String email = claims.get("email").toString();  
  
        // Redis 에서 Refresh Token 가져오기  
        String storeRefreshToken = refreshTokenRepository.getRefreshToken(email);  
  
        if (storeRefreshToken == null) {  
            throw new CustomJWTException("INVALID_REFRESH_TOKEN");  
        }  
  
        try {  
            jwtUtil.validToken(storeRefreshToken);  
        } catch (CustomJWTException e) {  
            refreshTokenRepository.deleteRefreshToken(email);  
            throw new CustomJWTException("Invalid refresh token");  
        }  
  
        String newAccessToken = jwtUtil.generateAccessToken(claims, 10);  
  
  
        String newRefreshToken = storeRefreshToken;  
        if (shouldRefreshToken(storeRefreshToken)) {  
            newRefreshToken = jwtUtil.generateRefreshToken(claims, 60 * 24);  
            refreshTokenRepository.saveRefreshToken(email, newRefreshToken,60*24*60*1000);  
            log.info("New refresh token generated");  
  
  
        }  
  
        log.info("Token refreshed SuccessFully");  
  
            return Map.of("accessToken", newAccessToken, "refreshToken", newRefreshToken);  
        }  

... 
  
}

총평
다음 리팩토링은 서버 측에서 직접 쿠키밸류에 접근 하여 accessToken 을 가져오는 방식이 필요해보인다. 굳이 클라이언트에서 accessToken 을 추출해서 담아 보내지 않게되면 좀 더 간결해지고, 토큰을 전적으로 서버에서 담당해서 통일성이 생길 뿐더러 보안성도 올라갈 것이다.

참고
https://velog.io/@haizel/XSS%EC%99%80CSRF-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B0%8F-%EB%8C%80%EC%9D%91-%EB%B0%A9%EC%95%88

profile
백엔드 개발자를 꿈꾸고 있습니다.

0개의 댓글