
들어가며
이번 쇼핑몰 프로젝트에서 백엔드의 보안 관련 로직을 담당하게 되었습니다. Google OAuth 2.0으로 제삼자에게 사용자 인증 역할을 위임하고 인증된 사용자에 대해 애플리케이션 자체의 Access Token과 Refresh Token을 JWT 형식으로 발급해 회원을 관리했습니다.
애플리케이션의 로그인 흐름은 아래 글에 자세히 나와있습니다.
[Spring Boot] Google OAuth 2.0과 JWT를 통한 회원 관리 방식 이해하기
들어가며반려동물 쇼핑몰 프로젝트에서 Google OAuth 2.0과 JWT를 회원 관리 방식으로 채택했습니다. 이미 다른 블로그에 코드에 대한 Reference는 많이 존재하고 있지만 사용자 인증부터 JWT 발급 및 갱
jundyu.tistory.com
액세스 토큰의 만료 시간이 짧기 때문에, 사용자가 페이지에 머무르는 동안 새로운 API 요청이 발생할 때마다 토큰의 유효성을 검사할 필요가 있습니다. 그러나 모든 컨트롤러의 API마다 개별적으로 액세스 토큰의 유효성을 검증하는 것은 유지보수나 가독성 측면에서 비효율적일 수 있습니다. 이에 따라, 모든 API 요청 시 중앙에서 토큰 유효성을 검사할 수 있는 방법을 찾고자 고민하였고, 현업 개발자의 조언을 받아 Security Config의 SecurityFilterChain에 유효성 검증 로직을 추가하게 되었습니다.
이번 글에서는 제가 구현한 JWT 필터가 Access Token을 미들웨어에서 어떻게 검사하는지 설명드리겠습니다.
주요 Class
- SecurityConfig
- 애플리케이션의 보안 설정을 관리하는 클래스입니다.
- JwtAuthenticationFilter
- API 요청 시 우선적으로 헤더에 담긴 Access Token의 유효성을 검사합니다.
- JwtUtil
- JWT 생성 및 검증 등을 관리하는 클래스입니다.
SecurityConfig
// import 문 생략
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
// 기타 필터 생략
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}1. 클래스와 역할
SecurityConfig는 애플리케이션의 보안을 담당하는 클래스입니다. SecurityFilterChain의 마지막에 addFilterBefore 메서드로 커스텀 필터를 추가할 수 있습니다.
2. addFilterBefore
addFilterBefore 메서드에 커스텀 필터를 추가할 땐 메서드 이름에 걸맞게 이미 존재하는 UsernamePasswordAuthenticationFilter라는 필터 앞에 작성해야합니다. 아래의 사진은 addFilterBefore의 표준 라이브러리입니다.

