이 글과 이어진다.
사실 이전 글에서 쓰는 걸 깜빡했는데, Redis 서버를 Docker 컨테이너로 사용하기 위한 환경변수 파일(.env)과 compose 파일도 있어야 한다.
물론 Docker를 사용하지 않는다면 해당사항이 없으니 넘어가면 된다.
# Docker setting
COMPOSE_PROJECT_NAME=project
# Docker volume setting
REDIS_DATA_PATH=./db/data
REDIS_DEFAULT_CONFIG_FILE=./redis.conf
# etc setting
REDIS_BINDING_PORT=6379
REDIS_PORT=6379
version: "3.7"
services:
redisdb:
image: "redis:6.2.6"
container_name: my_redis
hostname: redis
ports: # 바인딩할 포트:내부 포트
- ${REDIS_BINDING_PORT}:${REDIS_PORT}
command: "redis-server /usr/local/etc/redis/redis.conf"
volumes: # 마운트할 볼륨 설정
- ${REDIS_DATA_PATH}:/data
- ${REDIS_DEFAULT_CONFIG_FILE}:/usr/local/etc/redis/redis.conf
restart: always
Redis 정책 설정 파일인redis.conf는 여기서 구하고, 나에게 맞게 설정을 수정했다.
파일이 너무 길어서 주요 옵션만 보면 다음과 같다.
...
bind * -::*
...
maxmemory 2gb
...
maxmemory-policy volatile-ttl
bind: Redis에 대해 모든 접근을 허용하려면 위와 같이 하면 된다. 당연히 최소한으로 허용하는 게 좋다.maxmemory: Redis가 사용할 메모리의 최대 용량이다. mb 등 다른 단위로도 설정할 수 있다.maxmemory-policy: 최대 용량을 모두 채웠을 때의 정책이다. volatile-ttl은 만료까지 가장 작은 시간이 남은 데이터부터 삭제한 후 데이터를 채우는 정책이다....
dependencies {
...
//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
...
}
spring:
data:
redis:
host: {{YOUR_HOST}}
port: {{YOUR_PORT}}
spring.data.redis.XXX가 아닌 spring.redis.XXX일 수도 있다.redis 밑에 username, password, timeout, database 등 다른 property를 추가하면 된다.spring-data-redis-databaseBaeldung에 따르면 connection factory에서 사용될 데이터베이스의 인덱스라고 하는데, 디폴트는 0이다.
이게 무슨 말인지 이해하려면 Redis의 특성을 알아야 한다.
Redis는 하나의 서버 인스턴스 내에서 여러 개의 독립된 데이터베이스를 제공한다.
Redis는 최대 16개의 데이터베이스를 사용할 수 있는데, 각 데이터베이스는 0~15의 인덱스로 식별된다.
(최대 데이터베이스 갯수는 설정 파일(보통 redis.conf)에서 databases 설정을 통해 변경할 수 있다.)
요약하면 다음과 같다.
spring-data-redis-database는 내가 사용할 데이터베이스의 인덱스에 해당한다.RedisConfigimport org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<Long, String> redisTemplate() {
// Key가 Long이므로 StringRedisTemplate이 아닌 RedisTemplate을 사용한다.
RedisTemplate<Long, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setEnableTransactionSupport(true);
return redisTemplate;
}
}
StringRedisTemplate은 RedisTemplate<String, String>과 진배없다.StringRedisTemplate은 문자열 사용을 위한 사전 설정이 따로 되어있는 클래스이므로, 문자열을 다룬다면 이쪽이 더 편할 수 있다. import java.time.Duration;
public interface RedisService {
/*
* Redis에는 파기된 토큰(로그아웃한 사용자의 토큰)이 저장된다.
*/
// 토큰을 파기 처리하는 메소드.
void revoke(Long userId, String value, Duration expiry);
String findBykey(Long key);
boolean existsBykey(Long key);
}
revoke는 Redis에 주어진 값을 삽입하는 메소드다.userId를 key로, 토큰(JWT) 값 value를 value로, 그리고 만기는 expiry로 설정한다.revoke인 이유는 파기된 토큰을 식별하기 위해 Redis를 사용하기 때문.revoke로 명명했..으나 적절한 네이밍인지 모르겠다.import java.time.Duration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class RedisServiceImpl implements RedisService {
private final RedisTemplate<Long, String> redisTemplate;
@Override
@Transactional
public void revoke(Long userId, String value, Duration expiry) {
redisTemplate.opsForValue().set(userId, value, expiry);
}
@Override
@Transactional
public String findBykey(Long key) {
return redisTemplate.opsForValue().get(key);
}
@Override
@Transactional
public boolean existsBykey(Long key) {
return (findBykey(key) != null);
}
}
RedisService는 토큰을 파기 처리하거나, 주어진 토큰이 파기된 토큰인지 알아보는 기능을 수행한다.findByKey()와 existsByKey()의 경우 @Transactional을 없애거나 @Transactional(readonly=true)로 설정하는 게 더 적절할 듯 하다.RevokedTokenExceptionpublic class RevokedTokenException extends RuntimeException {
private static final String DEFAULT_MESSAGE = "로그아웃된 사용자입니다.";
public RevokedTokenException() {
this(DEFAULT_MESSAGE);
}
public RevokedTokenException(String message) {
super(message);
}
}
딱히 설명할 게....
사실 중요한 건 이걸 어디서 쓰냐, 언제 쓰냐인데 내 경우 필터에서 사용했다.
AuthTokenFilter...
public class AuthTokenFilter extends OncePerRequestFilter {
private final AuthTokenProvider tokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = request.getHeader("Authorization"); //헤더에서 토큰 정보를 얻는다.
try {
// claim을 얻어냄으로써 다음을 검증한다.
// 1. 우리가 발급한 토큰이 맞는가?
// 2. 만료된 토큰인가?
AuthToken authToken = new AuthToken(token);
if (tokenProvider.validate(authToken)) {
// 파기된 인증 토큰인가?
// 존재 여부 뿐 아니라 값 비교까지 해야 한다.
Long userId = tokenProvider.getUserIdFromAuthToken(authToken);
if(redisService.existsBykey(userId)) {
if(token.equals(redisService.findBykey(userId))) {
throw new RevokedTokenException();
}
}
//토큰
Authentication authentication = tokenProvider.getAuthentication(authToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (ExpiredJwtException je) {
...
} catch (RevokedTokenException re) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<ErrorResponse> responseBody = new ResponseEntity<>(
new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
re.getMessage()
),
badRequest
);
ServletOutputStream outputStream = response.getOutputStream();
outputStream.write(new Gson().toJson(responseBody).getBytes(StandardCharsets.UTF_8));
}
}
RevokedTokenException을 throw한다.catch해 처리한다.여기서 할 일은 토큰을 Redis에 넣는 것이다.
이를 위해서 다음과 같은 기능이 필요하다.
AuthToken 클래스로 관리하므로, 여기서 토큰 값(String)을 얻는 메소드가 필요하다....
public interface UserService {
...
void logout(AuthToken token);
}
...
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
@Override
public void logout(AuthToken token) {
//1. userId, 토큰 값, 남은 시간을 token으로부터 얻어낸다.
Long userId = tokenProvider.getUserIdFromAuthToken(token);
String value = tokenProvider.tokenToString(token);
Duration expiry = tokenProvider.getRemainingTime(token);
//2. 뽑아낸 데이터를 redis에 저장한다(키가 같으면 갱신된다).
redisService.revoke(userId, value, expiry);
//3. SecurityContext에 등록된 인증 정보를 삭제한다.
SecurityContextHolder.clearContext();
}
}
Authtoken, tokenProvider는 직접 선언한 클래스이므로 이전 글 참조 바람.3. SecurityContext에 등록된 인증 정보를 삭제한다.가 꼭 필요한 절차인지 잘 모르겠다.ThreadLocal(즉 SecurityContext)이 로그아웃 요청의 그것과 다를 건데, 굳이 메소드 하나를 더 호출해야 하는가...라는 것이다.는 여기 참고 바람.
...
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthTokenProvider tokenProvider;
private final RedisService redisService;
...
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setExposedHeaders(
Arrays.asList(
"Content-Type",
"Set-cookie",
"Authorization"
)
);
corsConfiguration.addAllowedOrigin("{{YOUR_ORIGIN}}");
corsConfiguration.addAllowedOriginPattern("*");
corsConfiguration.setAllowedHeaders(List.of("*"));
corsConfiguration.setAllowCredentials(Boolean.TRUE);
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setMaxAge(3600L); // 1h
corsConfiguration.setAllowedHeaders(
Arrays.asList(
"Origin",
"X-Requested-With",
"Content-Type",
"Accept",
"Authorization"
)
);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
...
@Bean
public AuthTokenFilter authTokenFilter() {
return new AuthTokenFilter(tokenProvider, redisService);
}
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
http
...
.cors(
cors -> cors
.configurationSource(corsConfigurationSource())
)
.exceptionHandling(
configurer -> configurer
.authenticationEntryPoint(jwtAuthenticationEntryPoint())
.accessDeniedHandler(jwtAccessDeniedHandler())
);
return http.build();
}
}
@CrossOrigin으로 하는 방법도 있으나 여기서는 Spring security 쓰는 김에 SecurityConfig에서 해봤다.이것으로 모든 설정을 마쳤다.
끼잉끼잉...