Spring Security, JWT, Redis (4)

Ajisai·2024년 4월 14일
0

Spring Security

목록 보기
5/7

https://velog.io/@kirisame/Spring-Security-JWT-Redis-3

이 글과 이어진다.

할 일

  • Redis 곁들이기
    • Redis 사용을 위한 property 및 dependency 추가
    • Redis 사용을 위한 Configuration 정의
    • Redis 사용을 위한 Service 정의
    • 파기된 토큰으로 요청된 경우에 대한 예외 정의
    • 로그아웃 처리 내용
  • CORS 설정하기
    • 도대체 그냥 넘어가는 법이 없는 귀염둥이 녀석...

사실 이전 글에서 쓰는 걸 깜빡했는데, Redis 서버를 Docker 컨테이너로 사용하기 위한 환경변수 파일(.env)과 compose 파일도 있어야 한다.
물론 Docker를 사용하지 않는다면 해당사항이 없으니 넘어가면 된다.

Redis

Docker

.env

# 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

docker-compose.yml

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은 만료까지 가장 작은 시간이 남은 데이터부터 삭제한 후 데이터를 채우는 정책이다.

.env 안써도 되나요?

  • 당연히 된다.
  • 결국에 volume 경로나 host, port 등을 .env라는 별도의 파일로 빼고 읽어오냐, 아니면 그냥 한 파일에 모두 작성하냐 차이인데, 관리 측면에서는 별도로 빼는 게 편하다.
  • 보안적인 측면에서도 배포 시 .env를 .gitignore에 등록해두면 내 서버에 관한 중요 정보를 (실수로라도) 공개하지 않을 수 있다는 장점이 있어, 현재는 이렇게 하는 게 권장 사항 또는 '국룰'이다.

Dependency

...
dependencies {
    ...

    //Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    
    ...
}

Property

spring:
  data:
    redis:
      host: {{YOUR_HOST}}
      port: {{YOUR_PORT}}
  • 이게 끝...인데 Redis나 Spring 버전에 따라 spring.data.redis.XXX가 아닌 spring.redis.XXX일 수도 있다.
    최근에 바뀐 듯 하다.
  • 필요하다면 redis 밑에 username, password, timeout, database 등 다른 property를 추가하면 된다.
    그 밖에 여러 property가 있으나 나는 안 써봐서 잘 모른다(아직까지는 필요한 경우가 없었다).

spring-data-redis-database

Baeldung에 따르면 connection factory에서 사용될 데이터베이스의 인덱스라고 하는데, 디폴트는 0이다.
이게 무슨 말인지 이해하려면 Redis의 특성을 알아야 한다.

Redis는 하나의 서버 인스턴스 내에서 여러 개의 독립된 데이터베이스를 제공한다.
Redis는 최대 16개의 데이터베이스를 사용할 수 있는데, 각 데이터베이스는 0~15의 인덱스로 식별된다.
(최대 데이터베이스 갯수는 설정 파일(보통 redis.conf)에서 databases 설정을 통해 변경할 수 있다.)

요약하면 다음과 같다.

  • Redis는 한 서버에서 여러 개의 데이터베이스를 운영할 수 있으며, 각 데이터베이스는 고유한 인덱스 번호로 식별된다.
  • 별도로 설정이 없으면 하나의 데이터베이스, 즉 0번만 생성 및 사용하게 되고, 따라서 디폴트는 0이 되는 것이다.
  • spring-data-redis-database는 내가 사용할 데이터베이스의 인덱스에 해당한다.

Configurations

RedisConfig

import 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;
	}
}
  • StringRedisTemplateRedisTemplate<String, String>과 진배없다.
    사실 완전 똑같은 건 아니고, 직렬화/역직렬화 방식이 따로 설정되어 있다는 점이 다르다.
  • StringRedisTemplate은 문자열 사용을 위한 사전 설정이 따로 되어있는 클래스이므로, 문자열을 다룬다면 이쪽이 더 편할 수 있다.

Service 정의

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);
	}
}
  • 각각 method의 기능은 Redis에 토큰을 삽입하고, 주어진 key로 토큰이 Redis에서 가져오거나, 존재 여부를 알아보는 기능.
  • 다시 말해 RedisService는 토큰을 파기 처리하거나, 주어진 토큰이 파기된 토큰인지 알아보는 기능을 수행한다.
  • 지금 보니 findByKey()existsByKey()의 경우 @Transactional을 없애거나 @Transactional(readonly=true)로 설정하는 게 더 적절할 듯 하다.
    프로젝트를 하던 시점과 이 글을 쓰는 시점 사이에 지식이 늘었다.

예외 정의

RevokedTokenException

public 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));
		}
        
}
  • 토큰의 파기 여부를 확인한 후, 파기된 토큰인 경우 앞서 정의한 RevokedTokenExceptionthrow한다.
  • 그리고 바로 catch해 처리한다.

로그아웃 처리

여기서 할 일은 토큰을 Redis에 넣는 것이다.
이를 위해서 다음과 같은 기능이 필요하다.

  • UserService에 logout 메소드를 추가한다.
  • 토큰으로부터 만기까지 남은 시간을 얻어낸다.
  • 현재 토큰은 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)이 로그아웃 요청의 그것과 다를 건데, 굳이 메소드 하나를 더 호출해야 하는가...라는 것이다.
    • SecurityContext에 등록된 인증 정보를 요청이 처리되면 자동으로 삭제된다고 한다. 그런데 이 '요청이 처리되면'의 기준이 어떤 것인지 잘 모르겠다....
    • 아시는 분은 댓글로 알려주세요.....

CORS

CORS가 뭘까?

여기 참고 바람.

설정은 어디서 하나?

...

@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();
	}
}
  • Controller에서 @CrossOrigin으로 하는 방법도 있으나 여기서는 Spring security 쓰는 김에 SecurityConfig에서 해봤다.
  • 여기서는 모든 출처와 모든 헤더를 허용하는 방법을 보여주고 있지만 당연히 지양하는 게 좋다.

이것으로 모든 설정을 마쳤다.

profile
고도로 발달한 공유는 메모와 구분할 수 없다

2개의 댓글

comment-user-thumbnail
2024년 4월 26일

끼잉끼잉...

1개의 답글