안녕하세요,
저는 2023년도 2학기 졸업프로젝트를 진행하고있는 '에잇'팀의 팀원입니다ヾ(•ω•`)o
저희 팀의 주제는 Object Detection을 사용해 부분해설기반 미술관 도슨트 앱 서비스입니다!
간결한 해설과 인터랙션으로 쉽고 재미있는 감상 경험을 제공하는 것을 목표로 삼아 다음의 3가지 목표를 세웠고, 모두 구현 완료했습니다 (‾◡◝)
특히 2번에서 사용자별 도감을 보여주기 위해서는 멀티 유저 관리가 필수적이었습니다. 그래서 oauth 로그인을 구현하였고, 마지막으로 이번 포스팅에서는 Redis를 사용한 로그아웃에 대해 다뤄보겠습니다! (ง •_•)ง
포스팅 구성은 다음과 같습니다.
로그인은 jwt 토큰으로 발급한 access token이 유효한지 검증해서 관리했었다. 그럼 로그아웃은 어떻게 관리해야할까?
access token은 프론트에 보내고, 프론트에서 브라우저에 저장하기 때문에 백엔드에서 토큰을 삭제할 수는 없다. (물론 프론트에게 토큰을 로컬 스토리지에 저장한 토큰을 지워달라고 요청할 수도 있지만, 만약 유저가 미리 토큰을 복사해두었다면 그 토큰으로 요청을 보냈을 때 그대로 로그인이 되게 된다.)
1. 로그아웃 API로 들어온 access token을 Redis에
블랙 리스트
로 기록해두어, 추후 이 토큰으로 요청이 들어온다면 authentication을 허용하지 않으면 된다!
key
: access token,value
: logout)- (참고) JWT는 클라이언트에 저장이 되는, Stateless한 점이 가장 큰 특징이다. 그래서 이것을 없애는 것이 어려운 것이다.
그러면 이 블랙리스트를 어디에 저장하면 될까? 기존에 사용하던 mysql DB에 저장하기에는, 만료시간이 지나면 쓸모없는 정보이기에 자원 낭비가 될 수 있다.
2. Redis 메모리에 저장시 만료시간을 설정하자!
- (참고) refresh token도 2주라는 만료시간을 설정해두었으니 Redis에 저장한다면 더 효율적으로 사용할 수 있다. 하지만 우선 로그아웃용 블랙리스트만 구현하였다.
- access token이 유효하더라도 Redis에 블랙리스트로 등록되어 있으니 로그인이 불가하다.
- 유효시간 만료 후에는 블랙리스트에서 삭제되지만, token 자체가 유효하지 않으니 당연히 불가
휘발성
) 대신, 빠른 엑세스
속도의 장점을 갖는다.Lettuce
를 사용했다. 그 이유는 별도 설정 없이 사용할 수 있기 때문이고... 더 자료가 많기 때문이다..RedisTemplate
혹은 RedisRepository
의 2가지 접근방식을 지원한다. dependency에 Spring Data Redis
를 추가해주어야 스프링에서 redis를 사용할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Redis를 사용하기 위한 configuration을 추가해준다.
REDIS_HOST
로 환경변수로 설정해두었다! 로컬에서 하려면 localhost
라고 적으면 되고, AWS이면 엔드포인트 주소
를 복사해 적으면 된다.6379
이고 나는 그대로 설정했다.# Redis Configuration
spring.data.redis.host=${REDIS_HOST}
spring.data.redis.port=6379
Redis와의 연결을 설정하는 파일이다.
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories // Redis Repository 활성화
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
// Redis 연결을 위한 RedisConnectionFactory 생성 (Redis 클라이언트로 Lettuce 사용)
@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory(redisHost, redisPort); // .properties 파일에서 설정한 host, port 가져와 연동
}
// Redis에 데이터 저장, 검색을 위한 RedisTemplate 설정
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// StringRedisSerializer로 직렬화 방법 설정
redisTemplate.setKeySerializer(new StringRedisSerializer()); // 문자열 key 저장
redisTemplate.setValueSerializer(new StringRedisSerializer()); // 문자열 value 저장
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
기존에 로그인을 구현하면서 JwtAuthenticationFilter를 만들었다. 로그인시 jwt 토큰인 access token을 발급한 후, 이후 로그인이 필요한 request마다 해당 access token을 검증하는 필터 역할을 하는 파일이다. 전체 코드 링크
이제는 로그아웃 기능을 추가했으니, 이 access token이 로그아웃 됐는지 여부를 확인하는 코드를 추가하면 된다!
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer";
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate redisTemplate;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// (추가) Redis 에 해당 accessToken logout 여부 확인
String isLogout = (String)redisTemplate.opsForValue().get(token);
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
// Request Header 에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
JwtService도 로그인 구현시 만들어둔 파일이다. 토큰 발급, 재발급, 유효성 검사 등의 메소드가 포함되어 있다. 전체 코드는 전체 코드 링크
로그인을 위한 메소드도 다음과 같이 추가했다!
블랙리스트
로 저장한다. token에서 만료시간을 추출해 그 시간동안 유효하도록 설정한다.[주의] 추출한 만료시간에서 현재시간을 차감한 뒤 ttl로 설정해야 한다!
Long expiresTime = decodedJWT.getExpiresAt().getTime(); Long now = new Date().getTime(); // 현재 시간 return Optional.ofNullable(expiresTime - now);
// 로그아웃 메소드
public ResponseEntity<ResponseDto> logout(String accessToken){
try {
// accessToken의 유효시간 추출해서, 해당 시간동안 Redis에 블랙리스트로 저장
Long expiration = getExpiresTime(accessToken).get();
redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);
// 200 응답
ResponseDto responseDto = ResponseDto.builder()
.status("success")
.message("Logout successful")
.data(null)
.build();
return ResponseEntity.ok(responseDto);
}
// accessToken 만료된 경우 401 응답
catch (TokenExpiredException e) {
return createResponseEntity(HttpStatus.UNAUTHORIZED, "Expired access Token", null);
}
// 그 외 401 응답
catch (Exception e) {
log.error(e.getMessage());
return createResponseEntity(HttpStatus.UNAUTHORIZED, "Access Token is Invalid", null);
}
}
// AccessToken에서 expiresTime 추출하는 메소드
public Optional<Long> getExpiresTime(String accessToken) {
try {
DecodedJWT decodedJWT = (JWT.require(Algorithm.HMAC512(secretKey)).build().verify(accessToken));
// 만료시간 리턴 (현재시간 차감 후 남은 만료시간)
Long expiresTime = decodedJWT.getExpiresAt().getTime();
Long now = new Date().getTime(); // 현재 시간
return Optional.ofNullable(expiresTime - now); // 기본 만료시간은 1시간 (3600초)
} catch (Exception e) {
log.error("expiresTime 추출 오류: {}", e);
return Optional.empty();
}
}
먼저 로컬에 설치된 Redis에 접속해서 잘 구현되었는지 테스트해보자!
나는 윈도우를 사용해서 다음의 블로그를 참고해서 설치했다.
설치된 여러 파일 중 redis-cli
를 선택하면 바로 접속할 수 있다 !
포스트맨에서 로그아웃 api를 보낸 뒤 redis에 접속하자. 로컬이라서 127.0.0.1:6379
라고 나온다.
KEYS *
명령어를 입력하면, request시 보낸 access token이 Redis에 잘 저장된 것을 확인할 수 있다( •̀ ω •́ )✧
[참고] KEYS 명령어는 key-value 중 key
만 보여준다. key로 access token을 저장했으니 access token이 보이는 것이다~ (나는 2번 실행한 뒤라 2개의 토큰이 보인다.)
이 중 하나의 key를 선택해서, TTL
명령어로 만료시간을 확인해보자.
다음과 같이 3423
이라고 나오는 건 만료될 때까지 3423초가 남았다는 뜻이다! 초반에 access token 유효시간을 1시간(3600초)로 설정했으니 잘 지정된 것이다 (ノ◕ヮ◕)ノ*:・゚✧
트러블 슈팅
위에서 언급했듯이, access token에서 만료시간을 가져온 뒤 현재시간을 차감하지 않았더니 무지막지하게 큰 시간이 만료시간으로 남아있었다. 현재 시간을 차감하는 것을 잊지 말자^^
key의 value를 확인하고 싶으면 GET
명령어를 사용하면 된다.
logout
이라는 value가 잘 설정된 것을 볼 수 있다.
이제 본격적으로 AWS에서 Redis를 설정해보자!! EC2 서버에 직접 Redis를 설치할 수도 있으나, AWS에서 Elastic Cache
를 사용하는 게 더 효율적이라고 한다!
단, ElasticCashe는 로컬에서 접속할 수는 없다. EC2 서버에 접속한 후 다음과 같이 실행하면 된다.
redis-cli -c -h {endpoint} -p 6379
다음과 같이 설정해서 생성하면 된다. 참고로 생성하는데 꽤 오래 걸린다. 5분 이상 걸렸던 것 같다.
과금을 방지하기 위해선 클러스터 모드를 비활성화
해주어야 한다!
AWS 클라우드를 선택하고
복제본 개수를 0
으로 설정한다.
특히 t2.micro
노드를 선택해야만 프리티어 내에서 사용할 수 있다 ( •̀ ω •́ )✧
보안그룹은 인바운드 규칙으로 6379
포트를 허용하는 보안그룹을 생성해 적용했다.
sudo apt install gcc
wget http://download.redis.io/redis-stable.tar.gz
tar xvzf redis-stable.tar.gz
cd redis-stable
make distclean # ubuntu에서만 입력!!!!
make
sudo cp src/redis-cli /user/bin
엔드포인트 주소
를 넣어주면 된다.src/redis-cli -c -h {REDIS_HOST} -p 6379
테스트용으로 'ping'이라고 보내보면 'pong이라는 답변이 오는 것을 확인할 수 있다.
깃허브 Secret에 추가
깃허브 액션을 통해 빌드하고 있으니 REDIS_HOST를 시크릿에 추가했다.
EC2 환경변수에 추가
참고 블로그를 참고해서 진행했다. 먼저 환경변수를 설정하고 확인해보면, 잘 설정된 모습이다.
참고로 이 과정을 안 하고 한참을 헤맸다^^ 아예 스프링 실행이 안 되더라.
팀원에게 SOS 쳤더니 알려주심.. 역시 혼자 끙끙 앓지 말자
스프링 파일 실행
이제 연동이 완료되었다! 스프링 파일을 실행해주면 된다~~~
마지막으로 테스트를 해보자 (ノ◕ヮ◕)ノ*:・゚✧
유효한 access token을 헤더에 담아 로그아웃 요청을 보내면, logout이 성공했다는 200
응답이 잘 오는 모습이다!!!
Redis에 접속해서 KEY
명령어로 확인해보면, 해당 access token이 key로 저장된 것도 확인 가능하다
또한 TTL
명령어로 해당 key의 남은 만료시간을 확인해보면, 3573초이라고 나온다. 기본 1시간(3600초)로 설정해두었으니 잘 설정된 것이다! 만료시간이 지나면 자동으로 Redis에서 삭제될 것이다.
이제 이 access token을 가지고 로그인이 필요한 api 요청을 보내보자. 그러면 로그아웃된 토큰이기 때문에, 403
응답으로 "Logged out access token"이라는 응답이 잘 온다. 모두 성공 !!!! (~ ̄▽ ̄)~