Spring & Redis 활용, access Token & refresh Token 인증/인가 구현

Minjae An·2024년 1월 17일
3

Spring ETC

목록 보기
5/8
post-thumbnail

Acess Token

Access Token만을 사용할 때의 단점은 다음과 같다.

토큰이 만료될 때마다 다시 로그인해야 한다

access token을 발급할 때 유효 기간을 정해서 발급을 하게 되는데 이 유효 기간이 지날 때마다 다시 로그인을 해야 한다. 그런데 이 유효 기간이 만약 사용자가 서비스를 사용 중일 때 만료가 된다면 사용자 입장에서는 매우 불편할 것이다. 이런 사용자 불편함을 없앨 수 있는 가장 간단한 방법은 access token의 유효 기간을 늘리는 것이다. 하지만 이어지는 위험이 존재한다.

토큰의 유효 기간이 길다면 탈취당했을 때 위험하다

토큰의 유효 기간을 길게 설정한다면 해당 토큰이 제3자에게 탈취당했을 때 긴 기간동안 사용자인 척 요청을 할 수 있게 된다. 그래서 access token의 유효 기간을 짦게 하여 탈취당했을 때 위험을 경감시켜야 한다.

하지만 유효 기간을 짦게 하면 앞서 언급했던 재로그인 불편함이 대두된다. 두 문제를 모두 해결하려면 refresh token을 도입할 수 있다.

Access Token + Refresh Token

access token 재발급을 위한 refresh token을 적용한다.

access token이 사용자를 인증할 때 사용한다면 refresh token은 access token을 재발급 받을 때 사용한다. access token이 탈취 당했을 때 위험을 줄이기 위해 유효 기간을 짧게 설정하고 refresh token을 길게 설정한다.

기본적으로 access token을 통해 인증을 진행하고 만약 해당 토큰이 만료되었다면 refresh token을 보내어 access token을 재발급받고 그 토큰으로 다시 인증을 진행하는 흐름이다. 과정을 그림으로 정리하면 다음과 같다.

하지만 여전히 남아 있는 문제들이 존재한다.

refresh token을 탈취당하면 탈취자는 무제한적으로 access token을 발급할 수 있다

access token은 유효 기간이 짧기 때문에 탈취하더라도 오랫동안 사용할 수 없다. 하지만 refresh token을 탈취당한다면 refresh token이 만료되기 전까지 이 refresh token으로 계속 access token을 발급해낼 수 있다. 심지어 refresh token의 유효 기간을 길게 설정했기 때문에 오랜 기간 사용자인 체 요청을 할 수 있다. 이를 해결하기 위한 방법은 RTR(refresh token rotation)이 존재한다.

RTR(Refresh Token Rotation)

access token을 재발급할 때 refresh token도 재발급한다.

서버가 refresh token을 발급할 때 DB에 refresh token을 저장해둔다. 그리고 유저 refresh token을 통해서 access token을 재발급 받을 때 refresh token도 재발급해서 같이 넘겨주는 것이다. 또한 재발급된 refresh token을 DB에 갱신한다.

그리고 유저가 access token 재발급을 요청하기 위해서 서버에게 refresh token을 보내면 해당 서버는 유효성 검증을 하고 추가로 DB에 저장된 refresh token과 동일한지 확인한다. RTR을 적용하면 refresh token의 유효 기간이 길더라도 값이 계속 갱신되기 때문에 긴 유효 기간의 문제점을 해결할 수 있다.

탈취자가 refresh token을 탈취했다고 가정하자. refresh token을 탈취 당한 상태에서 유저가 계속 인증이 필요한 요청을 보내고 있었다고 하자. 그러면 access token이 만료될 때마다 계속 refresh token이 갱신되고 탈취자가 access token을 재발급 받기 위해 이전 refresh token을 보내게 될 것이다. 이 때 DB에 저장된 refresh token과 다르기 때문에 악의적인 공격으로 간주하고 refresh token을 DB에서 삭제 후 재로그인을 요구하는 응답을 줄 것이다.

만약 탈취자가 refresh token을 발급받고 유저보다 먼저 access token을 재발급받으면 어떻게 될까? DB에 refresh token이 갱신되고 진짜 유저는 옛날 refresh token을 가지고 있을 것이다. 이 때 진짜 유저가 다시 refresh token을 보내면 DB 저장 토큰과 다르기 때문에 악의적인 공격으로 간주하고 refresh token을 DB에서 삭제한 후 다시 로그인을 요구할 것이다. 진짜 유저가 재로그인해야 하는 불편함이 존재하지만 보안적으로는 좋은 방법이다.

