
지난 포스팅에서는 JWT(JSON Web Tokens)를 세션(Session)에서 관리하고 토큰이 만료될 때 로그인 시 사용할 수 있는 기능을 구현했습니다. 그러나 이 방식은 사용자가 자주 다시 인증을 해야 하기 때문에 사용자 경험 측면에서 개선이 필요하다고 느꼈습니다. 이를 개선하기 위해 Redis를 사용하여 보안성과 사용자 경험을 균형 있게 제공하는 더 효율적이고 안전한 인증을 제공하도록 바꿔보겠습니다.
Spring Boot, JWT 로그인 구현
💡Spring Security 는 무엇인가?자바 기반의 웹 애플리케이션을 위한 보안 프레임워크주로 Spring 프레임워크를 사용하는 애플리케이션에서 인증과 권한 부여를 관리하기 위해 사용사용자가 누구인
orijava.tistory.com
💡 Redis 란 무엇인가?
Redis의 주요 기능
- 인메모리 스토리지: Redis는 데이터를 메모리에 저장하므로 매우 빠른 읽기 및 쓰기 작업이 가능합니다. 따라서 낮은 대기 시간과 높은 처리량이 필요한 애플리케이션에 적합합니다.
- 지속성: Redis는 데이터 내구성을 보장하기 위해 스냅샷 및 AOF(추가 전용 파일)와 같은 지속성 옵션을 제공합니다.
- 데이터 구조: Redis는 문자열, 목록, 집합, 해시, 정렬된 집합, 비트맵, 하이퍼로그 로그를 비롯한 고급 데이터 구조를 제공하여 단순한 키-값 저장소보다 더 많은 기능을 제공합니다.
- Pub/Sub 메시징: Redis에는 게시/구독 메시징 시스템이 내장되어 있어 애플리케이션의 여러 부분 간에 실시간 통신이 가능합니다.
- 고가용성 및 확장성: Redis Sentinel은 모니터링, 알림 및 자동 장애 조치를 통해 고가용성을 제공합니다. Redis 클러스터는 여러 노드에 걸쳐 데이터를 분할하여 수평 확장성을 허용합니다.
Redis에서 토큰을 관리할 때의 이점
- 속도 및 성능: Redis는 메모리 내에서 작동하므로 토큰 저장 및 검색이 매우 빠르므로 짧은 대기 시간이 중요한 인증 및 세션 관리에 이상적입니다.
- 확장성: Redis는 다수의 동시 연결 및 작업을 처리할 수 있으므로 사용자 및 세션 수가 많은 애플리케이션에 적합합니다.
- TTL(Time to Live): Redis는 키 만료 시간 설정을 지원합니다. 이는 토큰 수명을 자동으로 관리하는 데 유용합니다. 토큰은 특정 기간이 지나면 만료되도록 설정할 수 있어 오래되거나 오래된 토큰의 위험을 줄일 수 있습니다.
- 세션 저장소: Redis는 사용자 세션 데이터를 저장하는 데 자주 사용됩니다. 빠른 접속 시간으로 효율적인 세션 관리와 사용자 경험을 보장합니다.
- 복제 및 지속성: Redis의 복제 및 지속성 기능을 통해 토큰 데이터를 백업하고 여러 노드에서 사용할 수 있으므로 고가용성과 내구성이 보장됩니다.
🔎 변경된 구현 흐름
- 사용자가 로그인하면 Access Token 과 Refresh Token 을 받습니다
- 클라이언트는 Access Token 을 사용하여 API 요청을 인증하고 서버는 토큰의 서명을 검증하고 유효성을 확인합니다
- Access Token 이 만료되면 클라이언트는 Refresh Token 을 사용하여 새로운 Access Token 을 요청할 수 있습니다
- 서버는 Refresh Token 을 검증하고 새로운 Access Token(및 필요 시 새로운 Refresh Token)을 발급하고 이전 Access Token 은 재사용되지 않습니다
🧱 Redis 설정
1. Redis 다운로드
Releases · microsoftarchive/redis
Redis is an in-memory database that persists on disk. The data model is key-value, but many different kind of values are supported: Strings, Lists, Sets, Sorted Sets, Hashes - microsoftarchive/redis
github.com
- 위 링크로 이동하여 원하는 버전으로 설치해줍니다.
- 별다른 설정 없이 Next 만 클릭하여도 무관하며, 설치할 디렉토리와 포트는 입맛대로 수정해주시면 되겠습니다.
- Redis 기본 포트 : 6379
2. Build.Gradle 에 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
3. application.properties 설정
spring.redis.host=localhost
spring.redis.port=6379
🔨 기존 클래스 수정 및 추가
1. JwtVO 인터페이스 수정
- Refresh Token 만료 시간 추가
public interface JwtVO {
public static final String SECRET = "abo2"; // HS256 (대칭키)
public static final int ACCESS_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 1; // 1시간
public static final int REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 7; // 1주일
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER = "Authorization";
}
2. JwtProcess 클래스 수정
- Access Token 과 Refresh Token 을 생성하고 검증하는 기능 추가
public class JwtProcess {
private final Logger log = LoggerFactory.getLogger(getClass());
// 토큰 생성
public static String createAccessToken(LoginUser loginUser) {
String jwtToken = JWT.create()
.withSubject("access_token")
.withExpiresAt(new Date(System.currentTimeMillis() + JwtVO.ACCESS_TOKEN_EXPIRATION_TIME))
.withClaim("id", loginUser.getUser().getId())
.withClaim("role", loginUser.getUser().getRole() + "")
.sign(Algorithm.HMAC512(JwtVO.SECRET));
return JwtVO.TOKEN_PREFIX + jwtToken;
}
public static String createRefreshToken(LoginUser loginUser) {
String jwtToken = JWT.create()
.withSubject("refresh_token")
.withExpiresAt(new Date(System.currentTimeMillis() + JwtVO.REFRESH_TOKEN_EXPIRATION_TIME))
.withClaim("id", loginUser.getUser().getId())
.sign(Algorithm.HMAC512(JwtVO.SECRET));
return JwtVO.TOKEN_PREFIX + jwtToken;
}
// 토큰 검증 (return 되는 LoginUser 객체를 강제로 시큐리티 세션에 직접 주입)
public static LoginUser verify(String token) {
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(JwtVO.SECRET)).build().verify(token);
Long id = decodedJWT.getClaim("id").asLong();
String role = decodedJWT.getClaim("role").asString();
User user = User.builder().id(id).role(UserEnum.valueOf(role)).build();
LoginUser loginUser = new LoginUser(user);
return loginUser;
}
}
3. JwtAuthenticationFilter 클래스 수정
- RedisUtil 주입
- RedisUtil 을 필드로 추가하고, 생성자에서 주입받습니다
- successfulAuthentication 메서드 수정
- Access Token 과 Refresh Token 생성
- JwtProcess 를 시용하여 Access Token 과 Refresh Token 을 생성합니다
- Redis 에 토큰 저장
- 응답 헤더 및 바디에 토큰 추가
- Access Token 을 응답 헤더에, Refresh Token 을 응답 바디에 추가합니다
- Access Token 과 Refresh Token 생성
- 로그인 성공 시 Access Token 과 Refresh Token 을 생성하고 Redis 에 저장하게 됩니다
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final Logger log = LoggerFactory.getLogger(getClass());
private final AuthenticationManager authenticationManager;
private final RedisUtil redisUtil;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, RedisUtil redisUtil) {
super(authenticationManager);
this.authenticationManager = authenticationManager;
this.redisUtil = redisUtil;
setFilterProcessesUrl("/api/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.debug("디버그 : attemptAuthentication 호출됨");
try {
ObjectMapper om = new ObjectMapper();
UserReqDto.LoginReqDto loginReqDto = om.readValue(request.getInputStream(), UserReqDto.LoginReqDto.class);
// 강제 로그인
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginReqDto.getUsername(), loginReqDto.getPassword());
// UserDetailsService의 loadUserByUsername 호출
Authentication authentication = authenticationManager.authenticate(authenticationToken);
return authentication;
} catch (Exception e) {
throw new InternalAuthenticationServiceException(e.getMessage());
}
}
// 로그인 실패
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
CustomResponseUtil.fail(response, "로그인실패", HttpStatus.UNAUTHORIZED);
}
// 로그인 성공
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
log.debug("디버그 : successfulAuthentication 호출됨");
LoginUser loginUser = (LoginUser) authResult.getPrincipal();
// Access Token 생성
String accessToken = JwtProcess.createAccessToken(loginUser);
// Refresh Token 생성
String refreshToken = JwtProcess.createRefreshToken(loginUser);
// Redis에 토큰 저장
redisUtil.setDataExpire("RT:" + loginUser.getUsername(), refreshToken, JwtVO.REFRESH_TOKEN_EXPIRATION_TIME / 1000);
redisUtil.setDataExpire("AT:" + loginUser.getUsername(), accessToken, JwtVO.ACCESS_TOKEN_EXPIRATION_TIME / 1000);
// 응답 헤더에 Access Token 추가
response.addHeader(JwtVO.HEADER, accessToken);
// 응답 바디에 Refresh Token 추가
response.getWriter().write("{\"refreshToken\": \"" + refreshToken + "\"}");
UserRespDto.LoginRespDto loginRespDto = new UserRespDto.LoginRespDto(loginUser.getUser());
CustomResponseUtil.success(response, loginRespDto);
}
}
4. SecurityConfig 클래스 수정
- 'JwtAuthenticationFiler' 클래스가 'RedisUtil' 을 생성자 파라미터로 받도록 수정되었으므로, 'SecurityConfig' 클래스에서 'JwtAuthenticationFiler' 인스턴스를 생성할 떄 'RedisUtil' 도 함께 전달하도록 수정되었습니다
- `/api/token/refresh` 엔드포인트가 인증 없이 접근 가능하도록 설정을 추가하였습니다
@Configuration
public class SecurityConfig {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final RedisUtil redisUtil;
public SecurityConfig(RedisUtil redisUtil) {
this.redisUtil = redisUtil;
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
logger.debug("디버그 : BCryptPasswordEncoder 빈 등록됨");
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
return http.getSharedObject(AuthenticationManagerBuilder.class)
.build();
}
public class CustomSecurityFilterManager extends AbstractHttpConfigurer<CustomSecurityFilterManager, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, redisUtil);
builder.addFilter(jwtAuthenticationFilter);
builder.addFilter(new JwtAuthorizationFilter(authenticationManager));
super.configure(builder);
}
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
logger.debug("디버그 : filterChain 빈 등록됨");
http.headers().frameOptions().disable(); // iframe 허용안함.
http.csrf().disable();
http.cors().configurationSource(configurationSource());
// jSessionId를 서버쪽에서 관리안함
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.formLogin().disable();
// httpBasic은 브라우저가 팝업창을 이용해서 사용자 인증을 진행한다.
http.httpBasic().disable();
// 필터 적용
http.apply(new CustomSecurityFilterManager());
// 인증 실패
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
CustomResponseUtil.fail(response, "로그인을 진행해 주세요", HttpStatus.UNAUTHORIZED);
});
// 권한 실패
http.exceptionHandling().accessDeniedHandler((request, response, e) -> {
CustomResponseUtil.fail(response, "권한이 없습니다", HttpStatus.FORBIDDEN);
});
http.authorizeRequests()
.antMatchers("/api/v1/**").authenticated()
.antMatchers("/api/admin/v1/**").hasRole("" + UserEnum.ADMIN)
.antMatchers("/api/token/refresh").permitAll()
.anyRequest().permitAll();
return http.build();
}
public CorsConfigurationSource configurationSource() {
logger.debug("디버그 : configurationSource cors 설정이 SecurityFilterChain에 등록됨");
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*"); // GET, POST, PUT, DELETE (Javascript 요청 허용)
configuration.addAllowedOriginPattern("*"); // 모든 IP 주소 허용
configuration.setAllowCredentials(true); // 클라이언트에서 쿠키 요청 허용
configuration.addExposedHeader("Authorization");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
5. 토큰 재발급 로직 구현
- Refresh Token 을 사용하여 Access Token 을 재발급하는 Controller 와 Service, DTO 를 추가
5-1. TokenReqDto
@Getter
@Setter
public class TokenReqDto {
private String refreshToken;
}
5-2. TokenRespDto
@AllArgsConstructor
@Getter
@Setter
public class TokenRespDto {
private String accessToken;
}
5-3. TokenController
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/token")
public class TokenController {
private final TokenService tokenService;
@PostMapping("/refresh")
public ResponseEntity<?> refreshAccessToken(@RequestBody TokenReqDto tokenReqDto) {
try {
TokenRespDto tokenRespDto = tokenService.refreshAccessToken(tokenReqDto.getRefreshToken());
return new ResponseEntity<>(new ResponseDto<>(1, "엑세스 토큰 갱신", null), HttpStatus.OK);
} catch (AuthenticationException a) {
return new ResponseEntity<>(new ResponseDto<>(-1, "유효하지 않은 Refresh Token", null), HttpStatus.UNAUTHORIZED);
}
}
}
5-4. TokenService
@RequiredArgsConstructor
@Service
public class TokenService {
private final RedisUtil redisUtil;
private final AuthenticationManager authenticationManager;
public TokenRespDto refreshAccessToken(String refreshToken) {
try {
if (refreshToken != null && JwtProcess.verify(refreshToken) != null) {
LoginUser loginUser = JwtProcess.verify(refreshToken);
String storedRefreshToken = redisUtil.getData("RT:" + loginUser.getUsername());
if (storedRefreshToken != null && storedRefreshToken.equals(refreshToken)) {
String newAccessToken = JwtProcess.createAccessToken(loginUser);
redisUtil.setDataExpire("AT:" + loginUser.getUsername(), newAccessToken, JwtVO.ACCESS_TOKEN_EXPIRATION_TIME / 1000);
return new TokenRespDto(newAccessToken);
} else {
throw new AuthenticationException("유효하지 않은 Refresh Token") {};
}
} else {
throw new AuthenticationException("유효하지 않은 Refresh Token") {};
}
} catch (Exception e) {
throw new AuthenticationException("유효하지 않은 Refresh Token") {};
}
}
}
로그아웃 구현
1. SecurityConfig 클래스 수정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// ... 다른 설정들 ...
http.authorizeRequests()
.antMatchers("/api/v1/**").authenticated()
.antMatchers("/api/admin/v1/**").hasRole("" + UserEnum.ADMIN)
.antMatchers("/api/token/refresh", "/api/logout").permitAll()
.anyRequest().permitAll();
return http.build();
}
- 로그아웃 엔드포인드가 인증 없이 접근이 가능하도록 수정해줍니다
2. UserReqDto 클래스 수정
@Getter
@Setter
public static class LogoutReqDto {
private String username;
}
3. UserController 클래스 수정
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody @Valid UserReqDto.LogoutReqDto logoutReqDto) {
userService.logout(logoutReqDto);
return new ResponseEntity<>(new ResponseDto<>(1, "로그아웃 성공", null), HttpStatus.OK);
}
- '/api/logout' 엔드포인트를 통해 로그아웃 요청을 받아 서비스의 logout 메서드를 호출하여 토큰을 삭제해줍니다
4. UserService 클래스 수정
// RedisUtil 의존성 추가
private final RedisUtil redisUtil;
// 로그아웃 추가
public void logout(UserReqDto.LogoutReqDto logoutReqDto) {
// Access Token과 Refresh Token 삭제
redisUtil.deleteData("AT:" + logoutReqDto.getUsername());
redisUtil.deleteData("RT:" + logoutReqDto.getUsername());
}
- logout 메서드를 통해 Redis 에서 사용자의 Access Token 과 Refresh Token 을 삭제해줍니다
📽 Test
Postman 으로 요청해보겠습니다.
1. 회원 가입
2. 로그인
2-1. 로그인 성공 시 반환 값
- 로그인 시 성공적으로 사용자 정보와 refreshToken 이 반환되는 것을 확인 할 수 있습니다
2-2. 로그인 성공 시 Header
- 로그인 시 성공적으로 Header 에 토큰이 추가되는 것을 확인할 수 있습니다
3. Redis Cli 를 통해 key 확인
1. Redis 설치 디렉토리에서 redis-cli.exe 를 실행해줍니다
2. Key 확인
- keys * 명령어를 통해 현재 존재하는 모든 key 이름들을 조회할 수 있습니다
- AT : Access Token
- RT : Refresh Token
3. Key 값 확인
- GET + key 이름을 통해 해당 key 값을 조회할 수 있습니다
3. 만료 시간 확인
- TTL + key 이름을 통해 해당 키의 만료 시간을 확인할 수 있습니다
4. Access Token 과 Refresh Token 의 만료 시간을 각각 1분과 2분으로 설정하고 Test
- 만료시 정상적으로 key 가 사라지고 재로그인 시 갱신되는 걸 확인할 수 있습니다
기능을 완성하고 개선하는 과정을 통해 Spring Security 와 Jwt 그리고 새롭게 Redis 에 대해 알게된 시간이었습니다. 이상으로 포스팅을 마치겠습니다!
해당 코드는 아래의 GitHub 주소를 통해 확인하실 수 있습니다
GitHub - bearkuang/SpringSecurity-Jwt-Redis: Spring Security + Jwt + Redis 를 이용한 로그인
Spring Security + Jwt + Redis 를 이용한 로그인. Contribute to bearkuang/SpringSecurity-Jwt-Redis development by creating an account on GitHub.
github.com
'Back_End > Java' 카테고리의 다른 글
OAuth 를 이용한 구글 로그인 (0) | 2024.04.26 |
---|---|
Spring Boot, JWT 로그인 구현 (0) | 2024.04.23 |
Thread 와 Collection (0) | 2024.04.22 |
상속과 인터페이스 (0) | 2024.04.22 |
Back-End, Front-End, DevOps 기록 블로그
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!