Project/2023-02 캡스톤디자인

[Spring Security] SpringBoot 3.1.x Redis 설정 (with JWT)

48965 2024. 4. 8. 16:11

SpringBoot에서 인증(Authentication)인가(Authorization) 필터를 구현하면서 JWT를 도입하였고, Refresh 토큰의 저장소로 Redis를 채택하여 구현하려고 한다.

 

1. build.gradle 내부 redis 및 JWT 의존성 추가

	// Redis
	implementation group: 'org.springframework.data', name: 'spring-data-redis', version: '3.1.3'
	implementation group: 'io.lettuce', name: 'lettuce-core', version: '6.2.6.RELEASE'
    
	// JWT
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
	implementation group: 'org.json', name: 'json', version: '20231013'

사용 중인 SpringBoot의 버전과 호환성을 잘 고려해서 redis와 jwt의 버전 의존성을 추가해주면 된다.

 

2. application.yml 설정

# Redis 설정
spring:
  data:
    redis:
      host: [ip address]
      port: 6379
     
        
# JWT 설정
jwt:
  secret: [시크릿 키 설정]
  accessTokenExpiration: 7200000 # 2시간 (밀리초 단위)
  refreshTokenExpiration: 259200000 # 3일 (밀리초 단위)

 

application.yml 파일에 redis 및 jwt 설정을 추가한다. 

redis의 기본 포트 번호는 6379를 사용하며, host에는 redis가 사용중인 환경에서의 ip 주소를 기입해주면 된다.

jwt 설정에서는 사용할 시크릿 키 기입 후 엑세스 토큰의 만료시간과 리프레시 토큰의 만료시간을 설정해준다.

 

3. RedisConfig 클래스 생성

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

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory,
            RedisSerializer<Object> springSessionDefaultRedisSerializer) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(springSessionDefaultRedisSerializer);
        return redisTemplate;
    }
}

 

redisConnectionFactory() 메서드

  • 해당 메서드는 Redis와의 연결을 관리하는 RedisConnectionFactory 인스턴스를 생성한다.
  • LettuceConnectionFactory는 Lettuce 라이브러리를 사용하여 Redis 서버에 연결하는 팩토리이다. 

redisTemplate() 메서드

  • 해당 메서드는 Redis 연산을 수행하는 데 사용되는 RedisTemplate 인스턴스를 생성한다.
  • RedisTemplate은 Redis 데이터 액세스 코드를 간소화하는 데 사용되며, 다양한 Redis 명령을 메서드로 제공한다.
  • setConnectionFactory 메서드를 호출하여 RedisTemplate에 RedisConnectionFactory를 연결한다. 이를 통해 RedisTemplate이 Redis 연산을 수행할 때 해당 커넥션 팩토리를 사용하여 Redis 서버와 통신할 수 있게 된다.
  • 여기서는 setKeySerializersetValueSerializer 메서드는 Redis에 저장되는 키와 값의 직렬화 방식을 설정하였다. 
  • Hash 데이터 구조를 사용할 경우 setHashKeySerializersetHashValueSerializer를 통해 해시 키와 값의 직렬화 방식도 설정할 수 있다.
  • setDefaultSerializer는 모든 경우에 적용되는 기본 직렬화를 설정한다. 해당 코드에서는 모든 데이터 타입에 StringRedisSerializer를 사용한다.

 

4. TokenService 클래스 생성

@Slf4j
@Service
public class TokenService {
    private final RedisTemplate<String, String> redisTemplate;
    public TokenService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Value("${jwt.secret}")
    private String jwtSecretKey;
    private Key key;
    @Value("${jwt.accessTokenExpiration}")
    private long accessTokenExpiration;

    @Value("${jwt.refreshTokenExpiration}")
    private long refreshTokenExpiration;

