배경
토큰 방식의 인증은 두 가지 방법으로 나뉩니다.
1. Access Token만을 이용하는 방식
2. Refresh Token과 Acces Token을 같이 이용하는 방식
왜 2번 방식이 더 선호되는지, Refresh Token을 어디에 저장하고 어떤 방식으로 저장할 것인지 작성하겠습니다.
제가 하고있는 토이 프로젝트(간단한 SNS를 구현 중)에 적용한 코드입니다.
환경
Java 17
Spring 6.0.11 (Spring Boot 3.1.2)
Gradle 8.2.1
Junit 5.9.3, Mockito 5.3.1
JWT
Redis
Refresh Token의 필요성
Access Token 만료시간을 짧게 하면 보안성은 좋아집니다.
그러나, Access Token의 만료시간을 짧게 가져가면 사이트를 이용하는 회원은 자주 로그인 해야되는 불편함이 있습니다.
따라서, Refresh Token을 이용하여 Access Token을 재발급할 수 있고
Access Token의 유효 기간을 짧고 자주 재발급 하도록 만들어 보안을 강화하면서
사용자는 로그아웃 되어 다시 로그인해야 되는 상황을 주지 않도록 하기 위함입니다.
Refresh Token을 어디에 저장해야 될까?
Refresh Token은 Access Token을 재발급하기 위한 용도입니다.
제가 2023년 3월에 처음 JPA를 학습하고 프로젝트를 했었는데 그 당시에는 Refresh Token에 대한 깊은 이해도 부족했고 일단 구현만하자 라는 생각으로 Cookie에 저장하고 사용했습니다.
Refresh Token을 쿠키에 저장하면 오히려 보안성만 떨어뜨리는 행위가 됩니다.
쿠키는 CSRF 공격에 취약하다는 점을 가지고 있어 좋지 않은 방법이라고 결론을 내렸습니다.
마찬가지로 Refresh Token을 세션 스토리지에 저장하는 것도 XSS 공격의 취약성을 가지고 있습니다.
따라서 Refresh Token을 Redis에 저장하는 방식을 채택했습니다. 그 이유는
1. Key - Value 방식, 인메모리 DB 방식으로 빠르게 접근할 수 있습니다.
2. 브라우저에 비해 탈취 가능성이 낮다고 생각하는 redis 서버에 저장하는 방식입니다.
3. Refresh Token은 영구적으로 저장되는 데이터가 아닙니다.
Refresh Token은 영구적으로 저장될 필요가 없기 때문에 In-Memory DB를 사용해도 충분하며, 성능 이점을 챙길 수 있습니다.
Redis 설치 및 실행
설치
설치와 관련한 내용은 제가 참고한 블로그 주소를 남기겠습니다.
https://chordplaylist.tistory.com/299
https://chordplaylist.tistory.com/243
실행
설치
docker pull redis
실행
docker run --name some-redis -p 6379:6379 -d redis
Redis-cli 접속
docker exec -it some-redis redis-cli
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
Redis 설정
Lettuce vs Jedis
Spring Data Redis에서 사용할 수 있는 Redis Client 구현체는 Lettuce와 Jedis가 있습니다.
spring-boot-starter-data-redis를 사용하면 의존성 설정 없이 Lettuce를 사용할 수 있습니다.
Jedis는 별도의 설정이 필요합니다.
따라서, Lettuce가 코드도 간단하고 레퍼런스도 많으며 성능도 좋기 때문에 Lettuce를 사용했습니다.
https://jojoldu.tistory.com/418
application.yml 설정
spring:
datasource:
url: jdbc:h2:mem:testdb;
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
format_sql: true
default_batch_fetch_size: 1000
open-in-view: false
jwt:
secret: aaaabbbsdifqbvaesoioegwaaaabbbsdidsfdsfdfsdfsdfsdfsdfsd
redis:
host: localhost
port: 6379
Redis Repository vs Redis Template
Repository
Repository 인터페이스를 정의하는 방법은 Spring Data JPA를 사용하는 것과 비슷합니다.
Redis는 많은 자료구조를 지원하는데, Repository를 정의하는 방법은 Hash 자료구조로 한정하여 사용할 수 있습니다.
Repository를 사용하면 객체를 Redis의 Hash 자료구조로 직렬화하여 스토리지에 저장할 수 있습니다.
Redis Template
Redis Template은 Reids 서버에 커맨드를 수행하기 위한 고수준의 추상화를 제공합니다.
Redis Template 방식이 아닌 Redis Repository 방식을 사용
저는 CrudRepository를 상속받는 RedisRepository 방식을 이용했습니다.
별도의 Configuration 의존성 추가가 필요하지 않고 Redis Template 방식보다 훨씬 구현이 간편하기 때문입니다.
JpaRepository를 상속받지 않도록 주의합니다. JPA 의존성이 필요하지 않습니다.
RefreshToken
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
@Getter
@RedisHash(value = "refreshToken", timeToLive = 14440)
public class RefreshToken {
@Id
private String refreshToken;
private Long userId;
public RefreshToken(String refreshToken, Long userId) {
this.refreshToken = refreshToken;
this.userId = userId;
}
}
여기서 주의할 점은 @Id 어노테이션입니다.
java.persistence.id가 아닌 opg.springframework.data.annotation.Id 를 import해야 됩니다.
Refresh Token은 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않습니다. (persistence로 하면 에러납니다.)
@RedisHash 어노테이션은 Redis Lettuce를 사용하기 위해 작성해야 됩니다.
value는 redis key 값으로 사용됩니다.
redis 저장소의 key로는 {value}:{@Id 어노테이션을 붙여준 값}이 저장됩니다.
제가 작성한 value는 refreshToken이고, @Id 어노테이션이 붙어있는 값을 test 라고 넣는다고 가정하겠습니다.
"refreshToken:test"와 같이 저장됨을 알 수 있습니다.
timeToLive는 유효시간을 값으로 초 단위를 의미합니다.
제 코드는 현재 14_400초 4시간으로 설정했습니다.
RefreshTokenRepository
import com.example.mutsaSNS.domain.entity.token.RefreshToken;
import org.springframework.data.repository.CrudRepository;
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
}
JpaRepository가 아닌 CrudRepository를 상속받아야 합니다.
LoginService (Login Filter)
저는 시큐리티에서 지원하는 login 기능을 이용하였기 때문에 login 서비스가 존재하지 않습니다.
@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
private final AuthenticationManager authenticationManager;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인");
try {
User user = objectMapper.readValue(request.getInputStream(), User.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
return authenticationManager.authenticate(authenticationToken);
} catch (IOException e) {
log.error("{}", e);
}
return null;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalUserDetails userDetails = (PrincipalUserDetails) authResult.getPrincipal();
String accessToken = tokenProvider.createAccessToken(userDetails.getUser());
String refreshToken = UUID.randomUUID().toString();
RefreshToken redis = new RefreshToken(refreshToken, userDetails.getUser().getId());
log.info("userDetails.getUser().getId() = {}", userDetails.getUser().getId());
refreshTokenRepository.save(redis);
setTokenResponse(response, accessToken, refreshToken);
}
private void setTokenResponse(HttpServletResponse response, String accessToken, String refreshToken) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
Map<String, Object> result = new HashMap<>();
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
response.getWriter().println(
objectMapper.writeValueAsString(
Response.success(result)));
}
}
successfulAuthentication()
refreshToken과 accessToken 발급은 사용자가 로그인을 할 때 발급됩니다.
RefreshToken 값
RefreshToken으로 UUID.randomUUID().toString 값을 넘겨주었습니다.
TokenProvider에서 발급해주는 방식도 가능하지만 RefreshToken에 굳이 정보가 있을 필요가 없다고 판단했습니다.
https://stackoverflow.com/questions/73823170/refresh-tokens-stored-as-uuid-or-jwt
Postman 로그인 성공 화면
redis 저장소
TokenController
@RequiredArgsConstructor
@RestController
public class TokenController {
private final TokenService tokenService;
@PostMapping("/token")
public String getToken(@RequestBody RefreshToken refreshToken) {
return tokenService.generateAccessToken(refreshToken);
}
}
refreshToken을 이용하여 accessToken을 생성하는 Controller입니다.
TokenService
@RequiredArgsConstructor
@Transactional
@Service
public class TokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
private final TokenProvider tokenProvider;
public String generateAccessToken(final RefreshToken refreshToken) {
RefreshToken refreshToken1 = refreshTokenRepository.findById(refreshToken.getRefreshToken())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User user = userRepository.findById(refreshToken1.getUserId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return tokenProvider.createAccessToken(user);
}
}
Postman 으로 accessToken 발급
정상적으로 발급됨을 알 수 있습니다.
마치며
- opg.springframework.data.annotation.Id 를 import해야 됩니다.
- Refresh Token은 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않습니다.
- @RedisHash 어노테이션은 Redis Lettuce를 사용하기 위해 작성해야 됩니다.
- JpaRepository를 상속받지 않도록 주의합니다. JPA 의존성이 필요하지 않습니다.
처음으로 Redis를 사용해서 리프리쉬 토큰 인증 기능을 구현했습니다.
Redis에 대한 깊은 이해를 바탕으로 구현한 것은 아닌데 이번 계기로 Redis에 대한 공부도 해보고 캐싱까지도 적용해서 포스팅하겠습니다.
'Spring' 카테고리의 다른 글
LocalStorage, Cookie JWT토큰은 어디에 보관할까? (0) | 2023.10.17 |
---|---|
XSS(Cross Site Scripting) 공격 (0) | 2023.10.16 |
Lombok - 활성화 시키는 방법 (0) | 2023.01.06 |
뷰 리졸버 - /WEB-INF/views/ + .jsp 자동 설정 (0) | 2022.12.30 |
Spring - Gradle 서버 포트 충돌 오류 (0) | 2022.12.16 |