이 글에서는 JWT(Json Web Token) 기반 인증 시스템에서 발생할 수 있는 리프레시 토큰 탈취 문제를 해결하기 위한 실질적인 방법을 제시합니다.
탈취된 토큰이 악용되는 상황을 방지하고, 리프레시 토큰 로테이션과 플랫폼별 고유 토큰 관리를 통해 보안을 강화합니다.
프로젝트 환경 요약
- Spring Boot 3.3.4
- Java 21
- JJWT 라이브러리 버전 0.12.3
- Redis를 활용한 리프레시 토큰 관리
- Spring Security
의존성 설정
JWT와 Redis를 사용하기 위해 다음과 같은 의존성을 추가합니다.
dependencies {
// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
implementation("io.jsonwebtoken:jjwt-impl:0.12.3")
implementation("io.jsonwebtoken:jjwt-jackson:0.12.3")
// Redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
}
디렉터리 구조
JWT와 인증/인가 처리에 관련된 기능은 security 디렉터리 하위에 구성했습니다. 아래는 디렉터리 구조입니다.
project
│ JwtProjectApplication.java
├─common
│ ├─advice
│ │ └─exception
│ ├─config
│ │ RedisConfig.java
│ └─model
└─security
├─config
│ SecurityConfig.java
└─domain
├─jwt
│ ├─api
│ │ JwtApi.java
│ ├─filter
│ │ JwtExceptionHandlingFilter.java
│ │ JwtLoginFilter.java
│ │ JwtLogoutFilter.java
│ │ JwtRequestFilter.java
│ ├─model
│ │ RefreshToken.java
│ ├─repository
│ │ RefreshTokenRepository.java
│ └─service
│ JwtService.java
└─user
├─api
│ UserApi.java
├─repository
│ UserRepository.java
└─service
UserService.java
로그인과 로그아웃을 Filter로 구현한 이유
우선 저는 JWT 로그인과 로그아웃을 모두 Filter로 구현했는데 이에 대한 이유를 설명하겠습니다.
로그인과 로그아웃을 구현할 때 가장 먼저 고민했던 것은 필터(Filter)로 구현할지, 컨트롤러(Controller)로 구현할지였습니다. 이 두 방식은 각각의 장단점을 가지고 있어 개발 초기에는 어느 쪽이 더 나은지에 대한 고민이 깊었습니다.
컨트롤러 방식에 대한 초기 생각
컨트롤러로 로그인과 로그아웃을 구현하면, 특정 요청에만 해당 로직이 실행되므로 불필요한 경로에서 추가적인 필터 작업이 발생하지 않습니다.
즉, 로그인과 로그아웃 요청이 아닌 경우에는 아무런 로직도 실행되지 않아 성능적으로 더 효율적이라고 생각했습니다. 실제로 여러 레퍼런스를 찾아보면, 컨트롤러 방식을 사용하는 사례도 적지 않습니다.
필터 방식으로 결정한 이유
그러나 최종적으로 필터를 선택한 이유는 다음 세 가지입니다.
1. Security 설정의 일관성 유지
Spring Security의 인증, 인가와 관련된 로직은 대부분 SecurityConfig 클래스에서 설정합니다. 만약 로그인과 로그아웃을 컨트롤러로 구현한다면, 이와 관련된 경로와 로직이 Security 설정과 분리됩니다. 하지만 필터로 구현하면 모든 인증/인가 관련 로직을 한눈에 관리할 수 있어 코드의 일관성을 유지할 수 있습니다.
즉, 인증/인가와 관련된 모든 흐름을 SecurityConfig 안에서 설정하고 관리할 수 있다는 점이 필터 방식의 큰 장점이었습니다.
2. Spring Security의 기본 구조와의 호환성
Spring Security는 기본적으로 필터 체인(Filter Chain)을 기반으로 동작합니다. 로그아웃이나 인증 요청 같은 중요한 작업이 Security 필터 체인 내에서 처리되도록 구현하면, 필요에 따라 필터를 비활성화하거나 커스터마이징하기 쉽습니다.
이는 유연한 설정 변경이 가능하다는 점에서 컨트롤러 방식보다 필터 방식이 더 적합하다고 판단했습니다.
3. 성능상 문제 없음
필터로 구현했을 때, 성능에 부정적인 영향을 미칠 수 있는 부분도 검토했습니다.
필터는 모든 요청에서 작동하지만, 실제로 불필요한 경로에서는 if(!logoutRequestMatcher.matches(request)) 같은 조건문으로 빠르게 필터 처리를 종료합니다.
이로 인해 불필요한 리소스 소모는 거의 없고, 성능에 미치는 영향은 무시할 수 있는 수준이라는 결론에 도달했습니다.
필터 방식으로 결정한 이유 요약
최종적으로 로그인과 로그아웃을 필터로 구현한 이유는 다음과 같이 요약할 수 있습니다.
- 인증/인가와 관련된 모든 설정을 SecurityConfig에서 한눈에 관리할 수 있어 코드의 일관성이 유지됩니다.
- Spring Security의 기본 구조와 유연하게 통합할 수 있습니다.
- 성능상으로도 큰 차이가 없으며, 불필요한 경로에 대한 처리는 최소한의 자원만 사용하기 때문에 문제가 없다고 판단했습니다.
만약 컨트롤러 방식이 더 적합한 상황이 발생한다면, 그때는 필터를 비활성화하고 컨트롤러로 전환하는 것도 좋은 선택이라고 생각합니다.
Redis를 활용한 리프레시 토큰 관리
리프레시 토큰을 Redis에 저장하는 방법에는 여러 가지가 있습니다.
이 글에서는 RedisTemplate 을 사용하지 않고 @RedisHash 어노테이션을 활용하여 JPA 스타일로 CRUD 작업을 쉽게 처리하는 방법으로 구현하였습니다.
이 방식은 Java 코드에서 Redis의 데이터를 마치 JPA 엔티티처럼 사용할 수 있도록 해줍니다.
RefreshToken 클래스 설명
@RedisHash(value = "refreshToken")
public class RefreshToken {
@Id
private String key; // Redis 해시의 고유 키
private Long userId; // 사용자 ID
private String refreshToken; // 리프레시 토큰 값
private boolean rememberMe; // rememberMe 설정 여부
@TimeToLive(unit = TimeUnit.DAYS)
private Long timeToLive; // TTL (Time-To-Live) 설정, 일 단위
private PlatformType platformType; // 플랫폼 정보 (PC, Mobile 등)
}
- @RedisHash: 해당 어노테이션은 객체를 Redis에 저장할 때 Hash 자료구조로 저장되도록 설정합니다. Redis의 키에는 지정한 value 값이 Prefix로 추가되어 저장됩니다. 예를 들어, value = "refreshToken"으로 설정하면, Redis의 키는 refreshToken:{key} 형식으로 생성됩니다.
- @Id: Redis에서 저장된 데이터의 고유 식별자로 사용됩니다. 이 필드의 값이 Redis 키의 {key} 부분을 구성하며, 각 데이터는 이 고유 키를 통해 조회됩니다. 예를 들어, key = "PC123"이라면 Redis 키는 refreshToken:PC123이 됩니다.
- @TimeToLive: 저장된 데이터에 TTL(Time-To-Live)을 설정하여, 지정된 시간이 지나면 Redis에서 해당 데이터가 자동으로 삭제되도록 합니다. 이 클래스에서는 일 단위 TTL 설정이 적용되며, Redis는 TTL이 만료된 데이터를 효율적으로 제거하여 메모리를 관리합니다.
이 클래스는 CrudRepository를 통해 JPA 스타일로 데이터를 쉽게 관리할 수 있으며, 저장된 데이터는 아래와 같은 형태로 Redis에 저장됩니다
HGETALL refreshToken:PC123
1) "key"
2) "PC123"
3) "userId"
4) "123"
5) "refreshToken"
6) "sample_refresh_token_value"
7) "rememberMe"
8) "true"
9) "timeToLive"
10) "1" // 일 단위 TTL
11) "platformType"
12) "PC"
JWT 필터 구현
로그인 필터 (JwtLoginFilter)
- 원래라면 JWT 로그인 필터를 구현하기 전에 UserDetails와 UserDetailsService를 먼저 구현해야 하지만, 이 글에서는 해당 구현에 대한 설명은 생략하고 진행하겠습니다.
- JwtLoginFilter는 사용자가 로그인 요청을 할 때 실행되며, 액세스 토큰과 리프레시 토큰을 생성합니다.
- 생성된 리프레시 토큰은 Redis에 저장되어 TTL(Time-To-Live)로 만료가 관리되기 때문에 따로 배치나 스케줄을 구성하지 않아도 됩니다.
코드 구현
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final ObjectMapper objectMapper;
public JwtLoginFilter(AuthenticationManager authenticationManager, JwtService jwtService, ObjectMapper objectMapper) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
this.objectMapper = objectMapper;
// 필터가 처리할 로그인 URL 설정
setFilterProcessesUrl("/api/v1/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 사용자 입력값을 기반으로 인증 토큰 생성
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null || password == null) {
throw new IllegalArgumentException("Invalid parameter: username or password is missing");
}
// Username과 Password 기반으로 인증 토큰 생성 및 인증 시도
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
// 인증 성공 후 액세스 토큰과 리프레시 토큰 생성
UserDetailsDto userDetailsDto = (UserDetailsDto) authentication.getPrincipal();
Long userId = userDetailsDto.getUserId();
String role = userDetailsDto.getAuthorities().iterator().next().getAuthority();
// 클라이언트에서 전달받은 rememberMe, platformType 값 확인
boolean rememberMe = Boolean.parseBoolean(request.getParameter(REMEMBER_ME));
PlatformType platformType = PlatformType.valueOf(request.getParameter(PLATFORM_TYPE));
// JWT 토큰 생성
String accessToken = jwtService.createAccessToken(userId, role, platformType);
String refreshToken = jwtService.createRefreshToken(userId, role, platformType);
// RefreshToken 엔티티를 Redis에 저장 (TTL 기반 만료)
jwtService.saveRedisRefreshToken(new RefreshToken(userId, refreshToken, rememberMe, platformType));
// 응답으로 토큰 반환
TokenDto tokenDto = new TokenDto(accessToken, refreshToken);
response.setContentType("application/json");
objectMapper.writeValue(response.getWriter(), tokenDto);
}
}
successfulAuthentication 메서드
- 인증 성공 시 액세스 토큰과 리프레시 토큰을 생성합니다.
- 리프레시 토큰은 saveRedisRefreshToken 메서드로 Redis에 저장되며, rememberMe 값에 따라 TTL이 다르게 구현하였습니다.
- objectMapper.writeValue(response.getWriter(), tokenDto); 성공시 토큰 정보를 JSON 형태로 응답을 보냅니다.
JWT 인증 필터 (JwtRequestFilter)
- JwtRequestFilter는 모든 요청에서 Authorization 헤더를 검사하여 유효한 액세스 토큰인지 확인합니다.
코드 구현
public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtRequestFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더에서 Authorization 값 추출
String authorization = request.getHeader(AUTHORIZATION);
// 헤더가 없거나 Bearer 토큰이 아닌 경우 다음 필터로 전달
if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
// "Bearer " 이후의 실제 토큰 값 추출
String token = authorization.split(" ")[1];
try {
// 토큰이 비어있거나 만료된 경우 다음 필터로 전달
if (StringUtils.isBlank(token) || jwtService.isExpired(token)) {
filterChain.doFilter(request, response);
return;
}
// 토큰의 카테고리가 accessToken인지 확인
String category = jwtService.getCategory(token);
if (!category.equals(ACCESS_TOKEN)) {
filterChain.doFilter(request, response);
return;
}
// 토큰에서 사용자 정보 추출
Long userId = jwtService.getUserId(token);
String role = jwtService.getUserRole(token);
UserDto userDTO = new UserDto(userId, RoleCode.valueOf(role));
UserDetailsDto userDetailsDto = new UserDetailsDto(userDTO);
// 스프링 시큐리티 인증 토큰 생성 및 SecurityContext에 설정
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetailsDto, null, userDetailsDto.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
// 토큰이 만료되었을 경우에도 다음 필터로 전달
filterChain.doFilter(request, response);
return;
}
// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
}
doFilterInternal 메서드
- Authorization 헤더에서 Bearer 토큰을 추출하고 유효성 검증을 수행합니다.
- 유효한 액세스 토큰일 경우 사용자 정보를 SecurityContext에 설정합니다.
- 여기서 중요한건 String category = jwtService.getCategory(token); 부분인데 카테고리를 검사하지 않으면 refreshToken으로 요청을 보낼 수 있기 때문입니다.
로그아웃 필터 (JwtLogoutFilter)
- JwtLogoutFilter는 로그아웃 요청을 처리하여 Redis에 저장된 리프레시 토큰을 삭제합니다.
코드 구현
public class JwtLogoutFilter extends GenericFilterBean {
private final JwtService jwtService;
private final RefreshTokenRepository refreshRepository;
private final RequestMatcher logoutRequestMatcher;
public JwtLogoutFilter(JwtService jwtService, RefreshTokenRepository refreshRepository) {
this.jwtService = jwtService;
this.refreshRepository = refreshRepository;
this.logoutRequestMatcher = new AntPathRequestMatcher("/api/v1/logout", "POST");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
if (!logoutRequestMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
String refresh = request.getParameter(REFRESH_TOKEN);
if (refresh == null || "".equals(refresh.trim())) {
throw new IllegalArgumentException("refreshToken should not be null");
}
try {
String category = jwtService.getCategory(refresh);
if (!REFRESH_TOKEN.equals(category)) {
throw new IllegalArgumentException("refreshToken 이 아닙니다.");
}
Long userId = jwtService.getUserId(refresh);
PlatformType platformType = jwtService.getPlatformType(refresh);
String key = platformType.name() + userId;
refreshRepository.deleteById(key);
} catch (JwtException e) {
throw new IllegalArgumentException(e.getMessage());
}
response.setStatus(HttpServletResponse.SC_OK);
}
}
- 로그아웃 요청 시 리프레시 토큰을 검증하고, 리프레시 토큰으로는 더 이상 토큰을 재발급 할 수 없도록 Redis에서 삭제합니다.
- Redis에서 토큰 삭제 후 HTTP 200 OK 응답을 반환합니다.
예외 처리 필터 (JwtExceptionHandlingFilter)
- JwtExceptionHandlingFilter는 토큰 인증 및 인가 과정의 커스텀 필터에서 발생하는 공통된 예외를 처리할 수 있도록 에러를 처리하는 필터를 구현했습니다.
코드 구현
public class JwtExceptionHandlingFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;
public JwtExceptionHandlingFilter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (UnAuthorizedException ex) {
handleException(response, HttpStatus.UNAUTHORIZED, "INVALID_REFRESH_TOKEN", ex.getMessage());
} catch (JwtException ex) {
handleException(response, HttpStatus.UNAUTHORIZED, "INVALID_JWT_TOKEN", ex.getMessage());
} catch (IllegalArgumentException ex) {
handleException(response, HttpStatus.BAD_REQUEST, "BAD_REQUEST", ex.getMessage());
}
}
private void handleException(HttpServletResponse response, HttpStatus status, String errorCode, String message) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ErrorResponse errorResponse = new ErrorResponse(status.value(), errorCode, message);
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
JWT 토큰 재발급 구현
리프레시 토큰 재발급 API (JwtService)
재발급 API는 다중 환경 로그인 방지와 토큰 로테이션을 적용하여 보안성을 강화합니다.
아래 코드에서는 리프레시 토큰의 유효성을 검사하고, 새로운 액세스 및 리프레시 토큰을 발급하는 과정을 설명합니다.
JwtApi 구현
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/jwt")
public class JwtApi {
private final JwtService jwtService;
@PostMapping
public ResponseEntity<TokenDto> reissue(@RequestBody ReissueDto reissueDto) {
TokenDto tokenDto = jwtService.reissueToken(reissueDto);
return new ResponseEntity<>(tokenDto, HttpStatus.OK);
}
}
JwtService 구현
@Transactional
public TokenDto reissueToken(ReissueDto dto) {
try {
// 리프레시 토큰 카테고리 검증
String category = getCategory(dto.getRefreshToken());
if (!REFRESH_TOKEN.equals(category)) {
throw new IllegalArgumentException("refreshToken 이 아닙니다.");
}
// 사용자 ID와 플랫폼 유형 추출
Long userId = getUserId(dto.getRefreshToken());
PlatformType platformType = getPlatformType(dto.getRefreshToken());
String key = platformType.name() + userId;
// Redis에서 리프레시 토큰 조회
RefreshToken refreshToken = refreshTokenRepository.findById(key)
.orElseThrow(() -> new UnAuthorizedException("유효하지 않은 Refresh Token입니다."));
// Redis에 저장된 리프레시 토큰과 요청된 리프레시 토큰 비교
if (!refreshToken.getRefreshToken().equals(dto.getRefreshToken())) {
// 다른 환경에서 로그인한 경우
refreshTokenRepository.deleteById(key);
throw new UnAuthorizedException("다른 환경에서 로그인한 이력이 있어 재인증이 필요합니다.");
}
// 사용자 활성화 상태 확인
User user = userRepository.findByIdAndStatus(refreshToken.getUserId(), UserStatus.ACTIVATE)
.orElseThrow(() -> {
refreshTokenRepository.deleteById(key);
throw new UnAuthorizedException("승인되지 않은 사용자입니다.");
});
// 새로운 액세스 토큰 및 리프레시 토큰 생성
String newAccessToken = createAccessToken(refreshToken.getUserId(), user.getRole().name(), platformType);
String newRefreshToken = createRefreshToken(refreshToken.getUserId(), user.getRole().name(), platformType);
// Redis에 새 리프레시 토큰 저장
saveRedisRefreshToken(new RefreshToken(
refreshToken.getUserId(),
newRefreshToken,
refreshToken.isRememberMe(),
refreshToken.getPlatformType()
));
return new TokenDto(newAccessToken, newRefreshToken);
} catch (JwtException e) {
throw new UnAuthorizedException("유효하지 않은 토큰입니다.");
}
}
상세 설명
- 리프레시 토큰 카테고리 검증
- 리프레시 토큰은 JWT의 category 클레임을 통해 검증합니다.
- 액세스 토큰과 리프레시 토큰의 혼용을 방지하기 위해서 추가했습니다.
- 다중 환경 로그인 방지
- 리프레시 토큰은 Redis에 저장되며, 각 사용자와 플랫폼 유형(PC, Mobile 등)을 조합한 고유 키를 기반으로 관리됩니다.
- ex) "refreshToken:APP553”
- 요청된 리프레시 토큰과 Redis에 저장된 리프레시 토큰이 다르면, 사용자가 다른 환경에서 로그인했다고 판단하여 Redis에 저장된 모든 토큰을 삭제하고 재인증을 요구합니다.
- 탈취된 토큰으로 인한 부정 로그인을 방지할 수 있습니다.
- 리프레시 토큰은 Redis에 저장되며, 각 사용자와 플랫폼 유형(PC, Mobile 등)을 조합한 고유 키를 기반으로 관리됩니다.
- 사용자 상태 확인
- Redis에 저장된 사용자 ID를 기반으로 활성화 상태를 확인합니다.
- 만약 사용자가 비활성화 상태라면 Redis에 저장된 리프레시 토큰을 삭제하고, 인증을 거부합니다.
- 탈퇴한 사용자나 비활성 계정에 대한 추가적인 보안을 제공합니다.
- 리프레시 토큰 로테이션
- 보안성을 강화하기 위해 새로운 액세스 토큰과 리프레시 토큰을 생성하여 클라이언트에 반환합니다.
- 새로 발급된 리프레시 토큰은 Redis에 다시 저장되며, 기존 토큰은 만료 처리됩니다.
- 토큰 재사용 공격을 방지하고, 항상 최신의 토큰만 사용되도록 보장합니다.
보안 강화 추가 방안 구상
현재 구현은 다중 환경 로그인 방지와 토큰 로테이션을 통해 기본적인 보안을 제공하지만, 추가적인 보안 강화를 위해 다음과 같은 방안을 고려할 수 있습니다
- IP 및 디바이스 정보를 활용한 인증
- 예: 새로운 IP나 디바이스에서 요청이 발생하면 추가 인증(예: 이메일 인증)을 요구.
- 리프레시 토큰을 발급하거나 재발급할 때, 사용자의 IP 주소와 디바이스 정보를 함께 저장하고, 이후 요청 시 이를 비교하여 의심스러운 로그인 시도를 탐지할 수 있습니다.
- 지리적 위치 기반 경고
- 사용자의 위치 정보를 기반으로, 평소 로그인하지 않던 지역에서의 요청이 감지되면 이를 경고하고 추가 인증을 요구할 수 있습니다.
- 2단계 인증(2FA) 적용
- 로그인이나 재발급 요청 시, SMS나 이메일 기반의 2단계 인증을 추가로 적용하여 보안성을 강화할 수 있습니다.
SecurityConfig 설정
위에서 구현한 모든 JWT 필터는 SecurityConfig 클래스에서 관리합니다.
/api/v1/jwt 경로는 토큰 재발급 API로, 액세스 토큰이 만료되었을 때 호출됩니다.
따라서, 이 경로는 인증되지 않은 사용자도 접근할 수 있도록 permitAll()로 설정해야 합니다.
또한, 로그아웃 요청의 경우, permitAll()을 명시적으로 설정하지 않아도 됩니다.
이유는 Spring Security는 기본적으로 LogoutFilter를 인증 필터보다 앞서 배치하므로, 별도의 설정 없이도 로그아웃 요청이 정상적으로 처리됩니다.
코드 구현
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtService jwtService;
private final RefreshTokenRepository refreshTokenRepository;
private final ObjectMapper objectMapper;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/login", "/api/v1/jwt").permitAll()
.anyRequest().authenticated()
)
.addFilterAt(new JwtLoginFilter(authenticationManager(), jwtService, objectMapper), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtRequestFilter(jwtService), JwtLoginFilter.class)
.addFilterAt(new JwtLogoutFilter(jwtService, refreshTokenRepository), LogoutFilter.class)
.addFilterBefore(new JwtExceptionHandlingFilter(objectMapper), JwtLogoutFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return new AuthenticationConfiguration().getAuthenticationManager();
}
}
마무리 하며
이 구현은 JWT 인증 방식의 환경에서 발생할 수 있는 여러 보안 문제를 해결하기 위해 고민하며 설계하였습니다.
특히, 다중 환경 로그인 방지와 토큰 로테이션을 통해 탈취된 토큰으로 인한 부정 사용을 차단하며, Redis를 활용해 별도의 배치 작업 없이 리프레시 토큰 만료를 효율적으로 관리합니다.
추가적으로, IP 검증이나 2단계 인증 같은 보안 강화 기능을 도입하면 더욱 견고한 인증 시스템을 구축할 수 있습니다.
혹시 본문에서 잘못된 내용이나 추가적인 개선이 필요한 부분이 있다면, 댓글로 알려주시면 감사하겠습니다.