📌 이번 글에서는 지난 시간에 이어 JwtTokenizer와 Redis 클래스 생성하여 jwt 발급과 redis에 토큰을 저장하는 과정에 대한 준비를 해보겠습니다.
🤗 저의 스프링 시큐리티 구현은 아래와 같은 시나리오를 기준으로 합니다.
- 프론트 엔드와 백엔드가 나뉘어 진행되는 프로젝트를 기반으로 하여 스프링 시큐리티 설정에서 로그인 페이지에 대한 설정을 따로 하지 않음
- JWT 토큰 인증 방식을 사용함
- 토큰 관리에 redis를 이용함
🙉 이전 글 보기
첫 번째 글부터 정독하시면 보다 쉽게 이해하실 수 있습니다!
https://suzuworld.tistory.com/438 - 당신의 첫 프로젝트를 위한 스프링 시큐리티 톺아보기
이전 글
https://suzuworld.tistory.com/442 - 로그인 테스트 및 JWT, Redis 개념 정리
📖 목차
스프링 시큐리티 톺아보기
SecurityConfig 구성하기
인증 방식 개념과 AuthenticationFilter
AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails
로그인 테스트 및 JWT, redis 개념 정리
JwtTokenizer와 Redis 클래스 작성하기 (현재 글)
지난 글까지의 내용을 다시 정리해 봅시다. 우리는 로그인 구현에 성공했습니다. 그러나, 아직까지는 로그인을 한다고 해서 사용자에게 어떠한 권한도 부여하지 않습니다. 그리고 직전 글에서 JWT와 redis에 대해 설명했죠.
이제 우리는 지난 시간까지 구현한 로그인 로직에 JWT을 발급하고, redis에 저장하는 부분을 구현하기 위해 필요한 클래스를 작성해 보겠습니다.
🤑 JWT
JWT 발급을 구현하기 전에 토큰의 생성과 검증 방식에 대해 이해해 봅시다. JWT에 대한 기본적인 개념은 지난 글을 참고해 주세요. 지난 글에서 JWT의 예시 구조를 보셨죠? 무슨 생각이 드셨나요? 무슨 의미 없는 문자열 아닌가 싶으실텐데요. 사실 특정 규칙에 의해 변형된 문자열입니다. 정확히 설명하면 JWT는 Base64로 인코딩된 스트링 값에 불과합니다. 여기서 주의할 점은 Base64 인코딩 방식이 암호화의 일종이라고 생각하시면 안된다는 점입니다. Base64 인코딩은 데이터를 Base64의 규칙에 따라 문자열로 변환한 것 뿐입니다. 따라서 JWT를 디코딩한다는 것은 변환된 문자열을 원래의 JSON으로 되돌리는 것입니다.
디코딩을 하게 되면 의미없는 스트링 값을 원래의 json 형태 문자열 값으로 변환할 수 있게 되는데 그 안에는 유효 시간, 해시 알고리즘, 유저 정보 등이 들어있습니다. 이 정보를 가지고 사용자의 권한이나 토큰의 유효 시간 등을 판별하게 됩니다. JWT는 특정 키 등으로 암호화한 것이 아니라는 사실을 명심합시다. 누구나 디코딩 가능하기 때문에 보안에 해당하는 정보를 저장해서는 절대 안 됩니다.
🖋️ 서명 알고리즘?

