스프링 시큐리티 흐름
1. 사용자의 인증 요청이 들어오면 우선 DelegatingFilterProxy에서 SecurityFilterChain으로 요청을 전달해 준다.
2. 이후 사용자의 요청들이 필터들을 타고 들어가서 인증 or 인가 과정을 거치게 된다.
3. WebSecurity의 HttpSecurity를 통해서 적용되는 필터들을 Custom 해줄 수 있다.
코드 예시
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(FormLoginConfigurer::disable)
.httpBasic(HttpBasicConfigurer::disable)
return http.build();
}
위 코드처럼 filterChain을 정의해 준 뒤 bean으로 등록하면 내가 원하는 설정대로 필터를 적용하거나 바꿀 수 있다.
JWT 적용 시점
UsernamePasswordAuthenticationFilter 이 적용되기 이전에 JWT 토큰 필터를 적용해준다.
JWT의 인증하는 흐름을 살펴보면 크게 두 가지로 나눠진다.
- 첫 로그인
- JSON 바디에서 유저 정보를 가져옴
- AuthenticationManager를 이용하여 유저 정보가 올바른지 확인
- 토큰 발급
- 토큰을 가지고 요청
- 올바른 토큰인지 체크 (expired, wrong...)
- 토큰에서 authentication 추출 후 SecurityContextHolder에 등록
첫 로그인
@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JWTUtil jwtUtil;
/*
인증 처리 위임 로직 구현
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(request.getInputStream());
String email = jsonNode.get("email").asText();
String password = jsonNode.get("password").asText();
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(email, password, null);
return authenticationManagerBuilder.getObject().authenticate(token);
} catch (IOException e) {
log.info("로그인 오류");
throw new RuntimeException(e);
}
}
//인증 성공시 호출
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공");
String accessToken = jwtUtil.createJwt(authResult);
response.setHeader("Authorization", "Bearer " + accessToken);
chain.doFilter(request, response);
}
//인증 실패 시 호출
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
}
}
public String createJwt(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)//GrantedAuthority 클래스 내의 getAuthority를 호출하여 이를 스트링 타입으로 변환
.collect(Collectors.joining(","));
long time = System.currentTimeMillis();
return Jwts
.builder()
.signWith(secretKey)
.subject(authentication.getName())
.claim("auth", authorities)
.issuedAt(new Date(time))
.expiration(new Date(time + expiredTime))
.compact();
}
1. JSON 바디로 넘어오는 유저의 정보를 추출하기 위해 LoginFilter를 구현해 준다.
2. 이때 UsernamePasswordAuthenticationFilter를 상속받아서 구현하는데, 다음과 같은 이유가 있다.
- UsernamePasswordAuthenticationFilter가 /login 엔드포인트 기본 담당 처리를 하기 때문에
- 기존 인프라 활용 (AuthenticationManager, AuthenticationProvider 등 다양한 인증 관련 콜백 메서드들이랑 통합되어 있음.
3. 유저 정보를 바탕으로 UsernamePasswordAuthenticationToken을 만들어서 AuthenticationManager에게 전달
4. 유저 정보가 올바르면 JWT 토큰을 생성하여 헤더에 Authorization에 넣어준다.
토큰을 가지고 API 호출
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authorization.split(" ")[1];
if (jwtUtil.isExpired(token)) {
log.info("토큰이 만료되었습니다.");
filterChain.doFilter(request, response);
return;
}
Authentication authentication = jwtUtil.getAuthenticationFromToken(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
1. JWTFilter에서는 Authorization 헤더에 들어있는 토큰을 가져와 올바른 토큰인지 검증한다.
2. 올바른 토큰이면 SecurityContextHolder에 authentication을 등록
3. 이때 OncePerRequestFilter를 상속하여 구현했다. (GenericFilterBean으로도 구현할 수 있음)
OncePerRequestFilter vs GenericFilterBean
- servlet에 요청이 들어오면 서블릿 객체를 생성해 메모리에 저장해 두었다가 같은 클라이언트에게 요청이 다시 오면 메모리에 저장되어 있는 서블릿 객체를 사용한다.
- 근데 서블릿이 다른 서블릿으로 dispatch 되는 경우 (다른 api로 redirect) 필터를 한 번 더 타게 된다. 이를 방지하기 위해 OncePerRequesFilter를 사용한다.
- OncePerRequestFilter의 내부 코드를 보면 이미 인증한 서블릿 객체이면 한 번 더 인증을 하지 않도록 코드가 작성되어 있다.
예외처리
@Component
@RequiredArgsConstructor
public class CustomFilterExceptionHandler 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 (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
setErrorResponse(response, JwtExceptionCode.WRONG_TOKEN);
} catch (ExpiredJwtException e) {
setErrorResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
} catch (UnsupportedJwtException e) {
setErrorResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
} catch (IllegalArgumentException e) {
setErrorResponse(response, JwtExceptionCode.ILLEGAL_TOKEN);
}
}
private void setErrorResponse(HttpServletResponse response, JwtExceptionCode code) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ApiUtil.ApiErrorResult<String> error = ApiUtil.error(code.getCode(), code.getMessage());
try {
response.getWriter().write(objectMapper.writeValueAsString(error));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
1. 예외처리는 JWTFilter를 타기 전에 CustomFilterExceptionHandler를 등록해 주어서 try-catch 구문으로 처리해 주었다.
(CustomFilterExceptionHandler -> JWTFilter -> 예외 터지면 -> catch에서 잡힘)
2. 오류 내용을 json 형식으로 변환하여 클라이언트에 반환
다음과 같이 응답 생성
SecurityFilterChain에 등록
이제 위에서 생성한 필터들을 SecurityFilterChain에 등록해 보자
http
.addFilterBefore(customFilterExceptionHandler, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new LoginFilter(authenticationManagerBuilder, jwtUtil), UsernamePasswordAuthenticationFilter.class);
1. 위에서부터 등록한 순서대로 필터가 적용이 된다. 따라서 예외처리 필터를 제일 앞단에 두어 예외 핸들링을 할 수 있도록 설정해 주었다.
마무리
1. 정리해보면 JWT토큰 요청은 크게 2가지로 나눠진다.
2. 첫 로그인과 토큰을 가지고 요청하는 경우
3. 첫 로그인은 LoginFilter를 이용하여 구현하였고, 토큰을 가지고 요청하는 경우는 JWTFilter를 이용하여 구현하였다.
4. 예외처리는 JWTFilter 앞단에 두어 try-catch 구문을 이용하여 에러 핸들링을 해준다.
5. SecurityFilterChain에 등록 순서 중요!
'Java, Spring' 카테고리의 다른 글
[트러블 슈팅] docker-compose로 브릿지 네트워크 구성 (0) | 2024.08.26 |
---|---|
Java Garbage Collection (0) | 2024.08.08 |
AOP를 이용한 분산락 구현 (0) | 2024.07.21 |
Spring AOP에 대해서 알아보자 (3) | 2024.07.13 |
무신사 블프 이벤트 상품 동시성 문제 (2) (0) | 2024.07.10 |