첫 번째 인자로는 Filter 타입의 커스텀 필터 전달하고, 두 번째 인자로는 Filter 인터페이스를 구현한 기 필터 클래스 타입을 전달합니다. 말이 조금 어려우니 예를 들어 설명해보겠습니다. 위의 예시 코드에선 Spring Security에 이미 존재하는 UsernamePasswordAuthenticationFilter를 기존 필터로 지정했고, 이 필터 앞에 커스텀 필터인 JwtAuthenticationFilter가 실행되도록 설정했습니다.
추가로 저는 쇼핑몰 프로젝트에서 폼 로그인을 지원하지 않기 때문에 httpBasic을 비활성화하고, UsernamePasswordAuthenticationFilter를 명시적으로 사용하지 않도록 설정했습니다. 따라서 API 요청이 발생할 때 JwtAuthenticationFilter만 작동하게 됩니다.
JwtAuthenticationFilter
// import 문 생략
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = jwtUtil.resolveAccessToken(request);
Claims claims = jwtUtil.validateToken(token);
if (token != null && claims != null) {
request.setAttribute("userId", claims.getSubject());
Authentication authentication = new UsernamePasswordAuthenticationToken(
claims.getSubject(),
null,
null
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(request, response);
}
}1. 클래스와 역할
JwtAuthenticationFilter는 API 요청 시 헤더에 포함된 Access Token을 중간에 가로채서 토큰의 유효성 검사를 진행하는 역할입니다. 이 필터는 OncePerRequestFilter라는 필터를 상속 받고 있습니다. OncePerRequestFilter는 API 요청 발생 시 단 1회만 필터가 작동하도록 보장하는 필터입니다. API 요청 시 Access Token의 유효성 검사는 1회만 필요하기 때문에 OncePerRequestFilter를 추가했습니다.
2. doFilterInternal의 매개변수
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
) throws ServletException, IOException {HttpServletRequest 타입의 request 객체와 HttpServletResponse 타입의 response 객체를 선언했습니다. 요청의 헤더에 담긴 액세스 토큰을 가져오기 위해 request를 사용하고, Access Token이 만료된 경우에 response를 사용합니다. FilterChain은 Servlet API에서 제공하는 인터페이스로 필터 체인 내에서 필터를 관리하고 흐름을 제어합니다. FilterChain은 doFilter 메서드를 통해 request와 response를 다음 필터에게 전달합니다.
3. Access Token 검사
String token = jwtUtil.resolveAccessToken(request);
Claims claims = jwtUtil.validateToken(token);JwtUtil에 정의된 메서드는 아래에서 자세히 자세히 확인할 수 있습니다. resolveAccessToken은 요청에 담긴 헤더에서 Access Token만 추출하는 메서드입니다. 그리고 validateToken은 추출한 Access Token의 유효성을 검사한 뒤 Claim을 추출하는 메서드입니다.
4. User의 인증 상태 설정
if (token != null && claims != null) {
request.setAttribute("userId", claims.getSubject());
Authentication authentication = new UsernamePasswordAuthenticationToken(
claims.getSubject(), // principal
null, // credentials
null // authorities
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}만약 token과 claims 값이 전부 null이 아닌 경우 유효한 사용자라고 판단해서 사용자의 아이디를 request의 속성에 추가로 저장합니다. 저는 Access Token의 subject에 사용자의 아이디를 저장해뒀기 때문에 위와 같은 로직을 구현할 수 있었습니다.
TIP. request의 속성에 추가한 값은 컨트롤러에서 어떻게 사용할까?
컨트롤러의 인자에 @RequestVariable 어노테이션으로 request에 포함된 변수를 가져올 수 있습니다.
Authentication는 Spring Security에서 사용자의 인증 정보를 담는 핵심 객체입니다. 아래는 객체의 주요 구성 요소입니다.
- principal
- 사용자에 대한 고유한 식별자를 저장
- credentials
- 사용자의 자격 증명 정보로 보통 비밀번호를 저장
- 인증이 완료되면 null로 초기화
- authorities
- 사용자의 권한 목록을 저장
위에서 말했듯이 저는 JWT 기반으로 사용자를 관리하고 있기 때문에, Authentication 객체의 요소들을 모두 null로 설정해도 무관합니다. 그러나, 파싱된 토큰 값을 다른 메서드에서도 간편하게 재사용하기 위해 principal에는 사용자의 ID 값을 저장해 인증된 사용자 정보를 쉽게 참조할 수 있도록 했습니다.
5. doFilter
filterChain.doFilter(request, response);filterChain 인터페이스의 doFilter는 다음 필터 체인에게 변경된 request와 response를 전달합니다. doFilter를 통해 필터 체인의 흐름이 제어됩니다.
JwtUtil
// import 문 생략
@Component
public class JwtUtil {
@Value("${jwt.jwt-key}")
private String jwtSecretKey;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(jwtSecretKey.getBytes());
}
public String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
public Claims validateToken(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (JwtException | IllegalArgumentException e) {
return null;
}
}
}1. resolveAccessToken
HttpServletRequest 타입의 request 객체는 요청 헤더의 모든 값을 가지고 있습니다. 따라서 Authorization에 저장된 Access Token 값을 따로 추출하는 메서드를 만들었습니다. 이 메서드는 JWT를 String 값으로 반환합니다.
2. validateToken
String 값의 토큰을 검증한 뒤 사용자의 정보가 담긴 Claim을 반환합니다.
3. getSigningKey
JWT를 생성할 때 사용되는 서명 키 객체를 생성합니다. jwtSecretKey를 바이트 배열로 변환해 Keys.hmacShaKeyFor 메서드로 서명 키를 만듭니다. 이 키는 JWT를 서명하거나 검증할 때 사용됩니다.
.
.
.
마치며
이번에 미들웨어라는 개념도 처음 알았고, 미들웨어에서 Access Token의 유효성을 검사할 뿐만아니라 사용자의 아이디를 요청 값에 추가해서 보안을 한층 더 높였습니다. 클라이언트 측에서도 api 요청 시 따로 사용자의 아이디를 전달할 필요가 없어 편해진 점도 있습니다.
이번 프로젝트에서 보안 관련해서 많이 배워갑니다.. 다 배웠다고 생각해도 몰랐단 정보가 자꾸만 생기네요. 다음 프로젝트에서 또 맡게 된다면 폼 로그인 기능과 더 많은 소셜 로그인을 추가해서 Spring Security의 기능을 제대로 활용해보고 싶습니다.
피드백이나 궁금한 점은 언제나 환영합니다.