JWT 토큰의 가장 큰 취약점은 Access Token 탈취시 유효기간이 만료하기 전에 만료시킬 방법이 없고 마음껏 악용할 수 있다는 것이었습니다.
이 문제를 보완하기 위해, Access Token의 생명주기를 짧게 가져가고 Refresh Token을 서버에서 관리하면서 Refresh Token이 유효하면 Access Token을 재발급해주는 전략을 취하기로 결정하였습니다.
지금부터 하나씩 시작해보겠습니다.
JwtUtil 클래스는 Access Token과 Refresh Token을 발급하고, 검증하는 클래스입니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtUtil {
private final JwtProperties jwtProperties;
private final RefreshTokenService tokenService;
private String secretKey;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(jwtProperties.getSecret().getBytes());
}
public GeneratedToken generateToken(String email, String role) {
// refreshToken과 accessToken을 생성한다.
String refreshToken = generateRefreshToken(email, role);
String accessToken = generateAccessToken(email, role);
// 토큰을 Redis에 저장한다.
tokenService.saveTokenInfo(email, refreshToken, accessToken);
return new GeneratedToken(accessToken, refreshToken);
}
public String generateRefreshToken(String email, String role) {
// 토큰의 유효 기간을 밀리초 단위로 설정.
long refreshPeriod = 1000L * 60L * 60L * 24L * 14; // 2주
// 새로운 클레임 객체를 생성하고, 이메일과 역할(권한)을 셋팅
Claims claims = Jwts.claims().setSubject(email);
claims.put("role", role);
// 현재 시간과 날짜를 가져온다.
Date now = new Date();
return Jwts.builder()
// Payload를 구성하는 속성들을 정의한다.
.setClaims(claims)
// 발행일자를 넣는다.
.setIssuedAt(now)
// 토큰의 만료일시를 설정한다.
.setExpiration(new Date(now.getTime() + refreshPeriod))
// 지정된 서명 알고리즘과 비밀 키를 사용하여 토큰을 서명한다.
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public String generateAccessToken(String email, String role) {
long tokenPeriod = 1000L * 60L * 30L; // 30분
Claims claims = Jwts.claims().setSubject(email);
claims.put("role", role);
Date now = new Date();
return
Jwts.builder()
// Payload를 구성하는 속성들을 정의한다.
.setClaims(claims)
// 발행일자를 넣는다.
.setIssuedAt(now)
// 토큰의 만료일시를 설정한다.
.setExpiration(new Date(now.getTime() + tokenPeriod))
// 지정된 서명 알고리즘과 비밀 키를 사용하여 토큰을 서명한다.
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public boolean verifyToken(String token) {
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey) // 비밀키를 설정하여 파싱한다.
.parseClaimsJws(token); // 주어진 토큰을 파싱하여 Claims 객체를 얻는다.
// 토큰의 만료 시간과 현재 시간비교
return claims.getBody()
.getExpiration()
.after(new Date()); // 만료 시간이 현재 시간 이후인지 확인하여 유효성 검사 결과를 반환
} catch (Exception e) {
return false;
}
}
// 토큰에서 Email을 추출한다.
public String getUid(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// 토큰에서 ROLE(권한)만 추출한다.
public String getRole(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get("role", String.class);
}
}
이 부분도 한줄씩 뜯어서 설명해드리겠습니다.
JwtProperties로 부터 secret을 읽어와서 Base64로 인코딩한 값을 secretKey 변수에 할당시킵니다.
secretKey는 토큰을 생성하거나 토큰을 파싱할때 사용되는 정보로서 보안에 유의해야 합니다.
Github와 같이 모두가 볼 수 있는 public한 공간에 secretkey가 올라가지 않도록 application-app.yml 파일에 secret 값을 분리하고, JwtProperties 클래스에 값을 바인딩 시켜주었습니다.
JWT Secret은 임의의 문자열을 대부분 사용합니다.
문자열을 직접 생성하지 않고 편하게 생성하려면 리눅스 환경에서 아래 명령어를 입력해 사용할 수 있습니다.
openssl rand -hex 64
최초 로그인시 AccessToken과 RefreshToken을 발급하는 부분
최초 로그인시 AccessToken과 RefreshToken을 발급하고, 발급한 토큰들을 Redis에 저장하는 부분입니다. Redis 설치와 토큰 저장과 재발급 부분은 다음 게시글에 포스팅 하겠습니다.
RefreshToken 발급
RefreshToken의 생명주기를 저희 팀은 2주로 결정하였습니다.
따라서 밀리초 단위로 토큰의 유효기간을 변수에 담아주고, 이메일과 회원의 권한을 Claims 객체에 담아주었습니다.
Jwts Builder를 사용하여, 클레임들과 발행일자 그리고 비밀키와 서명 알고리즘을 지정하여 토큰을 발행합니다.
AccessToken은 30분으로 정책을 설정하였습니다.
RefreshToken과 전체적인 로직은 동일하고, 만료시간만 같기 때문에 따로 설명하지 않고 넘어가겠습니다.
AccessToken이나 RefreshToken이 현재 유효한지 확인하는 메서드입니다.
이 메서드는 5가지 종류의 예외를 발생시킬 수 있습니다.
따라서 예외의 최고 조상인 Exception을 이용하여 예외를 Catch 블럭에서 잡아서 false를 반환하도록 했습니다.
토큰이 유효하지 않다는 예외는 JWT 토큰을 검증하는 JwtAuthFilter에서 발생시키도록 할 것이기 때문에 boolean 값으로 토큰이 유효한지 여부를 반환하도록 했습니다.
토큰에서 Email과 권한만 추출하는 간단한 유틸성 메서드도 제작해두었습니다.
SecurityConfig 클래스에서 UsernamePasswordAuthenticationFilter 앞에 실행되도록 지정했던 JwtAuthFilter 클래스 코드를 보겠습니다.
코드를 보기전에 UsernamePasswordAuthenticationFilter보다 JwtAuthFilter가 왜 앞에 있어야 할까요?
내가 요청을 보냈을때 Security Filter들이 어떤 순서로 호출되는지 보고 싶다면 Security 설정 클래스에서 디버그 옵션을 켜주면 됩니다.
현재 아래와 같은 순서로 필터들이 실행되고 있는 것을 확인할 수 있습니다. UsernamePasswordAuthenticationFilter는 어디에도 존재하지 않습니다. 왜 그런것일까요?
현재 방식은 JWT 토큰을 이용한 방식으로, 전통적인 form 로그인 방식과 다르기때문에 UsernamePasswordAuthenticationFilter는 filter 목록에서도 빠져있습니다. OAuth2 Login 기능을 활성화하게 되면, UsernamePasswordAuthenticationFilter 대신 같은 부모 AbstractAuthenticationProcessingFilter 를 상속한 OAuth2LoginAuthenticationFilter가 Security filter chain에 등록되게 됩니다.
JWT 토큰을 검사하는 JwtAuthFilter는 실질적인 인증을 수행하는 LogoutFilter 뒤에 위치하면 예상한대로 동작합니다.
그렇다면 JwtAuthFilter를 LogoutFilter 전에 두면 어떻게 될까요?
로그인 페이지조차 제대로 나오지 않는 것을 볼 수 있습니다.
@RequiredArgsConstructor
@Slf4j
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final MemberRepository memberRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request Header에서 AccessToken을 가져온다.
String atc = request.getHeader("Authorization");
// 토큰 검사 생략(모두 허용 URL의 경우 토큰 검사 통과)
if (!StringUtils.hasText(atc)) {
doFilter(request, response, filterChain);
return;
}
// AccessToken을 검증하고, 만료되었을경우 예외를 발생시킨다.
if (!jwtUtil.verifyToken(atc)) {
throw new JwtException("Access Token 만료!");
}
// AccessToken의 값이 있고, 유효한 경우에 진행한다.
if (jwtUtil.verifyToken(atc)) {
// AccessToken 내부의 payload에 있는 email로 user를 조회한다. 없다면 예외를 발생시킨다 -> 정상 케이스가 아님
Member findMember = memberRepository.findByEmail(jwtUtil.getUid(atc))
.orElseThrow(IllegalStateException::new);
// SecurityContext에 등록할 User 객체를 만들어준다.
SecurityUserDto userDto = SecurityUserDto.builder()
.memberNo(findMember.getMemberNo())
.email(findMember.getEmail())
.role("ROLE_".concat(findMember.getUserRole()))
.nickname(findMember.getNickname())
.build();
// SecurityContext에 인증 객체를 등록해준다.
Authentication auth = getAuthentication(userDto);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
public Authentication getAuthentication(SecurityUserDto member) {
return new UsernamePasswordAuthenticationToken(member, "",
List.of(new SimpleGrantedAuthority(member.getRole())));
}
}
Request Header에서 AccessToken 값을 빼서 변수에 저장해줍니다.
Access Token의 값이 비어있을 경우, 더 이상 검증을 진행하지 않고 통과시켜 줍니다. 왜 이렇게 처리했을까요?
permitAll( )로 설정을 해주더라도, JwtAuthFilter는 매 요청마다 인증을 수행하게 되어있습니다.
토큰의 값이 비어 있을때 바로 다음 filter로 그냥 보내주지 않으면 토큰 검증에서 예외가 발생하여 다른 페이지로 리디렉션 되게 됩니다.
이 방법 이외에도 shouldNotFilter 메서드를 오버라이딩해서 인증을 수행하지 않을 URL을 매칭시킬 수 있는데, 이 방법은 URL List를 순회하면서 하나씩 비교해야 하는 리소스가 들어가기 때문에 사용하지 않았습니다.
토큰 검증시 유효하지 않을경우 발생하는 CustomException인 JwtException의 코드는 아래와 같습니다.
토큰이 유효하다면, 토큰에서 사용자 이메일을 뽑아서 회원정보를 조회해줍니다.
조회한 회원정보로 SecurityUserDto를 만들고 Authentication 객체로 변환한 후 Security Context에 넣어줍니다.
현재 Session 관리 전략이 Stateless 입니다. 따라서, Session 인증 매커니즘을 아예 사용하지 않습니다.
SecurityContextPersistenceFilter 는 세션 존재 여부를 무시하고 항상 새로운 SecurityContext 객체를 생성하기 때문에 인증성공 당시 SecurityContext 에 저장했던 Authentication 객체를 더 이상 참조 할 수 없게 되어 버립니다.
위와 같은 이유에서, 매 인증마다 SecuritContext에 Authentication 객체를 넣어줘야 합니다.
UsernamePasswordAuthenticationToken 클래스는 AbstractAuthenticationToken의 구현 클래스이며, AbstractAuthenticationToken은 Authentication 인터페이스의 구현체이므로 다형성에 의해 Authentication 타입으로 반환이 가능합니다.
SecurityUserDto는 UsernamePasswordAuthenticationToken 클래스 내부의 principal에 셋팅됩니다.
따라서 SecurityUserDto를 얻어 오려면 아래와 같은 방법으로 얻어와야 합니다.
지금까지 JwtAuthFilter의 코드를 살펴 보았습니다.
현재 JwtAuthFilter는 OncePerRequestFilter를 상속받고 있습니다. 하지만 이런 작업을 수행하는 filter는 GenericFilterBean을 상속받는 것으로도 기능을 수행할 수 있습니다.
왜 굳이 OncePerRequestFilter를 상속 받아야 할까요?
Servlet은 다른 Servlet으로 dispatch 되는 경우가 있습니다. GenericFilterBean을 상속 받아서 JWT 인증을 수행하게 되면, 이미 인증이 수행되었는데도 불구하고 다시 앞에서 거쳐왔던 filter를 다시 거쳐야하는 불필요한 자원낭비가 일어날 수 있습니다.
OncePerRequestFilter는 어느 서블릿 컨테이너에서나 요청 당 한 번의 실행을 보장하는 것을 목표로 만들어졌습니다.
따라서 다른 Servlet으로 dispatch 되는 과정을 겪더라 한 번만 수행되는 것을 보장하기 때문에 사용합니다.
마지막으로 JwtExceptionFilter를 보겠습니다.
JwtExceptionFilter를 만들게 된 이유가 있었습니다. 아직까지 Spring의 예외처리 구조와 Servlet 예외의 차이를 몰랐던 차이였던 것 같습니다.
@RestControllerAdvice를 이용해서 Controller에서 발생하는 모든 예외를 전역적으로 처리하고 있었기때문에, RuntimeException에 해당하는 JwtException 또한 핸들링 될거라 생각했습니다.
하지만 프론트 팀원으로부터 예외처리가 되고 있지 않다는 청천벽력(?) 같은 말을 듣고 천천히 찾아보게 되었습니다.
ControllerAdvice나 RestControllerAdvice와 같이 전역적으로 예외를 처리해주는 방법으로는 filter의 예외를 컨트롤 해줄 수 없습니다.
filter는 DispatcherServlet 앞에 존재하고, Spring 에서 예외를 처리해주는 HandlerInterceptor는 Spring Context 안에 존재하기 때문에 filter에서 던지는 예외는 처리할 수 없습니다.
이런 구조를 몰랐기 때문에 한 실수였습니다.
이제 JwtExceptionFilter의 코드를 보겠습니다.
@Component
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), StatusResponseDto.addStatus(401));
}
}
}
현재 JwtAuthFilter 앞에 JwtExceptionFilter를 위치 시켰습니다. 왜 예외를 핸들링하는 filter가 JwtAuthFilter 앞에 위치할까요?
코드를 보면서 설명드리겠습니다.
doFilter( ) 메서드는 현재 필터의 다음 필터를 진행시키는 메서드입니다.
따라서 doFilter 메서드를 호출하게 되면, JwtAuthFilter로 이동해서 Jwt 인증을 수행하게 됩니다.
JwtAuthFilter의 입장에서 보면 doFilter 메서드로 JwtExceptionFilter로 부터 호출 된 것입니다. 따라서 Java에서 메서드 내에서 예외를 던질경우 자신을 호출한 쪽으로 던지게 되어있습니다.
따라서 JwtAuthFilter에서 JwtException을 던지게되면, JwtAuthFilter를 호출한 JwtExceptionFilter의
try ~ catch 블럭에서 예외를 잡아서 처리해주게 되는 것입니다.
catch 블럭 안에서는 요청이 JWT 토큰이 문제가 있다는 것을 클라이언트에게 알려줘야 하기 때문에 401번 상태코드와 메시지를 보내주는 로직이 들어있습니다.
토큰이 유효하지 않을 경우 아래와 같이 응답하게 됩니다.
응답할때 사용한 StatusResponse 객체의 코드는 아래에 남겨두도록 하겠습니다.
@Getter
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) // DTO 를 JSON으로 변환 시 null값인 field 제외
public class StatusResponseDto {
private Integer status;
private Object data;
public StatusResponseDto(Integer status) {
this.status = status;
}
public static StatusResponseDto addStatus(Integer status) {
return new StatusResponseDto(status);
}
public static StatusResponseDto success(){
return new StatusResponseDto(200);
}
public static StatusResponseDto success(Object data){
return new StatusResponseDto(200, data);
}
}
JwtExceptionFilter 또한 JwtAuthFilter와 같은 이유로 OncePerRequestFilter를 상속받는 방법을 선택하였습니다.
SecurityConfig 클래스의 일부 수정된 부분이 있어 다시 한번 코드를 보여드리겠습니다.
@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
@Profile("local") // Profile이 local인 경우에만 설정이 동작한다.
public class SecurityConfig {
private final MyAuthenticationSuccessHandler oAuth2LoginSuccessHandler;
private final CustomOAuth2UserService customOAuth2UserService;
private final JwtAuthFilter jwtAuthFilter;
private final MyAuthenticationFailureHandler oAuth2LoginFailureHandler;
private final JwtExceptionFilter jwtExceptionFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // HTTP 기본 인증을 비활성화
.cors().and() // CORS 활성화
.csrf().disable() // CSRF 보호 기능 비활성화
.formLogin().disable() // form 로그인 비활성화
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션관리 정책을 STATELESS(세션이 있으면 쓰지도 않고, 없으면 만들지도 않는다)
.and()
.authorizeRequests() // 요청에 대한 인증 설정
.antMatchers("/token/**").permitAll() // 토큰 발급을 위한 경로는 모두 허용
.anyRequest().authenticated() // 그 외의 모든 요청은 인증이 필요하다.
.and()
.oauth2Login() // OAuth2 로그인 설정시작
.userInfoEndpoint().userService(customOAuth2UserService) // OAuth2 로그인시 사용자 정보를 가져오는 엔드포인트와 사용자 서비스를 설정
.and()
.failureHandler(oAuth2LoginFailureHandler) // OAuth2 로그인 실패시 처리할 핸들러를 지정해준다.
.successHandler(oAuth2LoginSuccessHandler); // OAuth2 로그인 성공시 처리할 핸들러를 지정해준다.
// JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가한다.
return http
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class)
.build();
}
}
벌써 4번째 포스팅이 끝났습니다.
아마 다음번 포스팅이 마지막이 될 거 같습니다.
지금껏 함께 달려주셨는데 마지막 게시글에서 가장 핵심이 되는 Redis를 이용한 RefreshToken 관리를 진행할 예정이니 끝까지 함께 해주셨으면 좋겠습니다.
읽어주셔서 감사합니다!
다음 시리즈 게시물로 이동
OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - Refresh Token 재발급
게시물로 이동 ->
안녕하세요! 잘보고 있습니다. 다름이 아니라 깃허브 코드는 없는걸까요??ㅜㅜ 레포를 찾아봤는데 안보여서요..