여러 문제들을 해결했지만 완벽하지는 않다. 만약 access token과 refresh token을 갱신하는 시점에 둘 다 탈취당하고 진짜 유저가 더 이상 서비스를 이용하지 않는다면 DB의 refresh token을 갱신하지 않기 때문에 탈취자가 계속 refresh token을 통해 access token을 발급받을 수 있을 것이다. 이 위험은 애초에 토큰을 탈취 당하지 않게 추가로 조치가 필요하다.

Redis의 사용

refresh token은 수시로 확인되므로 인메모리 NoSQL DB에 저장하여 성능을 보장하는 것이 좋다. key로 유저 PK와 같은 고유한 값을 설정하고 value로 refresh token을 저장할 수 있다.

Redis 도커를 이용해 실행

먼저 도커 허브에서 redis를 검색하여 공식 이미지의 가장 최신 버전을 검색한다. 그리고 해당 이미지를 로컬로 pull 한다.

docker pull redis

해당 이미지를 호스트 포트 번호와 컨테이너 포트 번호를 매핑해주고 백그라운도로 실행한다.

docker run -p 6379:6379 -d redis

Redis에서 cli로 명령어를 주어 테스트를 해보자

docker exec -it [container id] redis-cli

SpringBoot와 Redis 연동(with. Docker)

도커를 이용하여 스프링 부트 컨테이너와 레디스 컨테이너를 띄워보자. 레디스의 데이터들은 호스트에 백업하여 레디스 컨테이너를 종료해도 보존할 수 있도록 설정한다. 전반적인 구조는 다음과 같다.

  • 스프링 부트 컨테이너의 8080 포트는 호스트 8080 포트와 연결하여 외부 요청을 받을 수 있도록한다.
  • 스프링 부트 컨테이너와 레디스 컨테이너를 같은 네트워크 안에 둔다.
  • 위 설정을 통해 스프링 컨테이너가 레디스에 접근할 수 있고 레디스 컨테이너의 6379 포트는 호스트와 연결하지 않는다.
  • 레디스 설정을 통해 컨테이너 외부 호스트에 백업을 하고 외부 호스트의 설정 파일을 통해 레디스 설정을 한다.

redis.conf 설정

호스트의 redis.conf를 통해 레디스를 설정한다.

bind 0.0.0.0 # 외부 호스트 요청 수락
port 6379 # 컨테이너 내부 6379 포트 사용
save 900 1 # 900초 동안 1개 값 변경시 백업
requirepass 1234 # 비밀번호 1234

application.yml

설정 파일에 redis 연동을 위한 정보, jwt 관련 정보가 담겨 있어야 한다.

spring:	
	data:
    redis:
      host: ${REDIS_HOST}
      port: ${REDIS_PORT}
      password: ${REDIS_PW}

jwt:
  secret-key: secret
  expiredMillis: 600

이 환경 변수들은 docker-compose.yml에 등록할 예정이다.

RedisConfig

RedisRepository를 사용하기 위해 @EnableRedisRepositories 어노테이션을 설정해주어야 한다.

package com.example.springallinoneproject.config;

import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager.RedisCacheManagerBuilder;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableRedisRepositories
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Value("spring.data.redis.password")
    private String redisPassword;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
        config.setPassword(redisPassword);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 일반적인 key:value의 경우 시리얼라이저
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        // Hash를 사용할 경우 시리얼라이저
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());

        // 모든 경우
        redisTemplate.setDefaultSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

Token

레디스에 저장할 엔티티이다. idRedisHash 와 함께 키 값이 되고 실질적인 데이터는 refreshToken 이다. 토큰을 생성할 때 레디스에 저장할 것이며 오랫동안 접속하지 않으면 refresh token을 삭제할 예정이므로 @TimeToLive 어노테이션을 통해 만료 기간을 정해준다.

package com.example.springallinoneproject.refresh_token;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

@RedisHash("token")
@AllArgsConstructor
@Getter
public class Token {
    @Id
    private Long id;
    private String refreshToken;
    @TimeToLive
    private Long expiration;
}

모든 인스턴스의 만료 기간이 동일하다면 @RedisHash 에 TTL을 설정할 수 있다.

package com.example.springallinoneproject.refresh_token;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

@RedisHash(value = "token", timeToLive = 10)
@AllArgsConstructor
@Getter
@ToString
public class Token {
    @Id
    private Long id;
    private String refreshToken;
}

TokenRepository

package com.example.springallinoneproject.refresh_token;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface TokenRepository extends CrudRepository<Token, Long> {
}

JwtUtil - refresh token 생성 로직

@Component
@RequiredArgsConstructor
public class JwtUtil {
    @Value("${jwt.secret-key}")
    private String secret;
    private int expirationTimeMillis = 864_000_000; // 10일(밀리 초 단위)
    @Value("${jwt.refresh-token-expiration-mills}")
    private int refreshTokenExpirationMillis;
    private String tokenPrefix = "Bearer ";
    private ObjectMapper objectMapper = new ObjectMapper();
    private final TokenRepository tokenRepository;