    private static final String JWT_TYPE = "JWT";
    private static final String ALGORITHM = "HS256";
    private static final String LOGIN_ID = "loginId";
    private static final String USERNAME = "username";

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String generateJwtAccessToken(UserDto userDto) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + accessTokenExpiration);

        String accessToken = Jwts.builder()
                .setHeader(createHeader())
                .setClaims(createClaims(userDto))
                .setSubject(String.valueOf(userDto.email()))
                .setIssuer("user")
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expireDate)
                .compact();

        return accessToken;
    }

    @Transactional
    public String generateJwyRefreshToken(UserDto userDto){
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + refreshTokenExpiration);

        String refreshToken = Jwts.builder()
                .setHeader(createHeader())
                .setClaims(createClaims(userDto))
                .setSubject(String.valueOf(userDto.email()))
                .setIssuer("profile")
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expireDate)
                .compact();

        // redis에 저장
        redisTemplate.opsForValue().set(
                userDto.email(),
                refreshToken,
                refreshTokenExpiration,
                TimeUnit.MILLISECONDS
        );

        return refreshToken;
    }


    public boolean isValidToken(String token) {
        try {
            Claims claims = getClaimsFormToken(token);
            return true;
        } catch (ExpiredJwtException expiredJwtException) {
            log.error("Token Expired", expiredJwtException);
            return false;
        } catch (JwtException jwtException) {
            log.error("Token Tampered", jwtException);
            return false;
        } catch (NullPointerException npe) {
            log.error("Token is null", npe);
            return false;
        }
    }

    private static Map<String, Object> createHeader() {
        Map<String, Object> header = new HashMap<>();

        header.put("typ", JWT_TYPE);
        header.put("alg", ALGORITHM);
        header.put("regDate", System.currentTimeMillis());
        return header;
    }

    private static Map<String, Object> createClaims(UserDto userDto) {
        // 공개 클래임에 사용자의 이름과 이메일을 설정해서 정보를 조회할 수 있다.
        Map<String, Object> claims = new HashMap<>();

        log.info("loginId : " + userDto.email());
        log.info("username : " + userDto.name());

        claims.put(LOGIN_ID, userDto.email());
        claims.put(USERNAME, userDto.name());
        return claims;
    }

    private Claims getClaimsFormToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key)
                .build().parseClaimsJws(token).getBody();
    }

    public String getUserEmailFromToken(String token) {
        Claims claims = getClaimsFormToken(token);
        return claims.get(LOGIN_ID).toString();
    }
    
    public static String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }else{
            throw new ProfileApplicationException(ErrorCode.AUTH_TOKEN_NOT_FOUND);
        }
    }
}
  • init(): 클래스의 초기화 단계에서 호출되어 JWT 서명에 사용될 키를 설정한다. 
  • generateJwtAccessToken(UserDto userDto): 사용자 정보를 기반으로 JWT 엑세스 토큰을 생성하고 반환한다. 토큰은 사용자의 이메일을 주제(subject)로 사용하고, 지정된 만료 시간을 갖는다.
  • generateJwyRefreshToken(UserDto userDto): 사용자 정보를 기반으로 JWT 리프레시 토큰을 생성하고, 이를 Redis에 저장한 후 토큰을 반환한다. 토큰은 사용자의 이메일을 주제로 사용하고, 엑세스 토큰과 다른 별도의 만료 시간을 갖게 된다.
  • isValidToken(String token): 제공된 토큰이 유효한지 검사하는 메서드. 토큰의 만료, 변조 등을 확인하고, 상태에 따라 boolean 값을 반환한다.
  • createHeader(): JWT 헤더를 생성합니다. 토큰의 타입과 알고리즘을 지정하는 역할을 수행한다.
  • createClaims(UserDto userDto): 사용자 정보를 기반으로 JWT 클레임을 생성하는 메서드. 사용자의 이메일과 이름을 포함한 정보를 클레임으로 설정한다.
  • getClaimsFormToken(String token): 토큰을 파싱하여 클레임을 추출하는 메서드. 해당 메서드는 토큰의 유효성 검사 후 클레임 정보를 반환한다.
  • getUserEmailFromToken(String token): 토큰에서 사용자의 이메일 정보를 추출한다. 이는 클레임에서 loginId를 통해 얻어진다.
  • getTokenFromRequest(HttpServletRequest request): HTTP 요청에서 'Authorization' 헤더를 통해 Bearer 토큰을 추출한다. 토큰이 존재하지 않거나 형식이 올바르지 않은 경우 예외를 발생시킨다.

여기까지 구성이 완료되었으면, redis 세팅이 완료되었으며 인증 과정에서 JWT를 사용할 수 있게 된 것이다.

다음 포스팅에서 이어서 인증 및 인가 과정 구현을 마무리 할 예정이다.