안녕하세요.
이번 게시글에선 기존 세션 기반 인증에서 jwt 토큰 인증으로 변환하는 방법을 작성해보겠습니다.
초기 목표는 기존 스프링 시큐리티 디폴트 구조를 크게 벗어나지 않고 약간의 설정 변경정도로 jwt를 구현하는 것이었습니다.
jwt를 프로젝트를 적용하기 위해선 다음과 같은 목표가 필요합니다.
1. 로그인 (로그인 인증)
- username, password를 DB검증 후 토큰을 생성하여 반환해야합니다.
- 리프레시 토큰 정보는 DB에 저장합니다.
2. 토큰 검증 (토큰 인증 및 인가)
- 전달받은 토큰을 인증 및 파싱하여 principal을 가져온 후 (username) 해당 정보로 DB에서 권한을 조회하여 인가합니다.
- 리프레시 토큰이 만료되지 않았다면 리프레시 토큰을 갱신하여 DB에 저장합니다.
try) AbstractAuthenticationProcessingFilter를 상속받아 구현해보기
처음엔 스프링 시큐리티 디폴트 인증 필터인 UsernamePasswordAuthenticatonFilter의 추상클래스인 AbstractAuthenticationProcessingFilter를 상속받아서 위 기능을 구현하는 방법을 고민하였습니다.
AbstractAuthenticationProcessingFilter를 상속받고 기존 방식대로 인증을 통과시킨 후, 마지막에 토큰만 생성해서 보낼 수 있지 않을까라는 생각을 해보았습니다.
/**
* 이렇게만 해도 잘 될 줄 알았음..
*/
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final ObjectMapper objectMapper;
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(ObjectMapper objectMapper, JwtTokenProvider jwtTokenProvider) {
super("/login"); //super()가 필요로하는 로그인 경로
this.objectMapper = objectMapper;
this.jwtTokenProvider = jwtTokenProvider;
}
/**
* 로그인 or 토큰검증로직 내부에서 분기
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String accessToken = this.jwtTokenProvider.resolveAccessTokenByHeader(request);
String refreshToken = this.jwtTokenProvider.resolveRefreshTokenByCookie(request);
UsernamePasswordAuthenticationFilter
//토큰이 둘다 없을 땐 로그인
if (accessToken == null && refreshToken == null) {
EmailPassword emailPassword;
try {
emailPassword = objectMapper.readValue(request.getInputStream(), EmailPassword.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(emailPassword.email, emailPassword.password);
// 기존 방식대로 인증 처리..
Authentication authenticate = this.getAuthenticationManager().authenticate(token);
List<GrantedAuthority> authorities = (List) authenticate.getAuthorities();
TokenDto tokenDto = this.jwtTokenProvider.getTokens(emailPassword.email, authorities);
// 리프레시 토큰 엔티티 생성 및 신규 저장
Token refreshToken = Token.of(tokenDto.getUserEmail(), tokenDto.getRefreshToken());
this.jwtTokenProvider.renewalToken(refreshToken); //리프레시 토큰 리뉴얼..
// 엑세스&리프레시 토큰 쿠키에 저장
this.jwtTokenProvider.addAccessTokenToCookie(response, tokenDto.getAccessToken()); //쿠키저장안됨..
this.jwtTokenProvider.addRefreshTokenToCookie(response, tokenDto.getRefreshToken());
return authenticate;
}
// 토큰검증로직
boolean isAccessTokenValidate = this.jwtTokenProvider.validateAccessToken(accessToken);
boolean isRefreshTokenValidate = this.jwtTokenProvider.validateRefreshToken(refreshToken);
if (!isAccessTokenValidate && isRefreshTokenValidate) { // 리프레시 토큰 유효
refreshToken = this.jwtTokenProvider.renewalRefreshToken(); // 리프레시토큰 갱신
accessToken = this.jwtTokenProvider.getAccessTokenByRefreshToken(refreshToken);
this.jwtTokenProvider.addAccessTokenToCookie(response, accessToken);
} else if (!isRefreshTokenValidate) {
throw new TokenExpiredException();
}
SecurityContextHolder.getContext().getAuthentication(); // 시큐리티에서 인증객체 찾기
return authenticated;
}
}
위의 코드는 AbstractAuthenticationProcessingFilter의 인증 로직을 오버라이딩한 예시힙니다.
요청을 받으면 로그인인지, 토큰 인증요청인지 내부에서 분기하여 처리하는 것을 의도하였습니다.
return 값으로 Authentication 객체를 전달하면 UsernamePasswordAuthenticatonFilter 처럼 작동할 것을 기대했습니다.
특히 아래부분에서는 UsernamePasswordAuthenticatonFilter처럼 AbstractAuthenticationProcessingFilter에 선언된
AuthenticationManager에게 인증요청을 위임하는 부분인데요. 이렇게 하면 기존 로직을 활용할 수 있지 않을까 싶었습니다..
Authentication authenticate = this.getAuthenticationManager().authenticate(token);
하지만 위의 예시는 문제가 많은 코드였습니다..🫠 하나씩 문제점을 찾아보겠습니다!
problem) 1. 세션이 발급된다
위의 예시코드로 로그인 요청은 200 응답으로 에러 발생하지 않고 성공하였습니다.
그러나 계속 세션이 생성된다는 이슈가 존재하였습니다..!
jwt토큰을 사용하는 이유 중 하나가 서버를 세션 없이 stateless하게 가져가고자 하는 것인데.. 이런 결과는 jwt 사용 목적을 무색하게 만들어버렸습니다..
기존에 json 형식으로 요청을 받아 세션으로 처리했을 때 설정해둔 configuration 코드를 수정해줍니다..!
HttpSessionSecurityContextRepository는 세션 기반으로 인증을 처리할 때 사용되는 SecurityContextRepository입니다.
시원하게 지워줍니다..
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
//..
@Bean
public JwtAuthenticationFilter usernamePasswordAuthenticationFilter() {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(objectMapper, jwtTokenProvider);
filter.setAuthenticationManager(authenticationManager(daoAuthenticationProvider()));
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
// 인증이 유효하도록 만들어주는 컨텍스트 -> 이것이 있어야 세션 발급됨
// HttpSessionSecurityContextRepository httpSessionSecurityContextRepository = new HttpSessionSecurityContextRepository();
// filter.setSecurityContextRepository(httpSessionSecurityContextRepository);
return filter;
}
//..
}
해당부분을 주석 처리했고 로그인 결과 세션을 더 이상 생성, 반환하지 않는 것을 확인하였습니다!
그럼 다음 문제를 해결해볼까요..?
problem) 2.로그인 로직만 타고 토큰 요청 인증은 타지 않는다.
이제 로그인 요청 말고 일반적인 토큰 요청에 대한 로직을 검증하려고 합니다.
하지만 어째서인지 로그인 경로로 설정해둔 "/login" 외의 경로엔 작성해둔 JwtAuthenticationFilter를 타지 않는 것이었습니다.
따라서 인증을 처리하지 않고 요청이 이어지므로 "AccessDenied" 예외가 발생하였습니다.
원인은 상속받은 AbstractAuthenticationProcessingFilter 추상클래스에 있었습니다.
해당 추상클래스는 설정해둔 로그인 경로에 대해서만 인증을 처리하는 필터였습니다.
필터 로직 제일 첫 번째에 requiresAuthentication()이라는 메서드에서 해당 요청 경로가 로그인 경로인지 판단하고 로그인 경로가 아니면 필터를 통과시켜 버립니다!
세션 기반 인증 방식인 AbstractAuthenticationProcessingFilter에선 로그인을 통하여 "인증(Authentication)"만 진행하고 이후 인증된 유저에 대한 "인가(Authrization)" 다른 곳에서 진행시킵니다.
AbstractAuthenticationProcessingFilter는 명확한 두가지 기능을 갖고 있음을 알 수 있습니다.
1. 로그인 경로에 대한 인증 처리
2.인증이 처리된 경우 시큐리티 컨텍스트에 인증 객체 저장
- AuthrizationManager에 대한 참고 링크
그럼 jwt 토큰 인증을 위한 필터가 별도로 필요하단 말이 됩니다. 만들어 볼까요?
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { //OncePerRequestFilter -> 한 요청당 한번 필터 실행..
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = this.jwtTokenProvider.resolveAccessTokenByHeader(request);
String refreshToken = this.jwtTokenProvider.resolveRefreshTokenByCookie(request);
if (accessToken == null && refreshToken == null) { //토큰 없는 경우
filterChain.doFilter(request, response);
return;
}
// 토큰검증로직
boolean isAccessTokenValidate = this.jwtTokenProvider.validateAccessToken(accessToken);
boolean isRefreshTokenValidate = this.jwtTokenProvider.validateRefreshToken(refreshToken);
if (isAccessTokenValidate) {
log.info("[엑세스 토큰 검증 성공]");
} else if (isRefreshTokenValidate) { // 리프레시 토큰 유효
refreshToken = this.jwtTokenProvider.renewalRefreshToken(); // 리프레시토큰 갱신
accessToken = this.jwtTokenProvider.getAccessTokenByRefreshToken(refreshToken);
this.jwtTokenProvider.addAccessTokenToCookie(response, accessToken);
log.info("[리프레시 토큰 검증 성공]");
} else if (!isRefreshTokenValidate) {
throw new TokenExpiredException();
}
filterChain.doFilter(request, response);
}
}
cf) OncePerRequestFilter 대신 BasicAuthenticationFilter를 상속받아 jwt토큰 인가 필터를 작성하시는 경우도 있는 것 같습니다. 저는 BasicAuthenticationFilter javadoc에서 아래와 같이 username:password를 base64인코딩한 헤더 토큰인 경우라고 명시했기 때문에 해당 필터를 상속받지 않았습니다.
"Basic 인증 체계와 Base64로 인코딩된 username:password 토큰을 사용하여 Authorization 이라는 HTTP 요청 헤더가 있는 모든 요청을 처리하는 역할을 합니다"
요청 당 한 번 발생하는 OncePerRequestFilter를 상속받아 구현한 후 필터를 등록하였습니다.
이제 로그인 아닌 경우에도 해당 필터가 요청을 받아 토큰 검증을 시도합니다.
accessToken을 발급받아 헤더에 실어 인증 요청을 하면 [엑세스 토큰 검증 성공]이라는 로그가 찍히는 것을 확인하였습니다.
그러나.. 로그만 찍혔다고 문제가 해결된 것은 아니었습니다. AccessDenied 예외가 발생하고 있었습니다..!
problem) 3. 세션이 없으면 시큐리티 컨텍스트에서 인증 객체를 찾을 수 없다.
로그인 성공 후 토큰으로 권한이 필요한 요청을 하면 AccessDenied가 발생하고 있었습니다.
기존 세션방식에서는 @AuthenticationPrincipal을 통하여 UserDetails객체를 가져올 수 있었습니다.
하지만 토큰 인증방식으로 변환 후 UserDetails를 가져오지 못하고 있습니다. 어찌된 일일까요?
@AuthenticationPrincipal을 지정하면 AuthenticationPrincipalArgumentResolver를 통하여 스프링 시큐리티 컨텍스트에서 Authentication 인증객체를 찾아옵니다.
하지만 디버깅 결과 로그인한 인증객체와 무관한 anonymousUser 인증객체가 조회되었습니다!
로그인할 때 컨텍스트에 넣지 않는 것일까요?
로그인 기능을 상속받은 AbstractAuthenticationProcessingFilter을 다시 살펴보았습니다..
AbstractAuthenticationProcessingFilter는 로그인 성공 시 시큐리티 컨텍스트에 인증 객체를 잘 넣고 있었습니다.
원인은 이것이었습니다.
우리는 세션 발급을 중지시키고자 HttpSessionSecurityContextRepository를 주입시키지 않았습니다.
그렇기 때문에 AbstractAuthenticationProcessingFilter의 필드인 securityContextRepository엔 기본으로 생성되는
RequestAttributeSecurityContextRepository가 초기화 되었는데요.
해당 클래스의 javadoc에 힌트가 있었습니다.
"RequestAttributeSecurityContextRepository를 사용할 경우 후속 요청인 경우 SecurityContext를 사용할 수 없다." 라고 표현되어 있습니다!
스프링 시큐리티는 SecurityContextRepository로 RequestAttributeSecurityContextRepository를 사용할 경우 요청이 끝난 후에 시큐리티 컨텍스트를 비워버리는데요.
"세션을 사용하지 않는다" 라는 것이 서버를 stateless 하게 가져간다는 의미이고 이는 곧 스프링 컨텍스트 또한 마찬가지로 stateless 하게 가져간다는 것이기 때문입니다.
세션은 인증이 되었다는 결과를 나타내고 토큰은 인증을 할 수 있는 수단입니다.
따라서 서버가 세션을 갖고 있다는 것은 결과값을 들고 있는 것이므로 이는 곧 stateless하지 않은 것입니다.
우리는 jwt토큰을 사용하여 서버를 stateless하게 가져갈 것이므로
stateless하게 스프링 시큐리티를 사용하는 방법은 토큰 검증 요청 시 해당 요청에 한하여 시큐리티 컨텍스트에 인증 객체를 저장하고 요청이 종료되면 컨텍스트에 인증객체를 지우는 것입니다.
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//..
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//..
// stateless한 서버를 사용한다 -> 요청 마다 토큰 검증하여 시큐리티 컨텍스트에 인증 객체 넣고, 요청 끝나면 삭제
Authentication authentication = this.jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
위처럼 토큰 검증 완료 시 시큐리티 컨텍스트에 인증 객체를 담아줍니다.
인증 객체 삭제는 RequestAttributeSecurityContextRepository에 의해서 자동으로 clear될 것입니다.
최종 테스트 결과 UserDetails를 잘 가져오고 있는 것을 확인하였습니다.
정리
- jwt토큰은 서버를 stateless하게 사용하는 것에 목적이 있으므로 세션을 사용하는 HttpSessionSecurityContextRepository는 사용하지 않는다.
- AbstractAuthenticationProcessingFilter는 로그인에 대한 책임만 갖는다
- 세션을 사용하지 않으면 스프링 시큐리티는 요청마다 컨텍스트를 비운다.
- 이것이 최종적으로 서버를 stateless하게 사용하는 것이므로 토큰 검증 시 요청마다 컨텍스트에 인증 객체를 새롭게 set하고 요청이 끝나면 컨텍스트를 비운다.
- 세션은 인증 결과이고 토큰은 인증 수단이다!
참조)
https://itvillage.tistory.com/60
https://ws-pace.tistory.com/251?category=1025277
https://veluxer62.github.io/tutorials/spring-security-with-jwt/
'백엔드 > Spring Security' 카테고리의 다른 글
스프링 시큐리티 json 형식으로 로그인하기 - 2. 커스텀 구현 (2) | 2024.05.27 |
---|---|
스프링 시큐리티 json 형식으로 로그인하기 - 1. 스프링 시큐리티 UsernamePasswordAuthenticationFilter 분석 (0) | 2024.05.10 |