		// ...

		public String createRefreshToken(Long id, Instant issuedAt) {
        String refreshToken = JWT.create()
                .withClaim("id", id)
                .withIssuedAt(issuedAt)
                .withExpiresAt(issuedAt.plusMillis(refreshTokenExpirationMillis))
                .sign(Algorithm.HMAC512(secret));

        Token token = new Token(id, refreshToken);
        tokenRepository.save(token);
        return refreshToken;
    }
}

TokenController

package com.example.springallinoneproject.refresh_token;

import com.example.springallinoneproject.user.login.jwt.JwtUtil;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class TokenController {
    private final JwtUtil jwtUtil;

    @GetMapping("/refresh/{id}")
    public ResponseEntity<RefreshTokenResponse> getRefresh(@PathVariable("id") Long id) {
        String refreshToken = jwtUtil.createRefreshToken(id, Instant.now());
        RefreshTokenResponse response = new RefreshTokenResponse(refreshToken);
        return ResponseEntity.ok(response);
    }
}

refresh token 발급을 위한 API를 구성한다. 사용한 응답 DTO는 다음과 같다.

RefreshTokenResponse

package com.example.springallinoneproject.refresh_token;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class RefreshTokenResponse {
    private String refreshToken;
}

Dockerfile 설정

확장자 없이 프로젝트 루트 경로에 생성한다. 컨테이너 실행 시 자동으로 jar 파일을 복사해 실행하는 코드이다.

FROM openjdk:17
ARG JAR_PATH=build/libs/*.jar
COPY ${JAR_PATH} /home/server.jar
ENTRYPOINT ["java", "-jar", "/home/server.jar"]

docker-compose.yml

프로젝트 루트 위치에 설정한다.

version: '3'

services:
  redis:
    image: redis
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - C:\\Users\\mj324\\be_projects\\spring-allinone-project\\redis.conf:/usr/local/etc/redis/redis.conf
    restart: unless-stopped
    networks:
      - test_network

  spring-all-in-one:
    container_name: spring-all-in-one
    build:
      context: ./
      dockerfile: ./Dockerfile
    ports:
      - "8080:8080"
    environment:
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PW: 1234

    depends_on:
      - redis
    networks:
      - test_network
    restart: unless-stopped

networks:
  test_network:

services 아래로 redisspringboot 가 연결되어 있는 모습인데, 레디스 컨테이너와 스프링 컨테이너가 같은 네트워크로 연결되어 실행된다는 의미이다.

services:
  redis:
    image: redis
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - C:\\Users\\mj324\\be_projects\\spring-allinone-project\\redis.conf:/usr/local/etc/redis/redis.conf
    restart: unless-stopped
    networks:
      - test_network

conf 를 로컬에 있는 redis.conf 를 이용해 설정한다. 백업 파일과 설정 파일을 컨테이너 내부 파일이 아닌 호스트 파일을 이용하도록 해 새 컨테이너를 만들어도 데이터를 유지할 수 있게 한다. 이를 위해 volumes 라는 지시어를 통하여 호스트 파일 경로와 컨테이너 내부 파일 경로를 매핑해준다. 또 restart:unless-stopped옵션을 통해 컨테이너가 멈추면 다시 시작하도록 설정한다.

  spring-all-in-one:
    container_name: spring-all-in-one
    build:
      context: ./
      dockerfile: ./Dockerfile
    ports:
      - "8080:8080"
    environment:
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PW: 1234

    depends_on:
      - redis
    networks:
      - test_network
    restart: unless-stopped

스프링 부트 관련 설정으론 우선 호스트 포트:컨테이너 내부 포트 로 포트를 설정해주는 옵션이 있다. 또 application.yml 에서 환경 변수로 설정하였던 값들을 environment 옵션을 통해 정의해준다. 한편, 레디스 실행 후 스프링 부트가 실행되도록 depends_on 지시어를 이용한다.

실행 결과

docker compose up --build

위 명령을 통해 docker-compose를 활용하여 레디스 컨테이너와 스프링 컨테이너를 실행한다. 프로젝트 루트 위치에서 위 명령을 실행했기에 -f 옵션을 통한 docker-compose.yml 파일 경로 지정을 생략하였지만 다른 위치에서 실행 시 첨부해주어야 한다.

Postma을 통해 요청을 보내보면 정상적으로 refresh token을 생성하여 응답해준다.

docker exec -it 커맨드를 이용해 redis-cli로 레디스 컨테이너에 접속한 뒤 저장된 값을 확인해보면 refresh token이 잘 저장되있는 것을 확인할 수 있다.

전체 코드

https://github.com/Minjae-An/spring-all-in-one/tree/feat/%239-spring-with-redis-refresh-token

참고

profile
먹고 살려고 개발 시작했지만, 이왕 하는 거 잘하고 싶다.

0개의 댓글