[spring] spring security를 이용한 JWT 로그인
기존에는 세션/쿠키 방식으로 로그인을 구현하였지만 다중 서버환경에서의 서버간 세션공유문제, 앱으로 확장했을때 clinet의 환경 차이,
Front-end에서 로컬환경으로 테스트할때 Back-end 서버와의 쿠키공유 불가 등 여러 문제점들로 인해 다른 방식의 로그인 구현이 요구되었다.
물론 다중 서버환경의 경우 세션클러스팅이나 세션키값을 DB에 저장하는 방식으로 문제 해결이 가능하고,
앱으로의 확장 문제도 client단에서 어느정도 해결가능한 문제였지만
직면한 문제들을 좀 더 효율적으로 해결할 수 있는 방법은 JWT를 기반으로 로그인을 구현하는 것이라 생각하였다.
따라서 JWT로그인을 구현하게 되었고 인증/인가에서 많은 부분 도움을 주는 spring security를 함께 사용하였다.
먼저 개략적인 흐름을 파악하기 위해서 SecurityConfig부터 살펴보자
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserRepository userRepository;
private final CorsConfig corsConfig;
private final AuthenticationConfiguration authenticationConfiguration;
private final AccessDeniedHandler accessDeniedHandler;
private final AuthenticationEntryPoint authenticationEntryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.addFilter(corsConfig.corsFilter())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.addFilter(jwtAuthenticationFilter())
.addFilter(jwtAuthorizationFilter())
.addFilterBefore(exceptionHandlerFilter(), LogoutFilter.class)
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
.and()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/user/**")
.access("hasRole('PARENT') or hasRole('TEACHER') or hasRole('DIRECTOR')")
.antMatchers("/parent/**")
.access("hasRole('PARENT')")
.anyRequest().permitAll();
return http.build();
}
@Bean ExceptionHandlerFilter exceptionHandlerFilter() {
return new ExceptionHandlerFilter();
}
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler() {
return new CustomAuthenticationSuccessHandler();
}
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return new CustomAuthenticationFailureHandler();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManagerBean());
jwtAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
return jwtAuthenticationFilter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() throws Exception {
return new JwtAuthorizationFilter(authenticationManagerBean(), userRepository);
}
}
기존에는 WebSecurityConfigurerAdapter를 상속받아 SecurityConfig를 구현하였지만 최근에 WebSecurityConfigurerAdapter가 deprecated되면서
WebSecurityConfigurerAdapter의 메소드들을 override하는 방식이 아닌 @Bean을 통한 빈 등록을 이용해 구현할 수 있게 되었다.
먼저 SecurityFilterChain를 반환하는 filterChain메소드를 살펴보자
filterChain 메소드에서는 HttpSecurity를 통해 spring security의 각종 설정들을 할 수 있다.
http.csrf().disable();
csrf().disable()를 통해 SpringSecurity에 default로 적용되어있는 csrf protection를 해제한다.
이유는 현재 서버가 rest api 서버이고 인증정보로 JWT를 사용할 것이기 때문에 서버에 인증정보를 저장하지 않으므로 굳이 사용할 이유가 없다.
http.addFilter(corsConfig.corsFilter())
corsConfig.corsFilter를 추가하는 이유는 뒤에서 자세히 설명하겠지만 cors설정을 위한것이라고 생각하고 넘어가자
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
JWT를 사용할 것이기 때문에 STATELESS 즉, 세션을 사용하지 않겠다는 설정이다.
.formLogin().disable()
.httpBasic().disable()
formLogin().disable()은 Spring Security가 기본적으로 제공하는 formLogin 기능을 사용하지 않겠다는것을 나타낸다.
httpBasic().disable()은 매 요청마다 id, pwd를 보내는 방식으로 인증하는 httpBasic를 사용하지 않겠다는것을 나타낸다.
.addFilter(jwtAuthenticationFilter())
.addFilter(jwtAuthorizationFilter())
다음은 jwtAuthenticationFilter와 jwtAuthorizationFilter를 추가하는 부분이다.
두 필터 모두 Spring Security에서 제공하는 flter를 상속받아 구현한것으로 구현 코드는 뒤에서 자세히 다루도록 하겠다.
.addFilterBefore(exceptionHandlerFilter(), LogoutFilter.class)
exceptionHandelrFilter는 filter에서 터지는 exception들을 효율적으로 관리하기 위해 만든 filter이다.
ExceptionResolver를 호출하는 DispatcherServlet 앞단에 filter들이 위치하기 때문에
filter들의 exception들을 처리할 수 있는 별도의 장치가 필요해 보여 구현하게 되었다.
security에서 적용되는 filter중에 거의 제일 앞에 있는 LogoutFilter 앞에 적용시켜
filter에서 터지는 거의 모든 exception를 관리할 수 있도록 하였다.
(spring security를 지속적으로 공부하면서 알게된 사실이지만 인증/인가 수준에서 나올 수 있는 거의 모든 exception들을
처리하는 handler들이 security속에 다 구현돼 있어서 custom만 알맞게 한다면 직접 exceptionHandlerFilter를 만들 필요는 없는거 같다.)
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
인가 과정에서 JwtAuthorizationFilter가 JWT를 파싱하면서 토큰이 없거나 유효하지 않으면 authenticationEntryPoint에서,
권한 scope가 적절하지 않은 요청이면 AccessDeniedHandler에서 처리하도록 한다.
두 클래스 모두 security에서 제공하는 interface를 custom한 것이다.
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/user/**")
.access("hasRole('PARENT') or hasRole('TEACHER') or hasRole('DIRECTOR')")
.antMatchers("/parent/**")
.access("hasRole('PARENT')")
.anyRequest().permitAll();
authorizeRequests() 밑으로는 어떤 요청들을 어떻게 처리할지 설정하는 부분이다.
requestMatchers(CorsUtils::isPreFlightRequest).permitAll() 부분은 PreFlight 요청에 대해 모두 허용하겠다는 설정이다.
.antMatchers("/user/**")
.access("hasRole('PARENT') or hasRole('TEACHER') or hasRole('DIRECTOR')")
이 부분은 "/user/"로 시작하는 모든 요청에 대해 PARENT 또는 TEACHER 또는 DIRECTOR 권한이 있어야만 응답하겠다는 설정이다.
filterChain 밑으로는 앞서 말한 직접 구현하는 filter들과 인증정보를 관리하는 AuthenticationManager의 빈 등록 내용이다.
다음으로 filter들을 구현하기전에 security에서 사용자 정보를 가져올때 사용하는 UserDetails와 그것을 반환하는
UserDetailsService를 구현한 내용을 보자.
@Data
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> ("ROLE_" + user.getAuth().toString()));
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLoginId();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PrincipalDetails that = (PrincipalDetails) o;
return Objects.equals(user, that.user);
}
@Override
public int hashCode() {
return Objects.hash(user);
}
}
여기서 주의해야할 점은 spring security에서 권한을 읽을때 prefix로 "ROLE_"이 붙는다 가정하고 처리한다.
하지만 spring security를 떼야되는? 상황이 올 수도 있고 사용자들의 권한을 관리하는데 prefix로 무언가 붙는것이 부자연스러워서
spring security가 사용자의 권한을 읽어오는 getAuthorities메소드를 구현할때 prefix로 "ROLE_"를 붙이게끔 하였다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// username이 loginId이다.
User user = userRepository.findByLoginId(username)
.orElseThrow(()->new UsernameNotFoundException("존재하지 않는 아이디입니다."));
return new PrincipalDetails(user);
}
}
이제 filter들의 구현내용들을 살펴보자
제일처음 살펴볼 filter는 JwtAuthenticationFilter이다.
해당 filter는 이름에서 알 수 있듯이 인증단계를 처리하는 필터로써 spring security에서 Authentication역할을 하는
UsernamePasswordAuthenticationFilter를 상속받아 구현하였다.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
this.authenticationManager = authenticationManager;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
try {
ObjectMapper om = new ObjectMapper();
LoginRequest loginInfo = om.readValue(request.getInputStream(), LoginRequest.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginInfo.getLoginId(), loginInfo.getPassword());
return authenticationManager.authenticate(authenticationToken);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
String jwtToken = JWT.create()
.withSubject("ILuvIt")
.withExpiresAt(new Date(System.currentTimeMillis() + (60000 * 60 * 1)))
.withClaim("id", principalDetails.getUser().getId())
.sign(Algorithm.HMAC512("secretKey"));
response.addHeader("Authorization", "Bearer " + jwtToken);
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
}
이렇게 구현하게 되면 client가 POST: /login 요청을 했을때 attemptAuthentication메소드가 동작한다.
해당 메소드안에서는 ObjectMapper를 이용해 request에 담겨있는 사용자 정보를 DTO객체(loginRequest)로 만든다.
spring security에서 loginRequest정보를 이용할수 있게 token화 시키고 해당 token으로 authenticationManager를 이용하여 인증을 진행한다.
authenticationManager.authenticate 메소드는 PrincipaDetailsService에 loadByUsername 메소드를 호출하여
db정보와 요청 정보가 일치하는지 확인한다.
만약 두 정보가 일치하지 않아 인증에 실패한다면 AuthenticationFailureHandler로 이동하고,
인증에 성공한다면 Authentication(인증정보)객체를 return하면서 successfulAuthentication메소드를 거쳐 AuthenticationSuccessHandler로 이동하게 된다.
successfulAuthentication에서는 attemptAuthentication에서 return한 인증정보를 기반으로 PrincipalDetails를 생성하여 JWT를 만드는데 참조한다.
Authentication에 실패했을경우 실행되는 AuthenticationFailureHandler는 다음과 같다.
@Slf4j
public class AuthenticationFailureHandlerCustom implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.warn("Authentication Error: {}", exception.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
단순히 서버에 로그를 남기고 Unauthorized(401) error를 client에게 반환한다.
다음은 Authentication에 성공했을경우 실행되는 AuthenticationSuccessHandler이다.
public class AuthenticationSuccessHandlerCustom implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
LoginResponse loginResponse = principalDetails.getUser().getUserInfo();
response.setContentType("application/json");
response.getWriter().write(convertObjectToJson(loginResponse));
}
public String convertObjectToJson(Object object) throws JsonProcessingException {
if (object == null) {
return null;
}
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(object);
}
}
여기서는 로그인 요청에 성공했을때 client에게 알맞게 응답할 수 있도록 인증정보를 이용하여 response body를 만들었다.
(LoginResponse 객체는 client가 필요로 하는 정보를 담은 DTO이다. JWT payload에 해당 정보들을 담을 수 있지만 일단은 response body에 해당 정보를 담게끔 구현하였다.)
이제 Authorization단계를 설명할 차례이다.
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final UserRepository userRepository;
private AuthenticationManager authenticationManager;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String jwtHeader = request.getHeader("Authorization");
if (jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
chain.doFilter(request, response);
return;
}
try {
String jwtToken = request.getHeader("Authorization").replace("Bearer ", "");
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("secretKey")).build().verify(jwtToken);
Long id = decodedJWT.getClaim("id").asLong();
User user = userRepository.findById(id).
orElseThrow(()->new JWTVerificationException("유효하지 않은 토큰입니다."));
PrincipalDetails principalDetails = new PrincipalDetails(user);
Authentication authentication = new UsernamePasswordAuthenticationToken(
principalDetails, null, principalDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JWTVerificationException e) {
log.warn("[JwtAuthorizationFilter] token 파싱 실패 : {}", e.getMessage());
}
chain.doFilter(request, response);
}
}
Authorization을 위한 JwtAuthorizationFilter는 BasicAuthenticationFilter를 상속받아 구현하였다.
본래 BasicAuthenticationFilter는 httpBasic요청에 대한 인증 처리를 하는 필터이지만 securityConfig에서 disable하였기 때문에 상속받아 구현하여도 문제가 없어 보였다.
물론 필터를 직접 새로 만들어서 addFilter하여도 되지만 있는 필터를 상속받아 구현하는게 훨씬 수월할거라 판단하였다.
먼저 securityConfig에서 빈 주입을 위해 필요한 생성자를 알맞게 만들었다.
인가에 대한 전체적인 비즈니스 로직은 doFilterInternal에서 처리한다.
먼저 헤더에 발급했던 토큰이 있는지 확인한다. 만약 토큰이 없다면 별도의 처리없이 해당 필터를 넘긴다.
이 경우 해당 요청은 아무런 권한을 얻지 못하게 되고 만약 해당 요청에 특정 권한이 요구된다면 spring security의 다른 여러 필터들을 거쳐 결국 예외를 던져 AuthenticationEntryPoint에서 해당 예외를 처리하게 된다.
토큰이 존재한다면 해당 토큰을 확인하는 절차를 거친다.
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("secretKey")).build().verify(jwtToken);
만약 해당 토큰을 verify하는데 실패한다면 JWTVerificationException을 던지게 되고
해당 요청 역시 토큰이 존재하지 않는 경우와 똑같이 처리된다.
토큰이 유효한 경우 토큰을 이용하여 인증객체를 만들고 아래와 같이 시큐리티 세션에 인증객체를 저장한다.
SecurityContextHolder.getContext().setAuthentication(authentication);
세션에 인증정보를 저장한다면 Stateless하다는 JWT의 장점이 사라지는거 아닌가 물을 수 있겠지만
세션에 저장되는 인증정보는 해당 요청에 대한 권한 확인용으로만 사용되고
해당 요청이 완료되면 더 이상 사용되지 않고 사라지기 때문에 걱정하는 문제는 발생하지 않는다.
아무튼 spring security의 다른 필터들에서 시큐리티 세션에 저장된 인증정보와
securityConfig에서 설정한 각 요청들에 대한 필요 권한을 비교해보고
권한이 일치하지 않으면 예외를 던져 AccessDeniedHandler에서 처리하게 한다.
앞서 말한 검증들을 문제없이 통과한다면 해당 요청은 정상적으로 Controller까지 전달될 것이다.
AccessDeniedHanlder와 AuthenticationEntryPoint를 커스텀한 코드는 다음과 같다.
@Slf4j
@Component
public class AccessDeniedHandlerCustom implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.warn("[AccessDeniedHandlerCustom] Forbidden error : {}", accessDeniedException.getMessage());
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
@Slf4j
@Component
public class AuthenticationEntryPointCustom implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.warn("[AuthenticationEntryPointCustom] Unauthorized error : {}", authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
참고로 상태코드 Unauthorized(401)는 이름에서 많이들 오해를 하지만 인가실패가 아닌 인증단계에서 에러가 있을때 주로 사용된다.
Forbidden(403)이 인가단계에서 실패하였을때 사용되는데 주로 사용자 권한의 scope와 요청에 필요한 권한이 일치하지 않을때 사용된다.
마지막으로 SecurityConfig에서 제일 먼저 addFilter한 CorsConfig의 corsFilter를 살펴보겠다.
(사실 이 부분에서 제일 많은시간을 삽질(?)했다...)
@Configuration
public class CorsConfig {
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addExposedHeader("Authorization");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
처음 SpringSecurity에서 Cors설정 부분을 보고 의아했던 사람도 있었을 것이다.
보통은 WebMvcConfigurer를 상속받아 구현하는 Configuration에서 addCorsMappings를 override 하는 방식으로 cors설정을 했기 때문이다.
하지만 SpringSecurity에서도 same-origin-policy를 검사하는 부분이 있어 보였고(사실 이부분을 디버깅하면서 찾아낼라 했지만 시간이 없어서 다음으로 미뤘다...) WebMvcConfigurer를 통해 cors설정을 하더라도 서블릿보다 앞단에 있는 필터에 해당 설정이 적용될리 만무했다.
따라서 SpringSecurity를 사용할 경우 CorsFilter를 통해 위 처럼 Cors설정을 따로 해줘야 했다.
개선해야할 점으로는 현재 AccessToken만료시 다시 로그인을 해야되는 문제가있다.
해당 문제는 추후에 RefreshToken 추가함으로써 개선할 계획이다.