Spring Security - 블랙리스트 관리를 통한 Refresh Token 인증 관리 로직 ( OAuth-3 )

TopOfTheHead·2026년 6월 1일

Spring OAuth

목록 보기
11/12

이전 JWT 필터 작업

블랙리스트
블랙리스트 테이블로그아웃 할 사용자토큰입력함으로써 해당 토큰을 통한 로그인을 차단하는 방식

블랙리스트는 주로 RDBMS테이블 상에서 관리
Redis 등의 인메모리 DB에서 관리 시 휘발성이 매우 크기 때문에, 로그아웃토큰 정보를 잃은 후 해당 토큰으로 재로그인하는 보안 위험이 존재

블랙리스트 테이블의 경우 토큰 외에도 로그아웃 시점IP주소, IP주소를 통한 로그아웃 장소 위치를 기록가능

Refresh Token / RefreshTokenBlackList Entity 정의
RefreshTokenRefreshTokenBlackList는 서로 생명주기를 공유하므로, 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의 경우 만료토큰으로서 인증을 차단.

    RefreshToken1: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;
    }
}
  • RefreshTokenRefreshTokenBlackListRepository 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을 찾을 수 없습니다.")
        );
    }
}

RefreshTokenRefreshTokenBlackList를 묶는 Boundary ContextRepository Layer를 정의
RefreshTokenRefreshTokenBlackListAdapter로서 묶는 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 TokenRefresh Token Blacklist Table에 등록하는 메서드
 @Override
    public RefreshToken appendBlackList(RefreshToken refreshToken) {
        refreshTokenBlackListRepository.save(new RefreshTokenBlackList(refreshToken));
        return refreshToken;
    }
  • 사용자로부터 전달된 Refresh Token을 통해 Refresh Token Table에서 해당하는 Refresh Token을 찾아서 블랙리스트에 등록되었는지 확인 후 반환하는 메서드
    RefreshTokenBlackListRefresh 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 TableRefreshTokenBlackList TableJOIN블랙리스트 등록 여부필터링
// 사용자로부터 전달된 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 TokenCookie에 포함하여 전달 시 해당 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);
    }
}


CookieRefresh Token을 포함하고, Authorization HeaderAccess Token을 포함하여 /refresh로 전송 시 다음처럼 Access Token이 발급

profile
공부기록 블로그

0개의 댓글