하지만 이 Access 토큰을 사용하여 요청과 응답을 받는 과정에서 우리는 이 토큰을 탈취당할 수 있다.
토큰은 [헤더]+[페이로드]+[비밀키]로 암호화 되어있지만 성능 좋은 컴퓨터로 비밀키를 알아내면 우리는 모든 정보와 권한을 빼앗기에 된다.
이를 보완하고자 Refresh 토큰의 개념이 사용된다.
Access 토큰의 유효기간은 매우 짧게
Refresh 토큰의 유효기간은 길게
해주는 것이 포인트다.
사용자는 Access 토큰을 통해서만 자원에 접근이 가능하고 Refresh 토큰은 소용이 없다.
그럼 언제 Refresh 토큰을 사용하는가?
유효기간이 짧은 Access 토큰이 만료가 되면 Refresh 토큰을 확인하여 검증 후 Access 토큰을 재발급해준다.
즉,
Access 토큰을 통해서만 자원에 접근이 가능, But 유효기간이 매우 짧다(탈취를 당해도 이미 사용할 수 없는 상태)
Refresh 토큰은 유효기간이 길기에 탈취당할 수도 있지만 Refresh 토큰은 오직 Access 토큰을 재발급하는 용도(Refresh 토큰 자체로는 별 쓸모가 없다.)
2. 인증 과정
위 사진은 로그인으로 설명이 돼있는데 회원가입으로 설명하는 것이 흐름을 알아보기에 조금 더 좋을 것 같아서 조금 바꿔서 설명하겠다.
사용자 회원가입 정보 입력.
유효한 데이터가 들어왔다면 회원가입 처리(DB 등록)
사용자 정보와 권한이 들어가 있는 Access 토큰과 Refresh 토큰 발급 (이때 Refresh 토큰은 DB에 저장한다.)
클라이언트는 두 종류의 토큰을 받는다.
이후 사용자가 데이터를 요청할 때마다 Access 토큰을 동봉하여 보낸다.
서버는 사용자로부터 전달된 Access 토큰이 유효한지만 판단한다(어자피 사용자의 권한과 정보는 토큰에 자체적으로 있다.)
Access 토큰이 유효하면 사용자의 요청을 처리해서 반환해준다.
이때, 유효기간이 짧은 Access 토큰이 만료됐다고 해보자.
사용자는 만료된 Access 토큰으로 데이터 요청을 보낸다.
서버에서는 토큰에 대한 유효성 검사를 통해 만료된 토큰임을 확인한다.
클라이언트에게 "너의 토큰은 만료되었으니 갱신하기위해 Refresh 토큰을 보내라" 라고 응답한다.
클라이언트는 Access 토큰 재발급을 위해 Access 토큰과 Refresh 토큰을 전송한다.
전달받은 Refresh 토큰이 그 자체로 유효한지 확인하고, 3번에서 DB에 저장해 두었던 원본 Refresh 토큰과도 비교하여 같은지 확인한다.
유효한 Refresh 토큰이면 Access 토큰을 재발급 해준다.
만약 Refresh 토큰도 만료됐다면 로그인을 다시하고 Access 토큰과 Refresh 토큰을 새로 발급해준다.
더 간단한 흐름도로 확인하면 다음과 같다.
Refresh 토큰을 통해
Access 토큰의 유효기간을 짧게 잡을 수 있다.(탈취 방지)
Access 토큰의 유효기간에 짧음에도 불구하고 Refresh 토큰(유효기간 김)이 만료될때까지 추가적인 로그인을 안해도 된다. 마치 세션이 유지되는 것 처럼.
또 이러한 JWT 방식은
서버가 다수 존재하는 환경에서 유용하다. 세션을 사용하면 모든 서버에서 세션 내용을 공유해야 하기 때문
또한 매 요청시마다 DB 조회를 안하고 토큰 자체만으로 사용자의 정보와 권한을 알 수 있기에 병목현상을 방지한다.
3. 구현
구조
우리의 목표는 이 API에 접근하는 것
@PostMapping("/user/test")
public Map userResponseTest() {
Map<String, String> result = new HashMap<>();
result.put("result","success");
return result;
}
// JWT 토큰 생성
public String createAccessToken(String userId) {
Claims claims = Jwts.claims();// JWT payload 에 저장되는 정보단위
claims.put("userId", userId);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) // 만료시간 설정
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
클라이언트에서 이렇게 토큰을 받는다.
Access 토큰을 통해 데이터 요청
잘 받아졌다.
시간이 지나 Access 토큰이 만료되면
아무 응답도 받지 못함과 동시에 토큰이 만료됐음을 확인할 수 있다.
Refresh 토큰을 사용하여 Access 토큰 재발급
public TokenResponse issueAccessToken(HttpServletRequest request){
String accessToken = jwtTokenProvider.resolveAccessToken(request);
String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
//accessToken이 만료됐고 refreshToken이 맞으면 accessToken을 새로 발급
if(!jwtTokenProvider.isValidAccessToken(accessToken)){ //클라이언트에서 토큰 재발급 api로의 요청을 확정해주면 이 조건문은 필요 없을 것 같다.
System.out.println("Access 토큰 만료됨");
if(jwtTokenProvider.isValidRefreshToken(refreshToken)){ //들어온 Refresh 토큰이 자체적으로 유효한지
System.out.println("Refresh 토큰은 유효함");
Claims claimsToken = jwtTokenProvider.getClaimsToken(refreshToken);
String userId = (String)claimsToken.get("userId");
Optional<User> user = userRepository.findByUserId(userId);
String tokenFromDB = authRepository.findByUserId(user.get().getId()).get().getRefreshToken();
System.out.println("tokenFromDB = " + tokenFromDB);
if(refreshToken.equals(tokenFromDB)) { //DB의 원본 refresh토큰과 지금들어온 토큰이 같은지 확인
System.out.println("Access 토큰 재발급 완료");
accessToken = jwtTokenProvider.createAccessToken(userId);
}
else{
//DB의 Refresh토큰과 들어온 Refresh토큰이 다르면 중간에 변조된 것임
//예외발생
System.out.println("Refresh Token Tampered");
}
}
else{
//입력으로 들어온 Refresh 토큰이 유효하지 않음
}
}
return TokenResponse.builder()
.ACCESS_TOKEN(accessToken)
.REFRESH_TOKEN(refreshToken)
.build();
}
Refresh 토큰을 사용하여 Access 토큰을 재발급하는 메소드인데 뭔가 내용이 많아보이지만 사실상 간단하다.
Access 토큰이 만료됐다면
Refresh 토큰은 유효하다면
DB에 저장돼있는 Refresh 토큰 원본과 지금 들어온 Refresh 토큰이 일치한다면
Access 토큰을 재발급 해주는 흐름이다.
또, 이 글을 작성하면서 알게된 사실인데 Refresh 토큰의 페이로드에는 사용자 정보를 넣지 않는게 좋다고 한다. 생각해 보니 그런 것 같다. Refresh 토큰은 유효기간이 길기에 탈취될 수 있고(그 자체로는 뭘 할순 없지만) 탈취되어 내용을 까보면 치명적이진 않더라도 사용자의 정보가 노출되어 버리기 때문인 것 같다. 수정해야겠다.
4. Refresh 토큰에 대한 고찰
로그인이나 회원가입을 하고 Refresh 토큰을 클라이언트에게 반환하는 것을 보안상 문제로 인해 지양해야 한다는 주장이 있다. 카카오는 작년 업데이트를 통해 Javascript를 통한 카카오 로그인 기능에서 response 값에서 Refresh 토큰을 제외하기로 했다.
SW 개발 시 개발자들은 소스코드를 공유하게 된다. 이때, 여러 개발자들이 동시에 다양한 작업을 할 수 있게 만들어 주는 기능이 바로 브랜치(Branch) 다. 각자 독립적인 작업 영역(저장소) 안에서 마음대로 소스코드를 변경할 수 있다. 이렇게 분리된 작업 영역에서 변경된 내용은 나중에 원래의 버전과 비교해서 하나의 새로운 버전으로 만들어 낼 수 있다.
즉, 병렬적으로 작업할 수 있고 작업 후에 main으로 병합을 할 수 있는 서브 작업공간 같은 개념
2. Branch 국룰
변수명, 클래스명에서 암묵적인 규칙이 있듯 브랜치도 어느정도 정형화된 용도와 규칙이 있다. 처음 알았다.
Main (Main Branch)
Develop(Main Branch)
Feature/<Issue_number> or <Feature_name> / <Short Description>
Release/<version_number>
Hotfix/<Issue_number> orIssue/<Issue_number>
Main Branch
메인 브랜치는 배포할 수 있는 브랜치다.
즉 최종적인 상태를 의미하는 공간으로 최상위 브랜치다.
명명규칙: 보통 main 그대로 쓴다고 한다.
Develop Branch
다음 출시 버전을 개발하는 브랜치
main에서 분기되어 기능 개발을 위한 브랜치들의 병합을 위해 사용됨.
이 브랜치에서 기능을 병합하고 버그를 수정하여 배포 가능한 상태가 되면 main으로 병합한다.
명명규칙: 보통 develop 그대로 쓴다고 한다.
Feature Branch
기능을 개발하는 브랜치
새로운 기능이나 버그 수정이 필요할 때마다 develop 브랜치에서 분기된다.
여기서의 작업은 공유할 필요가 없기 때문에 주로 자신의 로컬 저장소에서 관리한다.
명명규칙: feature/기능요약 (ex. feature/login)
Release Branch
이번 출시 버전을 준비하는 브랜치
feature 브랜치에서 기능 개발 후 develop 브랜치에서 병합을 하는 과정을 반복하여,
최종적인 버그 수정이나 문서 추가 등 실질적으로 Release하기 직전에 하는 단계를 위한 브랜치
명명규칙: release/X.X.X 혹은 release-X.X.X
Hotfix Branch
출시 버전에서 발생한 버그를 수정하는 브랜치
갑작스럽게 수정해야하는 경우에 main에서 수정하지 않고 Hotfix 브랜치로 분기하고 수정 후 main으로 병합한다.
main에서 다시 배포 후에는 develop 브랜치에도 병합해준다.
명명규칙: hotfix-X.X.X
3. 모범 예시
4. 실습
1. main 브랜치에 최초 커밋을 한다.
어차피 브랜치가 중점이기에 그냥 txt파일로 연습한다.
2. Develop 브랜치 추가하기
현재는 main 브랜치 뿐이다.
develop 브랜치를 만들어주자.
git branch "브랜치이름"
develop 브랜치에서 로그인 기능을 만든다고 가정한 feature 브랜치도 만들어 준다.
git branch feature/login
feature/login 브랜치에 로그인 모듈을 만들어서 푸시 후 develop 브랜치로 merge 해보자
feature/login 브랜치로 일단 push 하고
develop 브랜치로 이동하여 feature/login을 merge했다.
두번의 과정에서 모두 첫 커밋이라 upstream을 설정하라고 에러가 발생했다.
push 뒤에 -set--upstream 옵션을 붙혀주면 된다.
새로 생성한 브랜치에서 작업이 끝나면
git branch -d feature/login
명령어로 브랜치를 제거해준다. 이때 merge되지 않은 정보들이 있다면 경고문이 뜨며 -D로 옵션을 바꿔서 진행하라고 알려준다.
5. 결과
브랜치 3개가 잘 만들어졌다.
develop 브랜치로 merge한 feature/login 브랜치의 내용도 잘 들어갔다.
어제 AWS 관련 인증 키들을 깃허브 리포지토리에 올려놓고 있었다는 사실을 알게 됐다. 깃허브에서 지우고 다시 커밋되지 않도록 .gitignore에 등록하였지만 리포지토리 히스토리상에는 이미 수 없이 남아있었다. 이를 해결하기위해 BFG Repo-Cleaner를 사용했다.