728x90
개요
레디스 사용을 안해봤기 때문에 어려울 것 같았는데.. 막상하니 너무나도 쉬웠다.
레디스 설정은 아래 블로그에서 설정하자.
[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정 :: 미정 (tistory.com)
[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정
* Docker-Desktop을 이용해 MySQL DBMS를 실행프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를
eesko.tistory.com
기존 MySQL을 사용해서 리프레시 토큰을 저장했는데 유효기간이 끝나면 삭제가 되어야 하는데 사진처럼 계속 디비에 남아있는 것을 볼 수 있다. 스케쥴링을 사용해서 매번 지워주는 번거로운 일을 해야 하기 때문에 만약 배포를 하게 되면 너무 번거로울 것 같아서 레디스를 도입하게 되었다.
레디스의 TTL이라는 기술을 통해 만료시간을 정해주고 만료시간이 지나면 알아서 삭제되도록 구현하도록 하겠다.
나의 환경
Window 11
intelliJ
java 21
spring Boot 3.3.0
spring Security 6
jwt 0.11.5
의존성 설치
//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
필요한 환경변수 세팅
spring:
data:
redis:
host: localhost
port: 6379
password: 1234
RedisConfig
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
return redisTemplate;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration
= new RedisStandaloneConfiguration("localhost", 6379);
redisStandaloneConfiguration.setPassword("1234");
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
}
- 여기서 host와 post 번호, password는 환경변수 (yml 파일 등)을 통해 안보이게 설정하는 것이 좋다.
- 나는 개인 프로젝트로 연습용이라 그냥 서버에서 사용했지만 실제로 배포를 하게 되면 환경변수로 설정해서 보안을 위해 안보이게 하는 것이 좋다.
TokenService.java
@Service
public class RedisTokenService {
private final RedisTemplate<String, String> redisTemplate;
private final String REDIS_REFRESH_TOKEN_KEY_PREFIX = "refreshToken:";
public RedisTokenService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 리프레시 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 사용자 ID를 값으로 저장합니다.
* @param refreshToken 리프레시 토큰
* @param expiration 토큰 만료 시간 (밀리초 단위)
*/
public void addRefreshToken(String refreshToken, long expiration) {
String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
redisTemplate.opsForValue().set(key, refreshToken, expiration, TimeUnit.MILLISECONDS);
}
/**
* 주어진 리프레시 토큰이 유효한지 확인합니다.
* @param refreshToken 리프레시 토큰
* @return 유효성 여부
*/
public boolean isRefreshTokenValid(String refreshToken) {
String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
String storedUserId = redisTemplate.opsForValue().get(key);
return storedUserId != null;
}
/**
* 주어진 리프레시 토큰을 삭제합니다.
* @param refreshToken 리프레시 토큰
*/
public void deleteRefreshToken(String refreshToken) {
String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
redisTemplate.delete(key);
}
/**
* 주어진 리프레시 토큰에 대해 저장된 사용자 ID를 조회합니다.
* @param refreshToken 리프레시 토큰
* @return 저장된 사용자 ID
*/
public String getRefreshToken(String refreshToken) {
String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
return redisTemplate.opsForValue().get(key);
}
}
- 리프레시 토큰을 레디스에 저장하는 로직이다. RedisTemplate를 사용해서 레디스에 저장하는 코드이다.
- 레디스는 기본적으로 키:값 형태로 저장이 되는데,
- 키 값으로 REDIS_REFRESH_TOKEN_KEY_PREFIX: {리프레시 토큰 값} 으로 저장을 했고
- 값으로는 리프레시 토큰 값이 저장되도록 구현했다.
@Service
@Slf4j
public class BlackTokenRedisService {
private final RedisTemplate<String, String> redisTemplate;
private final String REDIS_BLACKLIST_KEY_PREFIX = "blacklist:";
public BlackTokenRedisService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 블랙리스트 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 만료 시간을 값으로 저장합니다.
* @param token 블랙리스트 토큰
* @param expiration 만료 시간 (밀리초 단위)
*/
public void addBlacklistedToken(String token, long expiration) {
String key = REDIS_BLACKLIST_KEY_PREFIX + token;
redisTemplate.opsForValue().set(key, token, expiration, TimeUnit.MILLISECONDS);
}
/**
* 주어진 블랙리스트 토큰이 유효한지 확인합니다.
* @param token 블랙리스트 토큰
* @return 유효성 여부
*/
public boolean isTokenBlacklisted(String token) {
String key = REDIS_BLACKLIST_KEY_PREFIX + token;
String storedToken = redisTemplate.opsForValue().get(key);
return storedToken != null;
}
/**
* 주어진 블랙리스트 토큰을 삭제합니다.
* @param token 블랙리스트 토큰
*/
public void deleteBlacklistedToken(String token) {
String key = REDIS_BLACKLIST_KEY_PREFIX + token;
redisTemplate.delete(key);
}
}
- 로그아웃시 AccessToken을 Redis의 블랙리스트에 저장한다.
- 왜냐면 다시 요청이 들어왔을때 더 이상 못쓰는 AccessToken라는 것을 알려줘야하기 때문에..
<진행 로직>
1. 로그인시 Redis에 토큰을 저장함.
2. Redis에 AccessToken이 저장되어있다는 것은 블랙리스트에 올려져 있다는 것이고 , 사용하면 안됨.
3. 해당 access token이 redis에 있다면 만료된 토큰인것.
4. 로그아웃 시 Access Token 을 blacklist에 등록하여 만료시키기
5. 로그아웃 시 Redis에 저장된 RefreshToken을 삭제하고 Blacklist에 Access Token을 등록.
6. 다시 로그인을 할 때 Blacklist 존재하는지 확인 (로그아웃 된 토큰인지)
7. 만약 사용자가 JWt를 통해 인가를 요청했을 시 블랙리스트의 조회를 통해 로그아웃한 JWT 토큰인지 아닌지 판별
2. Redis에 AccessToken이 저장되어있다는 것은 블랙리스트에 올려져 있다는 것이고 , 사용하면 안됨.
3. 해당 access token이 redis에 있다면 만료된 토큰인것.
4. 로그아웃 시 Access Token 을 blacklist에 등록하여 만료시키기
5. 로그아웃 시 Redis에 저장된 RefreshToken을 삭제하고 Blacklist에 Access Token을 등록.
6. 다시 로그인을 할 때 Blacklist 존재하는지 확인 (로그아웃 된 토큰인지)
7. 만약 사용자가 JWt를 통해 인가를 요청했을 시 블랙리스트의 조회를 통해 로그아웃한 JWT 토큰인지 아닌지 판별
- 위와 같은 형태로 진행된다.
- 즉 로그아웃한 엑세스 토큰은 더이상 사용해서는 안되는 토큰이기 때문에 해킹이 되었을 때 보안에 신경을 쓸 수 있게 된다.
- 참고로 엑세스 토큰은 인증/인가 전용, 리프레시 토큰은 단순 엑세스 토큰을 재발급 해주는 용도라고 보면 된다.
Filter
@Slf4j
@RequiredArgsConstructor
public class RedisJwtFilter2 extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
private final BlackTokenRedisService blackTokenRedisService;
private final RedisTokenService redisTokenService;
private static final List<String> PERMIT_ALL_PATHS = List.of(
"/login", "/api/login", "/de","/api/users/signup","/api/mail/.*","/api/verify-code",
"/api/check-email","/css/.*","/js/.*", "/api/check-usernick", "/api/refreshToken", "/signup",
"/oauth2/.*","/login/oauth2/.*", "/api/users/randomNickname", "/files/.*",
"/api/reset-password", "/api/verify-temporary-password", "/my/change-password",
"/v3/.*", "/swagger-ui/.*"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestPath = request.getRequestURI();
if (isPermitAllPath(requestPath)) {
filterChain.doFilter(request, response);
return;
}
String token = getToken(request);
if (!StringUtils.hasText(token)) {
handleMissingToken(request, response);
} else {
handleTokenValidation(request, response, token);
}
filterChain.doFilter(request, response);
log.info("토큰이 있는지 확인 : {}", token);
}
private boolean isPermitAllPath(String requestPath) {
return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
}
private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String refreshToken = getRefreshToken(request);
if (StringUtils.hasText(refreshToken)) {
try {
String storedToken = redisTokenService.getRefreshToken(refreshToken);
System.out.println(storedToken);
if (refreshToken.equals(storedToken)) {
if (!jwtUtil.isRefreshTokenExpired(refreshToken)) {
String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
setAccessTokenCookie(response, newAccessToken);
getAuthentication(newAccessToken);
} else {
handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
}
} else {
handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
}
} catch (ExpiredJwtException e) {
handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
}
} else {
handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
}
}
private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
try {
if (blackTokenRedisService.isTokenBlacklisted(token)) {
handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
} else {
getAuthentication(token);
}
} catch (ExpiredJwtException e) {
handleExpiredAccessToken(request, response, token, e);
} catch (UnsupportedJwtException e) {
handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
} catch (MalformedJwtException e) {
handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
} catch (IllegalArgumentException e) {
handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
} catch (Exception e) {
handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
}
}
private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
log.warn("Access token expired: {}", token);
String refreshToken = getRefreshToken(request);
if (StringUtils.hasText(refreshToken) && !jwtUtil.isRefreshTokenExpired(refreshToken)) {
String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
setAccessTokenCookie(response, newAccessToken);
getAuthentication(newAccessToken);
} else {
handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
}
}
private String getToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private String getRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
response.addCookie(accessTokenCookie);
}
private void getAuthentication(String token) {
Claims claims = jwtUtil.parseAccessToken(token);
String username = claims.getSubject();
Long userId = claims.get("userId", Long.class);
String name = claims.get("name", String.class);
List<GrantedAuthority> authorities = getGrantedAuthorities(claims);
CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities);
Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
List<String> roles = claims.get("roles", List.class);
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
handleException(request, exceptionCode, logMessage, null);
}
private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
request.setAttribute("exception", exceptionCode.getCode());
log.error(logMessage, e);
log.info("로그인을 부탁드립니다. --> 토큰이 없어요.");
}
}
- 위 코드에서는 달라진 점은 크게 없다. 레디스 블랙리스트에 있는지 보는 부분 말고...
controller.java
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api")
public class RedisController2 {
private final JWTUtil jwtUtil;
private final UserServiceImpl userServiceimpl;
private final PasswordEncoder passwordEncoder;
private final RedisTokenService refreshTokenService;
private final BlackTokenRedisService blackTokenRedisService;
/**
* 로그인 요청 시 JWT 토큰 발급
*/
@PostMapping("/login")
public ResponseEntity<UserLoginResponseDto> login(@RequestBody @Valid UserLoginDto userLoginDto,
BindingResult bindingResult,
HttpServletResponse response) {
log.info("로그인 요청이 들어왔습니다.");
log.info("아이디 :: {}", userLoginDto.getUsername());
log.info("비밀번호 :: {}", userLoginDto.getPassword());
if (bindingResult.hasErrors()) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
Optional<User> user = userServiceimpl.findByUsername(userLoginDto.getUsername());
if (!passwordEncoder.matches(userLoginDto.getPassword(), user .get().getPassword())) {
return new ResponseEntity("비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED);
}
List<String> roles = user.get().getRoles().stream().map(Role::getName).collect(Collectors.toList());
// 기존 리프레시 토큰 삭제
refreshTokenService.deleteRefreshToken(String.valueOf(user.get().getId()));
// 토큰 발급
String accessToken = jwtUtil.createAccessToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);
String refreshToken = jwtUtil.createRefreshToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);
// 리프레시 토큰을 레디스에 저장
refreshTokenService.addRefreshToken(refreshToken, jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT);
// 토큰 쿠키 저장
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT / 1000));
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
// 응답 값
UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.userId(user.get().getId())
.username(user.get().getUsername())
.build();
return new ResponseEntity<>(loginResponseDto, HttpStatus.OK);
}
/**
* 로그아웃 요청
*/
@GetMapping("/logout")
public void logout(@CookieValue(name = "accessToken", required = false) String accessToken,
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse response) {
log.info("로그아웃 요청이 들어왔습니다.");
if (accessToken == null) {
try {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Access token not found in cookies.");
} catch (IOException e) {
log.error("로그아웃 중 오류 발생", e);
}
return;
}
try {
// JWT 블랙리스트에 추가
Date expirationTime = Jwts.parser()
.setSigningKey(jwtUtil.getAccessSecret())
.parseClaimsJws(accessToken)
.getBody()
.getExpiration();
log.info("만료시간: {}", expirationTime);
// 블랙리스트에 추가
blackTokenRedisService.addBlacklistedToken(accessToken, expirationTime.getTime() - System.currentTimeMillis());
SecurityContextHolder.clearContext();
// 쿠키 삭제
Cookie accessCookie = new Cookie("accessToken", null);
accessCookie.setPath("/");
accessCookie.setMaxAge(0);
response.addCookie(accessCookie);
Cookie refreshCookie = new Cookie("refreshToken", null);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(0);
response.addCookie(refreshCookie);
// 레디스에서 리프레시 토큰 삭제
if (refreshToken != null) {
refreshTokenService.deleteRefreshToken(refreshToken);
}
response.sendRedirect("/"); // 리디렉션 처리
} catch (IOException e) {
log.error("로그아웃 후 리디렉션 중 오류 발생", e);
}
}
}
- 여기서 로그인에 성공하면 리프레시 토큰을 레디스에 담아준다.
- 로그아웃 시 엑세스 토큰은 블랙리스트에 담아준다.
SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomOauth2UserService customOAuth2UserService;
private final AuthenticationConfiguration authenticationConfiguration;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final BlackTokenRedisService jwtBlacklistService;
private final RedisTokenService refreshTokenService;
private final CustomSuccessHandler customSuccessHandler;
private final JWTUtil jwtUtil; //JWTUtil 주입
public SecurityConfig(CustomUserDetailsService customUserDetailsService, CustomOauth2UserService customOAuth2UserService,
AuthenticationConfiguration authenticationConfiguration, CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
BlackTokenRedisService jwtBlacklistService, RedisTokenService refreshTokenService,
CustomSuccessHandler customSuccessHandler, JWTUtil jwtUtil) {
this.customUserDetailsService = customUserDetailsService;
this.customOAuth2UserService = customOAuth2UserService;
this.authenticationConfiguration = authenticationConfiguration;
this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
this.jwtBlacklistService = jwtBlacklistService;
this.refreshTokenService = refreshTokenService;
this.customSuccessHandler = customSuccessHandler;
this.jwtUtil = jwtUtil;
}
// 모든 유저 허용 페이지
String[] allAllowPage = new String[] {
"/", // 메인페이지
"/css/**", "/js/**", "/files/**", // css, js, 이미지 url
"/api/login", // 로그인 페이지
"/api/users/signup" // 회원가입 페이지
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers(allAllowPage).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form.disable())
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.cors(cors -> cors.configurationSource(configurationSource()))
.exceptionHandling(exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource configurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowedMethods(List.of("GET", "POST", "DELETE"));
config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
config.setAllowCredentials(true);
source.registerCorsConfiguration("/**", config);
return source;
}
- 여기서도 크게 달라진건 없고 레디스에 저장하기 위해 리프레시토큰, 블랙리스트 서비스로 변경해주면 된다.
이렇게 기존 RDB를 사용해서 토큰을 저장했는데 레디스를 사용함으로써 만료시간 지정해주고 더욱 서버의 부담을 줄여줄 수 있도록 구현했다.
728x90
'Spring > Security' 카테고리의 다른 글
Spring : Security 권한 나누기 (0) | 2024.08.28 |
---|---|
JWT 로그인 시 Refresh-Token 저장소를 Redis로 변경 (0) | 2024.08.20 |
Spring Security + OAuth2 + JWT 를 사용한 로그인, 로그아웃 구현 - 3 (0) | 2024.08.13 |
Spring Security + OAuth2 + JWT 를 사용한 로그인, 로그아웃 구현 - 2 (0) | 2024.08.13 |
Spring Security + OAuth2 + JWT 를 사용한 로그인, 로그아웃 구현 - 1편 (0) | 2024.08.09 |