지난 글에 이어 이번 글에서는 권한 인가를 담당하는 JwtAuthorizationFilter를 구현하고, JWT와 관련해 Redis를 사용하는 방법에 대해 알아보겠습니다.
JWT 방식에서 인가란, 로그인을 포함한 인증 이후의 모든 요청에 대해 수행되는 과정입니다. 사용자가 서버 리소스에 대해 접근할 때 사용자 브라우저의 쿠키 내 AccessToken/RefreshToken 유무로 접근에 대한 허용과 차단을 결정하죠. 저는 AccessToekn에 DB 사용자 테이블의 pk 컬럼 값과 role 컬럼 값 두 가지를 claim으로 포함시켰는데요, 여기엔 최소한의 정보가 들어가는 것이 권장됩니다. 그리고 RefreshToken은 Redis에 키값 쌍으로 저장하는데, key에는 임의의 난수를 넣고 value에는 pk 컬럼 값을 저장했습니다.
우선 지난 글에서 봤던 흐름도를 다시 한 번 볼텐데요, 첫 번째 AccessToken이 만료되지 않아 사용자 브라우저의 쿠키에 있을 경우를 보겠습니다.
그림에서 보면 알 수 있듯 첫 번째의 경우는 간단합니다. 간단히 말하자면, AccessToken 내부에 담긴 사용자Id 값을 꺼내 DB와 비교해 검증하는 방식입니다. 코드로 보겠습니다. 가장 먼저 쿠키에 데이터가 있는지 확인한 후, 있을 경우에만 로직을 진행합니다. accessToken이라는 이름의 쿠키가 있는지 확인 후 해당 쿠키의 값을 가져옵니다. 그런 다음 꺼낸 id 값으로 DB를 조회해 Member -> UserDetails -> Authentication 순으로 객체에 담아 시큐리티 컨텍스트에 저장합니다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final JwtService jwtService;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtService jwtProcess) {
super(authenticationManager);
this.jwtService = jwtProcess;
}
// 권한 확인을 위해 Authentication 객체를 시큐리티 컨텍스트에 저장한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 쿠키에 무언가 있을 때만 동작한다.
if (isCookieIncluded(request)) {
// 쿠키에서 토큰을 꺼내 파싱한다.
String replacedAccessToken = getAccessToken(request);
// 액세스 토큰을 검증하고 CustomUserDetails를 반환한다.
// 액세스 토큰이 만료되면 리프레시 토큰으로 재발급한다.
CustomUserDetails userDetails = null;
try {
userDetails = jwtService.verifyAccessToken(replacedAccessToken);
} catch (TokenExpiredException | JWTDecodeException e) {
e.printStackTrace();
String refreshToken = getRefreshToken(request);
userDetails = jwtService.verifyRefreshToken(response, refreshToken);
}
// 토큰에서 반환한 CustomUserDetails로 Authentication 객체를 생성하고 시큐리티 컨텍스트에 저장한다.
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// 쿠키 존재 여부를 확인한다.
private boolean isCookieIncluded(HttpServletRequest request) {
return request.getCookies() != null;
}
// 쿠키에서 액세스 토큰을 꺼내 파싱한다.
private String getAccessToken(HttpServletRequest request) {
String accessToken = "";
Cookie[] header = request.getCookies();
for (Cookie cookie : header) {
if ("accessToken".equals(cookie.getName())) {
accessToken = cookie.getValue();
break;
}
}
return accessToken;
}
}
@Service
@RequiredArgsConstructor
public class JwtService {
private final RefreshTokenRepository refreshTokenRepository;
private final MemberRepository memberRepository;
// 액세스 토큰을 검증해 CustomUserDetails 객체를 반환한다.
public CustomUserDetails verifyAccessToken(String token) {
// 액세스 토큰에서 mid를 꺼낸다.
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256(JwtConstant.SECRET)).build().verify(token);
Integer mid = decodedJWT.getClaim("mid").asInt();
// 꺼낸 mid로 DB를 조회해 Member 객체를 생성한다.
Optional<Member> findMember = memberRepository.findById(mid);
Member member = new Member(findMember);
// DB에서 찾은 멤버 객체를 반환한다.
return new CustomUserDetails(member);
}
}
두 번째 경우는 AccessToken이 만료돼 RefreshToken을 꺼낼 경우입니다.
이 경우 사용자 브라우저 쿠키의 RefreshToekn을 가져와 Redis를 조회합니다. 조회 성공 시 값을 꺼내 RDBMS를 조회해 검증합니다. 이후 로그인(인증)과 같이 Access/RefreshToken을 각각 새로 생성해주고 브라우저에 저장해주면 됩니다. 코드로 보겠습니다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
// 권한 확인을 위해 Authentication 객체를 시큐리티 컨텍스트에 저장한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 쿠키에 무언가 있을 때만 동작한다.
if (isCookieIncluded(request)) {
// 쿠키에서 토큰을 꺼내 파싱한다.
String replacedAccessToken = getAccessToken(request);
// 액세스 토큰을 검증하고 CustomUserDetails를 반환한다.
// 액세스 토큰이 만료되면 리프레시 토큰으로 재발급한다.
CustomUserDetails userDetails = null;
try {
userDetails = jwtService.verifyAccessToken(replacedAccessToken);
} catch (TokenExpiredException | JWTDecodeException e) {
e.printStackTrace();
String refreshToken = getRefreshToken(request);
userDetails = jwtService.verifyRefreshToken(response, refreshToken);
}
// 토큰에서 반환한 CustomUserDetails로 Authentication 객체를 생성하고 시큐리티 컨텍스트에 저장한다.
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// 쿠키에서 리프레시 토큰을 꺼내 파싱한다.
private String getRefreshToken(HttpServletRequest request) {
String refreshToken = "";
Cookie[] header = request.getCookies();
for (Cookie cookie : header) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
break;
}
}
return refreshToken;
}
}
@Service
@RequiredArgsConstructor
public class JwtService {
private final RefreshTokenRepository refreshTokenRepository;
private final MemberRepository memberRepository;
// 쿠키의 리프레시 토큰과 Redis의 리프레시 토큰을 비교 후 재발급한다.
public CustomUserDetails verifyRefreshToken(HttpServletResponse response, String refreshToken) throws IOException {
// Redis에서 리프레시 토큰을 조회한다.
Optional<RefreshToken> findRefreshToken = refreshTokenRepository.findRefreshTokenById(refreshToken);
// 리프레시 토큰이 있을 시
if (findRefreshToken.isPresent()) {
// value인 mid로 Member 객체를 생성한다.
RefreshToken unWrapedRefreshToken = findRefreshToken.get();
Object mid = unWrapedRefreshToken.getMid();
Optional<Member> findMember = memberRepository.findById(((int) mid));
// 기존 리프레시 토큰을 삭제한다.
refreshTokenRepository.deleteById(refreshToken);
// 액세스 토큰와 리프레시 토큰을 재발급한다.
String newAccessToken = generateAccessToken(findMember);
String newRefreshToken = generateRefreshToken(findMember);
// 토큰들을 쿠키에 넣어준다.
CookieUtil.addCookie(response, "accessToken", newAccessToken, JwtConstant.ACCESS_TOKEN_MAX_AGE, true, true);
CookieUtil.addCookie(response, "refreshToken", newRefreshToken, JwtConstant.REFRESH_TOKEN_MAX_AGE, true, true);
return new CustomUserDetails(findMember.get());
}
else {
return null;
}
}
}
인가는 이런 코드로 구성되어 있습니다. 이번에 JWT를 구현하면서 RefreshToken과 관련해 Redis를 사용해봤는데요, Redis는 key:value 쌍의 in-memory DB로 즉, 모든 데이터를 메모리에 저장하고 조회합니다. 다른 in-memory 혹은 NoSql들과는 다르게 value에 다양한 자료구조를 지원한다는 장점이 있습니다.
먼저 자바에서 Redis를 사용하기 위해선 의존관계 주입이 필요합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
그리고 yml 파일에 DB 정보도 기입해주어야 합니다. 기본적으로 6379 포트를 사용합니다.
spring:
redis:
host: localhost
port: 6379
password:
Redis를 사용하는 방식은 RedisTemplate를 이용하는 방법과 RedisReporitory를 이용하는 두 방법이 있습니다. RedisReposiroty는 Redis를 사용할 도메인에 어노테이션을 붙이고 JPA와 비슷한 방식으로 사용합니다. 하지만 저는 이번 프로젝트에는 RedisTemplate 방식을 사용했기 때문에 이것에 대해서만 다루도록 하겠습니다.
먼저 Redis관련 설정을 할 클래스를 만듭니다. @EnableRedisRepositories 어노테이션이 있고, url과 port를 생성자에서 주입합니다. 그리고 RedisTemplate를 빈으로 등록하는데, 각각 key와 value를 직렬화하기 위한 설정을 해줍니다.
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final String redisHost;
private final int redisPort;
public RedisConfig(@Value("${spring.redis.host}") final String redisHost,
@Value("${spring.redis.port}") final int redisPort) {
this.redisHost = redisHost;
this.redisPort = redisPort;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<String, Integer> redisTemplate() {
RedisTemplate<String, Integer> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
이러면 기본적인 사용 설정은 끝이 났고, 이제 이 RedisTemplate를 사용해 로직을 작성하면 됩니다. 저는 Redis에 RefreshToken을 저장하고, 조회하고, 삭제하는 3개의 로직만 구현했습니다. 코드로 보겠습니다. 특이한 점은 save()의 마지막에 보면 expire() API를 사용하는데, 이는 Redis의 TTL(TimeToLive) 기능을 사용한 것입니다. 그러니까 Redis DB에서 이 데이터가 삭제될 시점을 저장 시에 입력할 수 있는 것이죠. 만약 이 API를 사용하지 않으면 무한히 DB에 데이터가 남게 됩니다.
@Repository
@RequiredArgsConstructor
public class RefreshTokenRepository {
private final RedisTemplate<String, Integer> redisTemplate;
// 레디스에 리프레시 토큰을 TTL과 함께 저장한다.
public String save(RefreshToken refreshToken) {
ValueOperations<String, Integer> valueOperations = redisTemplate.opsForValue();
valueOperations.set(refreshToken.getRefreshToken(), refreshToken.getMid());
redisTemplate.expire(refreshToken.getRefreshToken(), JwtConstant.REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.SECONDS);
return refreshToken.getRefreshToken();
}
// 레디스에서 리프레시 토큰을 찾는다.
public Optional<RefreshToken> findRefreshTokenById(String refreshToken) {
ValueOperations<String, Integer> valueOperations = redisTemplate.opsForValue();
Integer mid = valueOperations.get(refreshToken);
if (Objects.isNull(mid)) {
return Optional.empty();
}
return Optional.of(new RefreshToken(refreshToken, mid));
}
// 레디스에서 리프레시 토큰을 지운다.
public void deleteById(String refreshToken) {
redisTemplate.delete(refreshToken);
}
}
이번 글에서는 JWT에서의 인가와 Redis 사용법에 대해 알아봤습니다. 다음 글에서는 전월 입/출급 내역을 종합해 합계금을 내는 스케줄러에 대해 짧게 기록해보겠습니다.