블랙리스트
。블랙리스트 테이블에로그아웃 할 사용자의토큰을입력함으로써 해당토큰을 통한로그인을 차단하는 방식
。블랙리스트는 주로RDBMS의테이블상에서 관리
▶Redis등의인메모리 DB에서 관리 시휘발성이 매우 크기 때문에,로그아웃된토큰 정보를 잃은 후 해당토큰으로재로그인하는보안 위험이 존재
。블랙리스트 테이블의 경우토큰외에도로그아웃 시점의IP주소,IP주소를 통한로그아웃 장소 위치를 기록가능
Refresh Token/RefreshTokenBlackListEntity정의
。RefreshToken과RefreshTokenBlackList는 서로생명주기를 공유하므로,Adapter를 통해 같은Boundary Context로 묶는다.
Refresh Token Entity생성
。사용자가로그인시 발급되는 모든Refresh Token을 저장하는Entity
。한사용자는로그인 / 로그아웃을 반복 시 여러Refresh Token을 가질 수 있으므로1:N 관계를 형성@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RefreshToken { @Id @GeneratedValue private Long id; private String refreshToken; @Setter @ManyToOne @JoinColumn(name = "member_id") private Member member; @Builder public RefreshToken( String refreshToken, Member member ){ this.refreshToken = refreshToken; this.member = member; } private LocalDateTime createdAt = LocalDateTime.now(); private LocalDateTime updatedAt = LocalDateTime.now(); }
RefreshTokenBlackList Entity정의
。Refresh Token의만료를 관리하는Entity
▶ 해당Entity에 등록된RefreshToken의 경우만료된토큰으로서인증을 차단.
。RefreshToken과1:1 관계를 가짐.@Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RefreshTokenBlackList { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne @JoinColumn(name = "refresh_token_id") private RefreshToken refreshToken; private LocalDateTime createdAt = LocalDateTime.now(); private LocalDateTime updatedAt = LocalDateTime.now(); public RefreshTokenBlackList(RefreshToken refreshToken) { this.refreshToken = refreshToken; } }
RefreshToken과RefreshTokenBlackList의Repository Layer정의@Repository public interface RefreshTokenBlackListRepository extends JpaRepository<RefreshTokenBlackList, Long> { boolean existsByRefreshToken(RefreshToken refreshToken); }。
RefreshTokenBlackList에 해당Refresh Token이 존재하는지 판단하는쿼리 메서드정의
▶ 존재하는 경우로그아웃된Refresh Token@Repository public interface RefreshTokenRepository extends JpaRepository<RefreshToken,Long> { Optional<RefreshToken> findByRefreshToken(String refreshToken); default RefreshToken findByRefreshTokenOrThrow(String refreshToken){ return findByRefreshToken(refreshToken).orElseThrow( ()-> new RuntimeException("해당 RefreshToken을 찾을 수 없습니다.") ); } }
RefreshToken과RefreshTokenBlackList를 묶는Boundary Context의Repository Layer를 정의
。RefreshToken와RefreshTokenBlackList를Adapter로서 묶는Repository정의
▶ 두도메인은 동일한생명주기를 공유하므로.
확장성을 위해Repository 인터페이스정의public interface TokenRepository { RefreshToken save(Member member, String token); Optional<RefreshToken> findValidRefreshTokenByTokenStr(String str); default RefreshToken findValidRefreshTokenByTokenStrOrThrow(String str){ return findValidRefreshTokenByTokenStr(str).orElseThrow( ()->new RuntimeException("유효한 토큰을 찾을 수 없습니다.") ); } Optional<RefreshToken> findValidRefreshTokenByMemberId(Long memberId); default RefreshToken findValidRefreshTokenByMemberIdOrThrow(Long memberId){ return findValidRefreshTokenByMemberId(memberId).orElseThrow( ()->new RuntimeException("유효한 토큰을 찾을 수 없습니다.") ); } RefreshToken appendBlackList(RefreshToken refreshToken); }
Refresh Token Table에 생성된Refresh Token을 저장하는메서드@Override public RefreshToken save(Member member, String token) { return refreshTokenRepository.save( RefreshToken.builder() .refreshToken(token) .member(member) .build() ); }
로그아웃시 전달된Refresh Token을Refresh Token Blacklist Table에 등록하는메서드@Override public RefreshToken appendBlackList(RefreshToken refreshToken) { refreshTokenBlackListRepository.save(new RefreshTokenBlackList(refreshToken)); return refreshToken; }
사용자로부터 전달된Refresh Token을 통해Refresh Token Table에서 해당하는Refresh Token을 찾아서블랙리스트에 등록되었는지 확인 후 반환하는메서드
。RefreshTokenBlackList에Refresh Token이 존재하는지 확인하여블랙리스트에 등록되었는지 확인// 사용자로부터 전달된 Refresh Token을 통해 블랙리스트에 등록되지 않은 Refresh Token을 Refresh Token Table에서 찾아서 반환하는 메서드 @Override public Optional<RefreshToken> findValidRefreshTokenByTokenStr(String str) { // Access Token을 통해 Refresh Token Table에 저장된 Refresh Token을 가져옴 RefreshToken foundedRefreshToken = refreshTokenRepository.findByRefreshToken(str) .orElseThrow( ()->new RuntimeException("서버에서 저장되지 않은 토큰입니다.") ); // 블랙리스트로 등록된 Refresh Token의 경우 비어있는 값으로 반환. return ( isBannedRefreshToken(foundedRefreshToken) )? Optional.empty() : Optional.of(foundedRefreshToken); }
사용자로부터 전달된Member ID를 통해Refresh Token Table에서 해당하는Refresh Token을 찾아서블랙리스트에 등록되었는지 확인 후 반환하는메서드
。JPQL을 활용하여Refresh Token Table과RefreshTokenBlackList Table을JOIN후블랙리스트 등록 여부로필터링// 사용자로부터 전달된 Member ID를 통해 블랙리스트에 등록되지 않은 Refresh Token을 Refresh Token Table에서 찾아서 반환하는 메서드 @Override public Optional<RefreshToken> findValidRefreshTokenByMemberId(Long memberId) { // memberID를 통해 찾은 Refresh Token의 Join된 Refresh Token Table의 데이터가 Null이어야한다. return em.createQuery( """ SELECT rt FROM RefreshToken rt LEFT JOIN RefreshTokenBlackList rtb ON rtb.refreshToken = rt WHERE rt.member.id = :memberId AND rtb.id is null """, RefreshToken.class ).setParameter("memberId",memberId) .getResultStream() .findFirst(); }
- 최종본
@Repository @RequiredArgsConstructor public class RefreshTokenRepositoryAdapter implements TokenRepository { private final RefreshTokenRepository refreshTokenRepository; private final RefreshTokenBlackListRepository refreshTokenBlackListRepository; private final EntityManager em; // 생성된 Refresh Token을 저장하는 메서드 @Override public RefreshToken save(Member member, String token) { return refreshTokenRepository.save( RefreshToken.builder() .refreshToken(token) .member(member) .build() ); } // 사용자로부터 전달된 Access Token을 통해 블랙리스트에 등록되지 않은 Refresh Token을 Refresh Token Table에서 찾아서 반환하는 메서드 @Override public Optional<RefreshToken> findValidRefreshTokenByTokenStr(String str) { // Access Token을 통해 Refresh Token Table에 저장된 Refresh Token을 가져옴 RefreshToken foundedRefreshToken = refreshTokenRepository.findByRefreshToken(str) .orElseThrow( ()->new RuntimeException("서버에서 저장되지 않은 토큰입니다.") ); // 블랙리스트로 등록된 Refresh Token의 경우 비어있는 값으로 반환. return ( isBannedRefreshToken(foundedRefreshToken) )? Optional.empty() : Optional.of(foundedRefreshToken); } private boolean isBannedRefreshToken(RefreshToken refreshToken){ return refreshTokenBlackListRepository.existsByRefreshToken(refreshToken); } // 사용자로부터 전달된 Member ID를 통해 블랙리스트에 등록되지 않은 Refresh Token을 Refresh Token Table에서 찾아서 반환하는 메서드 @Override public Optional<RefreshToken> findValidRefreshTokenByMemberId(Long memberId) { // memberID를 통해 찾은 Refresh Token의 Join된 Refresh Token Table의 데이터가 Null이어야한다. return em.createQuery( """ SELECT rt FROM RefreshToken rt LEFT JOIN RefreshTokenBlackList rtb ON rtb.refreshToken = rt WHERE rt.member.id = :memberId AND rtb.id is null """, RefreshToken.class ).setParameter("memberId",memberId) .getResultStream() .findFirst(); } // `로그아웃` 시 전달된 `Refresh Token`을 `Refresh Token Blacklist Table`에 등록하는 `메서드` @Override public RefreshToken appendBlackList(RefreshToken refreshToken) { refreshTokenBlackListRepository.save(new RefreshTokenBlackList(refreshToken)); return refreshToken; } }
Service Layer에서Refresh Token 발급시Refresh Token Table에 추가하는메서드로 수정하기public String issueRefreshToken( Long id ){ Member foundedMember = memberRepository.findByIdOrThrow(id); String refreshToken = issue(id, Role.NONE, jwtProperties.getValidations().getRefresh()); tokenRepository.save(foundedMember, refreshToken); return refreshToken; }
컨트롤러 구현 및 테스트
Refresh Token 재발급
。Access Token 만료이후Refresh Token을Cookie에 포함하여 전달 시 해당Refresh Token이블랙리스트에 등록되지 않은 경우Access Token을재발급후 반환@RestController @RequiredArgsConstructor public class AuthController { private final TokenRepository tokenRepository; private final TokenProvider tokenProvider; private final String REFRESH_TOKEN_NAME = "RT"; @GetMapping("/refresh") public ResponseEntity<String> refreshToken(HttpServletRequest request){ Cookie[] cookies = request.getCookies(); if(cookies == null) throw new IllegalArgumentException("Cookie가 존재하지 않습니다."); String bearerToken = null; for(Cookie cookie : cookies){ if (cookie.getName().equals(REFRESH_TOKEN_NAME)) bearerToken = cookie.getValue(); } // if(bearerToken == null) throw new IllegalArgumentException("Refresh Token이 포함되지 않았습니다."); // if(!tokenProvider.validate(bearerToken)) throw new IllegalArgumentException("해당 서버에서 유효하지 않은 Refresh Token 입니다."); // Access Token을 통해 서버에 저장된 블랙리스트에 등록되지 않은 Refresh Token을 찾음 RefreshToken foundedRefreshToken = tokenRepository.findValidRefreshTokenByTokenStr(bearerToken) .orElseThrow( () -> new RuntimeException("해당 Refresh Token과 연관된 Refresh Token을 찾을 수 없습니다.") ); // Access Token을 새로 생성 및 반환 String accessToken = tokenProvider.issueAccessToken( foundedRefreshToken.getMember().getId(), foundedRefreshToken.getMember().getRole() ); // return ResponseEntity.ok().body(accessToken); } }
▶Cookie에Refresh Token을 포함하고,Authorization Header에Access Token을 포함하여/refresh로 전송 시 다음처럼Access Token이 발급