그럼 여기서 의문이 들 수 있습니다. 서명 알고리즘이 사용된다고 하는데 그건 무슨 말인가? 위 그림과 같이 JWT 안의 유저 정보 등은 payload에 저장됩니다. 이 payload에는 해당 유저의 정보 등이 들어있는데 위에 언급했듯이 JWT는 누구나 디코딩할 수 있습니다. 이는 누구나 정보를 조작하고 다시 Base64로 인코딩하여 조작된 JWT를 만들 수 있다는 이야기가 됩니다. 따라서 해당 정보가 조작되지 않았다는 것을 증명하기 위해 서명 알고리즘을 이용하여 payload를 해싱합니다. 이렇게 해시한 값을 시그니처에 포함시키고, 어떤 해시 알고리즘을 사용했는지는 header에 기록합니다.
🏃🏽♂️➡️ 생성부터 검증 과정
해싱을 위해서는 비밀키(secret key)가 필요한데요. 서버에서는 JWT를 만들 때 비밀키를 미리 준비해 두고 이를 사용하여 payload를 해싱합니다. 이때 나온 해시값은 JWT의 시그니처에 저장되며 해당 JWT를 클라이언트에게 발급합니다. 클라이언트는 이후 이 JWT를 인증이 필요한 요청에 싣어 보냅니다. 여기서 두 가지 실패 경우가 나옵니다.
🥷🏻 JWT가 조작된 경우
❌ payload 값을 조작
JWT의 payload 값을 클라이언트가 조작했다고 가정합시다. 클라이언트는 조작된 payload값으로 요청을 보내면 서버에서 해당 payload 값을 서버에서 저장 중인 비밀키로 해시하여 시그니처에 적힌 해시값과 비교하여 payload가 조작된지를 검증합니다. 당연히 해시값이 다를테니 인증을 통과하지 못할 것입니다.
❌ payload 값을 조작하면서 시그니처에 저장된 해시값 역시 조작한 경우
클라이언트에서 payload를 조작함과 동시에 이를 header에 있는 해시알고리즘을 이용해 해싱하고 이 해시값도 시그니처에 싣습니다. 그러나, 클라이언트가 해시값을 만들 때 사용한 비밀키와 서버에서 검증에 사용하는 비밀키가 달라 해시값이 일치하지 않을 것임으로 인증을 통과할 수 없습니다.
⭕️ JWT가 조작되지 않은 경우
서버에서 검증할 때 payload를 서버의 비밀키로 해시하여 값을 시그니처의 해시값과 비교하는 과정을 거치는데 이때, 시그니처의 해시값 역시 서버의 비밀키로 만든 해시값일 것임으로 해시값이 일치해 검증이 통과됩니다.
👨🏻🏭 JWT 관리
이제 실무에서 적용할 때 고려하는 사항을 추가로 생각해봅니다. 서버에서는 JWT의 재발급에 대해서도 고려해야합니다. 왜냐하면 같은 JWT를 무한히 사용할 수 없기 때문입니다. 만약 클라이언트의 관리 실수로 JWT가 탈취된다면 이는 다른 사람에 의해 악용될 가능성이 있습니다. 따라서 일반적으로 JWT의 유효시간은 굉장히 짧게 가져갑니다. 5-10분정도죠. 그리고 이 유효시간이 다 되면 재발급을 하는 방식으로 동작하게 됩니다. 이 작업을 효율적으로 하기 위해 AccessToken과 RefreshToken 둘로 나뉘어 관리하게 됩니다.
💧토큰의 생명주기
서버는 최초에 두 JWT(AccessToken, RefreshToken)를 발급합니다. AccessToken의 경우 유효 시간이 매우 짧습니다. RefreshToken의 경우 AccessToken에 비해 유효 시간이 깁니다. 클라이언트는 두 토큰을 이용해 인증이 필요한 요청을 진행합니다.
이때 AccessToken의 유효 시간이 전부 끝나버린다면? 클라이언트는 가지고 있는 RefreshToken을 이용하여 서버에게 AccessToken의 재발급을 요청하게 됩니다. 서버에서는 RefreshToken을 검증하여 통과되면 AccessToken을 재발급해줍니다.
만약 RefreshToken마저 유효 시간이 끝나면 재로그인을 진행해야 합니다.
RefreshToken Rotation(RTR)
저는 보다 보안적인 설계를 위함과 자동 로그인의 불필요한 요청을 줄이기 위해 RTR 방식으로 구현할 생각입니다. RefreshToken Rotation이란 AccessToken이 만료될 경우, RefreshToken 검증을 통해 AccessToken만 재발급하는 것이 아닌 RefreshToken 역시 새로 발급하는 방식입니다. 이 방법을 사용할 경우 두 가지 장점을 얻을 수 있습니다.
1. RefreshToken 역시 계속 갱신되며 보안에 좀 더 유리해진다.
2. 만일 사용자가 계속 접속하여 활동중이라면 인증이 필요한 요청이 계속될 것이고, 이 요청으로 RefreshToken의 유효시간이 끝나기 전에 지속적인 재발급이 될 것이므로 로그인 상태를 길게 유지하도록 할 수 있다. 물론, 금융 등 중요 보안 관련 애플리케이션의 경우 재로그인을 하도록 강제하는 것이 더 안전한 방법일 것이다.
🧑🏻💻 클래스 작성하기
build.gradle 의존성 추가
//추가!
// JJWT
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.12.6'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- jjwt는 경우 최신 버전이 시간이 지나 변경될 수 있습니다.
- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt 이 링크를 통해 버전을 확인하면됩니다.
JwtTokenizer 클래스 추가
- Jwt 발급부터 검증을 담당하는 메서드를 모아놓은 클래스입니다.
- 코드를 위에서부터 아래로 내려가며 분석해봅시다.
#application.yml에 작성
jwt-secret-key: testsecretcodexxx...
access-token-expiration-minutes: 10 # accesstoken 유효 시간
refresh-token-expiration-minutes: 60 # refreshtoken 유효 시간
#############################################################
import org.springframework.beans.factory.annotation.Value;
@Slf4j
@Getter
@Component
public class JwtTokenizer {
@Value("${jwt-secret-key}")
private String secretKey;
@Value("${access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Value("${refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
...
@Component
- 스프링이 이 애너테이션이 붙은 클래스를 스프링 빈으로 등록합니다.
@Value
- application.yml 파일의 값을 읽어와 초기화할 수 있습니다.
- 예를 들어 위 예시와 같이 application.yml에 있는 값을 가져와 각 변수를 초기화합니다.
- jwt에서 사용할 비밀키(secret key)를 지정합니다.
- access, refresh token의 유효 시간을 지정합니다.
- 이런 보안 정보가 들어간 순간부터 깃허브 등 외부에서 접근 가능한 경로에 보안 정보를 업로드하지 않도록 주의합시다.
- git에서 폴더 및 파일을 ignore하도록 추가 합시다.
generateAccessToken 메서드
public String generateAccessToken(String email,
Map<String, Object> claims,
int expirationMinute,
String secretKey) {
return Jwts.builder()
.subject(email) // 해당 토큰의 주체(구분자 역할이므로 이메일을 사용)
.claims(claims) // 토큰에 포함되어있는 기본 정보(memberId, grade, status 등)
.issuedAt(Date.from(Instant.now())) // 토큰 발행 시간
.expiration(getTokenExpiration(expirationMinute)) // 토큰 만료 시간
.signWith(createSignKey(secretKey)) // 토큰을 서명할 때 필요한 비밀키를 파라미터로 하여 header와 payload를 해싱함
.compact();
}
- Access Token을 생성하는 메서드입니다.
- Jwt를 빌더 패턴을 이용하여 쉽게 생성할 수 있습니다.
- 여기서 claims에 대해 설명을 하고 넘어가는 것이 좋을 것 같습니다.
- claims는 토큰에 포함된 유저의 정보로 토큰 인증 및 인증 후 유저의 권한과 상태 여부 등을 확인하는 데에 사용됩니다.
- 위에서 언급한 payload 안에 들어가있는 유저 정보가 바로 이 claims입니다.
- JWT의 서명에 사용되는 알고리즘은 주로 HMAC-SHA 계열입니다.
- HMAC-SHA256의 경우 SHA-256 해시 함수와 SecretKey를 함께 사용하여 해싱하는 알고리즘입니다.
generateRefreshToken 메서드
public String generateRefreshToken(String email,
int expirationMinute,
String secretKey) {
// AccessToken과 동일
return Jwts.builder()
.subject(email)
.issuedAt(Date.from(Instant.now()))
.expiration(getTokenExpiration(expirationMinute))
.signWith(createSignKey(secretKey))
.compact();
}
- Refresh Token을 생성하는 메서드입니다.
- 내용은 AccessToken과 거의 같습니다.
- 차이점은 Access Token의 재발급에 목적을 두고 있기 때문에 claims이 따로 없습니다.
createAccessToken 메서드
public String createAccessToken(Member member) {
// Map을 통해 JWT에 포함할 claims 생성.
// 주의할 점은 암호화되지 않으므로 중요 정보(비밀번호 등)을 넣어서는 안됨.
Map<String, Object> claims = new HashMap<>();
claims.put("memberId", member.getMemberId());
// 해당 토큰에서 구분자 역할을 할 주체 값(여기서는 email로 사용)
String subject = member.getUsername();
return generateAccessToken(subject, claims, accessTokenExpirationMinutes, secretKey);
}
- AccessToken을 만들기 위한 메서드로 claim과 subject를 따로 만들기 위하여 generateAccessToken과 분리했습니다.
- 여기서는 간단한 예제를 위해 claims에 member의 ID 값만 포함시켰습니다.
- 추가 정보는 그대로 Map에 추가하는 식으로 작성하시면 됩니다.
createRefreshToken 메서드
public String createRefreshToken(Member member) {
String subject = member.getUsername();
return generateRefreshToken(subject, refreshTokenExpirationMinutes, secretKey);
}
- RefreshToken을 만들기 위한 메서드로 subject를 위해 generateRefreshToken과 분리했습니다.
getTokenExpiration 메서드
private Date getTokenExpiration(int expirationMinute) {
// 현재 시간을 나타내는 Calendar 객체를 생성함.
Calendar calendar = Calendar.getInstance();
// Calendar 객체의 현재 시간에 expirationMinute 만큼의 분을 더함.
calendar.add(Calendar.MINUTE, expirationMinute);
// 수정된 Calendar 객체의 시간을 Date 객체로 변환하여 반환.
return calendar.getTime();
}
- 현재 시간에 토큰의 유효 시간 만큼을 더해 만기 시간을 리턴합니다.
CreateSignKey 메서드
private SecretKey createSignKey(String secretKey) {
// String secretKey를 Base64로 디코딩하여 byte 배열로 만듬
byte[] decodedSecretKey = Base64.getDecoder().decode(secretKey);
// hmacShaKeyFor() 메서드는 전달받은 바이트 배열을 사용하여
// HMAC-SHA256 또는 HMAC-SHA512 등의 알고리즘에서 사용할 수 있는 SecretKey 객체를 생성함.
// 예를 들어, HMAC-SHA256에서는 최소 256비트(32바이트) 이상의 키가 필요함.
// 만약 키 길이가 짧으면 예외(InvalidKeyException)를 발생시킴.
return Keys.hmacShaKeyFor(decodedSecretKey);
}
- Jwt의 서명키를 만들기 위한 메서드입니다.
- 파라미터로 서버에서 지정한 secretKey를 입력받습니다.
- generateAccessToken 메서드 참고
verifyAccessJws 메서드
public Claims verifyAccessJws(HttpServletRequest request) {
// 헤더에 실린 토큰의 맨 앞에 Bearer가 포함되어있는지를 확인하여 AccessToken임을 확인함
try {
String jws;
if (request.getHeader("Authorization").startsWith("Bearer ")) {
jws = request.getHeader("Authorization").replace("Bearer ", "");
} else {
log.error("[ JwtTokenizer - verifyAccessJws ] Request Header에 AccessToken이 없습니다.");
throw new RuntimeException("토큰이 유효하지 않습니다.");
}
// 서버에 저장되어 있는 secretKey를 사용하여 서명에 사용할 비밀키 생성
SecretKey signKey = createSignKey(secretKey);
// 검증 시에 만든 secretKey를 사용하여 Jwt 파싱에 성공한다면
// 해당 JWT를 만들 떄 사용한 signKey와 같은 비밀키라는 이야기이므로
// 서버에서 생성된 JWT임이 확인됨.
Claims claims = Jwts.parser().verifyWith(signKey).build().parseSignedClaims(jws).getPayload();
return claims;
} catch (SignatureException e) {
log.error(e.getMessage());
throw new RuntimeException("토큰이 유효하지 않습니다.");
}
}
- AccessToken의 JWS를 검증하는 메서드입니다.
verifyRefreshJws 메서드
public Claims verifyRefreshJws(HttpServletRequest request) {
try {
String jws;
if (!request.getHeader("Refresh").isBlank()) {
jws = request.getHeader("Refresh");
} else {
log.error("[ JwtTokenizer - verifyAccessJws ] Request Header에 RefreshToken이 없습니다.");
throw new RuntimeException("토큰이 유효하지 않습니다.");
}
SecretKey signKey = createSignKey(secretKey);
return Jwts.parser().verifyWith(signKey).build().parseSignedClaims(jws).getPayload();
} catch (SignatureException e) {
log.error(e.getMessage());
throw new RuntimeException("토큰이 유효하지 않습니다.");
}
}
}// 클래스 끝
- RefreshToken의 JWS를 검증하는 메서드입니다.
- AccessToken JWS 검증 메서드와 대체로 일치합니다.
- 여기까지가 JwtTokenizer 클래스입니다.
🛑 redis
Docker에서 Redis 설치 및 테스트
# redis 이미지 다운로드
$ docker pull redis
# redis 컨테이너 실행
$ docker run -d --name redis-container -p 6379:6379 redis
# redis CLI를 사용하여 접속 가능 여부 확인
docker exec -it redis-container redis-cli
- 이 부분은 자유롭게 해주시면 됩니다. 로컬 Redis도 상관없습니다.
RedisConfig
@Configuration // 해당 클래스를 Spring 설정 클래스(Configuration)로 지정
@RequiredArgsConstructor // 필수 생성자를 자동으로 생성해주는 Lombok 어노테이션
public class RedisConfig {
/**
* Redis 연결을 위한 ConnectionFactory를 Bean으로 등록
* 여기서는 LettuceConnectionFactory를 사용하여 Redis에 연결
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
/**
* RedisTemplate을 설정하여 Redis와의 데이터 직렬화 및 연결을 관리
*
* @return 설정된 RedisTemplate 객체
*/
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// Redis 연결을 위한 ConnectionFactory 설정
redisTemplate.setConnectionFactory(redisConnectionFactory());
// Key Serializer 설정 (문자열 직렬화)
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// Value Serializer 설정
redisTemplate.setValueSerializer(new StringRedisSerializer()); // 문자열 직렬화
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화
return redisTemplate;
}
}
- 이 클래스는 Spring Boot 애플리케이션에서 Redis를 사용하기 위한 설정을 담당합니다.
- RedisConnectionFactory 설정
- LettuceConnectionFactory를 사용하여 Redis 서버와의 연결을 관리합니다.
- RedisTemplate 설정
- Redis에서 데이터를 쉽게 저장하고 조회할 수 있도록 RedisTemplate<String, Object>를 빈으로 등록합니다.
- 키는 StringRedisSerializer를 사용하여 문자열로 직렬화합니다.
- 값은 StringRedisSerializer와 GenericJackson2JsonRedisSerializer를 사용하여 직렬화 방식을 지정합니다.
RedisService
Service
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, Object> redisTemplate;
/*
RefreshToken Redis에 저장
*/
public void setRefreshToken(String key, String value, int expirationMinutes) {
if (key.startsWith("Bearer")) throw new RuntimeException("유효하지 않은 Refresh Token입니다.");
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(expirationMinutes));
}
/*
AccessToken Redis에 저장, 이 실습에서는 사용하지 않음
*/
public void setBlackList(String key, String value, int expirationMinutes) {
if (!key.startsWith("Bearer")) throw new RuntimeException("유효하지 않은 Access Token입니다.");
redisTemplate.opsForValue().set(key.replace("Bearer ", ""), value, Duration.ofMinutes(expirationMinutes));
}
/*
로그아웃 후 RefreshToken 삭제
*/
public void deleteRefreshToken(String key) {
redisTemplate.delete(key);
}
}
- Redis와 통신하여 데이터를 다룰 수 있도록 도와주는 서비스 클래스입니다.
- 각 메서드는 Redis에 데이터를 저장하거나 불러오는 역할을 합니다.
- setBlackList()는 로그아웃했으나, 유효시간은 남은 AccessToken을 사용하지 못하도록 redis를 통해 관리하도록 만들어진 메서드입니다. 이 실습에서는 사용하지 않으나 만들어 두었습니다.
👨🏻💻 다음 시간
- 다음 글에서는 실제 구현하여 로그인 시 jwt를 발급하고, 토큰을 redis에 저장하는 부분을 보여드리겠습니다.
참고
https://dkswnkk.tistory.com/684#refresh-token-rotationrtr-방법