프로젝트/Share Your Trip
[Spring boot] Spring Security + JWT + Redis (2/3)
cks._.hong
2024. 2. 6. 21:36
이번 포스팅에서는 Share Your Trip 프로젝트에 Spring Security를 적용해 볼 것이다. 또한, JWT와 함께 사용하여 Spring Security를 커스터 마이징 해볼 것이다.
Spring Security Filter
- Share Your Trip은 Rest API 서버로 formLogin 방식을 사용하지 않아 formLogin(AbstractHttpConfigurer::disable)을 설정했다.
- httpBasic은 username과 userpassword를 텍스트로 전송하는 방식이다. 해당 프로젝트에서는 JWT 방식을 사용하여 httpBasic(AbstractHttpConfigurer::disable)을 설정했다.
- stateless한 JWT 사용으로 인해 Spring Security에서 기본적으로 제공하는 Session을 사용할 이유가 없다. 그래서 sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)를 설정했다.
- stateless JWT 사용으로 인해 Session 기반의 공격 기법인 CSRF 방어 기능을 비활성하기 위해 csrf(AbstractHttpConfigurer::disable)을 설정했다.
- "/api/users/login", "/api/users/", "/api/users/regenerateToken"의 API 주소를 가진 컨트롤러 메소드의 경우 인증이 필요하지 않기에 열어두었다.
- 그 외에는 인증이 필요한 메소드로 anyRequest().authenticated()로 설정하여 보안성을 강화했다.
- JWT를 사용하기 때문에 커스텀 필터가 필요한데 AuthenticationFilter인 UsernamePasswordAuthenticationFilter를 사용하지 않기 때문에 해당 필터 앞에 JwtAuthenticationFilter 커스텀 인증 필터를 설정했다.
- PasswordEncoder의 경우 BCryptPasswordEncoder를 사용했는데 다른 인코더에 비해 서버 리소스를 적게 먹는 다고 한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeRequests((authorize) -> authorize
.requestMatchers(
new AntPathRequestMatcher("/api/users/login", HttpMethod.POST.name()),
new AntPathRequestMatcher("/api/users", HttpMethod.POST.name()),
new AntPathRequestMatcher("/api/users/regenerateToken", HttpMethod.POST.name())
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Custom Authentication Filter
- UsernamePasswordAuthenticationFilter를 사용하지 않는 이유에 대해서 알아보도록 한다.
- 기본적인 Spring Security의 경우 사용자로부터 요청을 받으면 인증을 할 때 Username and Password Authentication Mechanism을 사용하여 로그인 폼으로 보내게 된다. 이때, 해당 역할을 수행하는 필터가 UsernamePasswordAuthentication이다.
- 하지만, Share Your Trip은 REST API 서버로 로그인 폼 방식의 인증을 사용하지 않을 것이기 때문에 UsernamePasswordAuthentication 이전에 커스텀 필터를 만들어 해당 역할을 대신할 필터가 필요하게 된다.
- 그래서 커스텀 필터인 JwtAuthenticationFilter를 통해 인증과 인가를 관리해보려고 한다.
- 필터로 등록하기 위해 OncePerRequestFilter를 상속받고 있는데 이 외에도 GenericFilterBean이 존재한다.
- 같은 서블릿에서 다른 서블릿을 생성하여 호출하는 경우가 존재하는데 이때 필터 로직을 다시 한 번 수행하게 되어 GenericFilterBean를 상속받은 경우 중복되어 실행된다.
- OncePerRequestFilter를 사용할 경우 하나의 요청에 한 번의 처리만 가능하게 보장하는 필터로 Spring Security 공식 문에서도 CustomFilter를 만들 때 해당 필터를 상속받아 사용하라고 권장하고 있다.
Architecture :: Spring Security
The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec
docs.spring.io
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if(token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
- 사용자 요청이 들어오면 request Header의 Authorization에서 JWT accessToken토큰을 추출한다.
- JWT 혹은 OAuth의 AccessToken의 경우 RFC6750에 따라 통상적으로 Authorizaion 헤더에 "Bearer {accessToken}" 형식으로 넘겨주곤 한다.
- 만약, 토큰이 존재하지 않으면 다음 필터로 넘기고 토큰이 존재하고 유효하다면 JwtTokenProvider에게 토큰을 넘겨 Authentication 객체를 생성하고 SecurityContext에 넣고 있는 것을 알 수 있다.
- JwtTokenProvider는 AuthenticationManager가 사용하는 AuthenticationProvider와 같은 역할로 커스텀 필터를 사용하고 있으므로 AuthenticationProvider 또한 JWT에 맞게 클래스를 생생했다.
@Component
public class JwtTokenProvider {
private final CustomUserDetailService customUserDetailService;
private final Key key;
@Value("${jwt.access-expiration-time}")
private long accessTokenExpiresIn;
@Value("${jwt.refresh-expiration-time}")
private long refreshTokenExpiresIn;
@Autowired
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, CustomUserDetailService customUserDetailService) {
this.customUserDetailService = customUserDetailService;
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public JwtToken generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("role", authorities)
.setExpiration(new Date(now + accessTokenExpiresIn))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + refreshTokenExpiresIn))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
if(claims.get("role") == null) {
throw new RestApiException(CommonResponseCode.INVALID_PARAMETER);
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("role").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) throws JwtException {
parseClaims(token);
return true;
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}
Token Validation
- 생성자부터 살펴보면 customUserDetailService를 주입하고 있는데 추후에 설명하도록 하겠다. 이후, 사전에 Base64로 인코딩한 32Bytes 길이의 secretKey를 decode하고 비밀키를 생성하기 위해 hmacShaKeyFor()를 호출하고 있다.
- 이후, JwtAuthenticationFilter에서 가장 먼저 실행되는 JwtTokenProvider의 validateToken을 보면 parseClaims()를 호출하고 발생하는 에러를 던지고 있다.
- parseClaims()는 Jwt parser 객체를 생성하고 생성한 비밀키를 셋팅한다. 그리고 Jws로 AccessToken을 넘겨주어 파싱 과정을 거쳐 payload에 있는 값들을 가져오게 된다.
- 이 과정에서 토큰이 유효하지 않다면 에러를 발생시키고 각 에러를 잡아 처리하는 과정을 볼 수 있다.
Authentication
- JwtAuthenticationFilter에서 사용자가 보낸 accessToken을 바탕으로 Authentication 인증 객체를 생성하는 과정에 대해서 보겠다.
- 우선, parseClaims()를 통해 accessToken을 검증하고 payload를 가져온다. 그리고 claims의 role 존재 유무를 체크하여 정상적인 토큰인지를 판단하게 된다.
- UserDetails의 구현체인 User에 id에 해당되는 claim의 subject와 authoities를 넘겨줘서 객체를 생성한다.
- 이후, UserDetails와 authorities를 이용하여 Authentication 객체를 생성하고 반환한다.
- JwtAuthenticationFilter가 Authentication을 받아서 SecurityContextHolder의 Context에 저장하고 다음 필터 과정을 수행하게 된다.
Token 생성
- Security Filter Chain에서 "/api/user/login"은 인증 필터를 거치지 않게 설정을 해두었다. 해당 컨트롤러는 userService의 login()를 호출하게 된다.
- 사용자로부터 id와 pw를 받아서 UsernamePasswordAuthenticationToken을 생성하게 된다. 이때, 인증 여부를 나타태는 authenticated의 값은 false이다.
- 해당 토큰 값을 AuthenticationManager에게 넘겨주면 AuthenticationProvider가 authentication 작업을 수행할 것이다.
- AbstractUserDetailsAuthenticationProvider의 retrieveUser() 메소드에서 loadUserByUsername()를 통해서 검증할 UserDetails 값을 가져오기 때문에 기존의 방식이 아닌 Share Your Trip의 DB랑 연동을 시켜야한다.
@Override
public UserDto.UserInfoResponseDTO login(final UserDto.LoginRequestDTO requestDTO) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(requestDTO.getUserId(), requestDTO.getUserPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
User user = userMapper.findById(requestDTO.getUserId())
.orElseThrow(() -> new RestApiException(CustomResponseCode.USER_NOT_FOUND));
JwtToken jwtToken = jwtTokenProvider.generateToken(authentication);
return UserDto.UserInfoResponseDTO.builder()
.userId(user.getUserId())
.userName(user.getUserName())
.joinDate(user.getJoinDate())
.profile(user.getProfile())
.role(user.getRole())
.email(user.getEmail())
.accessToken(jwtToken.getAccessToken())
.refreshToken(jwtToken.getRefreshToken())
.build();
}
- UserDetailsService 인터페이스를 구현하는 CustomUserDetailService 클래스를 생성하여 loadUserByUsername()를 override 했다.
- userMapper를 이용하여 Share Your Trip의 DB에서 사용자를 조회하고 이를 CustomUserDetails 객체로 다시 만들어서 반환하고 있는데 해당 객체는 UserDetails를 상속한 객체이다.
- user 클래스는 Share Your Trip에서 사용하는 user 객체이며 Spring Security에서 사용하는 UserDetails 객체로 만들어주기 위해서 상속받고 있다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
User user = userMapper.findById(userId)
.orElseThrow(() -> new RestApiException(CommonResponseCode.UNAUTHORIZED_REQUEST));
return CustomUserDetails.builder()
.user(user)
.build();
}
}
public class CustomUserDetails implements UserDetails {
private final User user;
@Builder
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole().value();
}
});
return collection;
}
- 위 과정들을 통해 인증을 완료하면 유효한 Authentication이 생성이되고 이를 JwtTokenProvider의 generateToken()를 통해 accessToken과 refreshToken을 생성한다.
- 이를 사용자 응답 DTO에 맞게 변환을 해주고 controller로 넘겨 토큰 생성 과정을 마무리하게 된다.
회원가입
- "/api/users" POST 요청의 경우도 유저를 생성하는 API로 spring security에서 인증을 거치지 않도록 예외 처리해두었다.
- Body에 JSON 형식으로 파라미터를 채워 유저 생성 테스트를 해본 결과 성공적으로 생성된 것을 알 수 있다.
로그인
- 해당 파라미터를 그대로 사용하여 로그인 API인 "/api/users/login"을 호출했다.
- 그 결과 올바르게 값들이 넘어오는 것을 확인할 수 있다.
인증이 필요한 API 호출
- Authorization 헤더에 AccessToken을 삽입하여 인증이 필요한 API를 호출한 결과가 올바르게 응답하는 것을 알 수 있다.
- Authorization 헤더를 제거하고 인증이 필요한 API를 호출한 결과가 403에러가 응답하는 것을 알 수 있다. Filter 에러 처리의 경우 추후에 진행하려고 한다.
Authentication에서 값 추출
- Controller에서 요청한 클라이언트의 id값이 필요한 경우가 있다. 그 경우 컨트롤러의 파라미터로 Authentication을 설정하여 인증 정보를 가져올 수 있다.
@GetMapping("/{userid}")
public ResponseEntity<Object> SelectUser(@PathVariable("userid") @UserId final String userId, Authentication authentication) {
System.out.println(authentication.getName());
UserDto.UserInfoResponseDTO responseDTO = service.findById(userId);
return SuccessResponse.createSuccess(SuccessCode.READ_USER_SUCCESS, responseDTO);
}
인가 처리
- API를 호출할 때, 요청한 사용자의 권한에 맞게 API 인가 처리가 이뤄져야 했다. 또한, 게시글을 삭제할 경우 요청한 사용자의 게시글이 아닌 경우는 처리가 되면 안됐기 때문에 인가 처리가 필요했다.
- Spring Security에서는 두 가지 방법을 제공하고 있었다. Secured 방식과 prePost 방식이 존재하였고 이에 대해서 알아보고 적용해보도록 하겠다.
- 해당 방식들을 적용하기 위해, Security Filter Chain에 @EnableMethodSecurity 어노테이션에 securedEnabled를 true 옵션을 주었다. prePost의 경우 default로 true 값을 가지고 있어서 따로 값을 주지 않았다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
@Secured()
- Secured 방식의 경우 컨트롤러 위에 @Secured 어노테이션을 붙이고 인자로 받은 유저 권한이 있는 사용자만 허용이 가능하게 할 수 있다.
@Secured("ROLE_ADMIN")
@GetMapping("/{userid}")
public ResponseEntity<Object> SelectUser(@PathVariable("userid") @UserId final String userId, Authentication authentication) {
System.out.println(authentication.getAuthorities());
UserDto.UserInfoResponseDTO responseDTO = service.findById(userId);
return SuccessResponse.createSuccess(SuccessCode.READ_USER_SUCCESS, responseDTO);
}
- ROLE_ADMIN으로 변경하고 API를 호출하니 정상적으로 응답이 오는 것을 확인할 수 있다.
@Pre/PostAuthorize
- PreAuthorize의 경우 Controller를 실행하기전에 권한을 확인하는 방식이며 PostAuthorize의 경우 Controller를 실행하고 나서의 권한 체크가 이뤄진다.
- returnObject의 결과의 userId와 accessToken에서 추출한 유저의 아이디가 다를 경우 요청이 거절되고 ROLE_ADMIN의 경우 정상적으로 처리되게 설정해놨다.
@PostAuthorize("(returnObject.body.data.userId == principal.username) or hasRole('ROLE_ADMIN')")
@GetMapping("/{userid}")
public ResponseEntity<Object> SelectUser(@PathVariable("userid") @UserId final String userId, Authentication authentication) {
UserDto.UserInfoResponseDTO responseDTO = service.findById(userId);
return SuccessResponse.createSuccess(SuccessCode.READ_USER_SUCCESS, responseDTO);
}
- USER_ROLE을 가진 chanhong 계정의 AccessToken으로 타인의 계정인 chan9784를 조회한 결과 아래와 같이 거절되었다.
- 본인의 계정(chanhong)을 조회한 결과 올바른 응답을 출력하는 것을 알 수 있다.
- ADMIN 계정인 chan9784의 accessToken을 이용하여 타인의 계정인 chanhong을 조회한 결과 올바른 결과 값을 반환하는 것을 알 수 있다.
예외 처리
- Spring Security Filter에서 발생하는 예외는 @RestControllerAdvice에서 처리할 수 없다. 왜냐하면 Filter 에러는 Controller에 도달하기전에 에러를 발생시켜 @RestControllerAdvice가 에러를 처리할 수 없기 때문이다.
- Spring Security는 AuthenticationException을 발생시켰을 때, AuthenticationEntryPoint에서 예외 처리를 시도한다. 또한, AccessDeniedException을 발생시켰을 때는 AccessDeniedHandler가 예외를 처리하게 된다. 따라서, AuthenticationEntryPoint와 AccessDeniedHandler 구현체들을 활용한다면 @RestControllerAdvice에서 에러를 처리할 수 있을 것이다.
- 일단, @RestControllerAdvice에서 Spring Security 관련 에러를 처리할 수 있도록 에러 처리 메소드들을 등록했다.
@ExceptionHandler({SignatureException.class, MalformedJwtException.class, UnsupportedJwtException.class})
public ResponseEntity<Object> handleJwtException(final Exception e) {
final ResponseCode responseCode = CommonResponseCode.INVALID_PARAMETER;
return handleExceptionInternal(responseCode, "유효하지 않은 토큰입니다.");
}
@ExceptionHandler(ExpiredJwtException.class)
public ResponseEntity<Object> handleJwtAuthException(final ExpiredJwtException e) {
final ResponseCode responseCode = CommonResponseCode.UNAUTHORIZED_REQUEST;
return handleExceptionInternal(responseCode, "만료된 토큰입니다.");
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Object> handleAuthException(final AccessDeniedException e) {
final ResponseCode responseCode = CommonResponseCode.FORBIDDEN_ACCESS;
return handleExceptionInternal(responseCode, "권한이 존재하지 않습니다.");
}
- AuthenticationEntryPoint와 AccessDeniedHandler의 구현체를 만들어주었다. @ExceptionHanlder가 처리할 수 있도록 HandlerExceptionResolver에서 @Qualifier를 활용하여 handlerExceptionResolver를 지정해주었다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
resolver.resolveException(request, response, null, authException);
}
}
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final HandlerExceptionResolver resolver;
public JwtAccessDeniedHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
resolver.resolveException(request, response, null, accessDeniedException);
}
}
- 또한, Spring Security에서 발생하는 에러들을 해당 구현체들이 처리할 수 있도록 Security Filter Chain에서 설정했다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeRequests((authorize) -> authorize
.requestMatchers(
new AntPathRequestMatcher("/api/users/login", HttpMethod.POST.name()),
new AntPathRequestMatcher("/api/users", HttpMethod.POST.name()),
new AntPathRequestMatcher("/api/users/regenerateToken", HttpMethod.POST.name())
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(handler -> handler
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint))
.build();
}
- 이로써 해당 구현체들을 통해서 에러를 처리할 수 있을 것이라 기대하여 테스트를 진행했다. 그 결과 JwtException의 종류인 ExpiredJwtException 에러가 출력됐지만 postman에는 401이 아닌 500 에러가 출력되는 것을 볼 수 있다.
- JwtException 에러 클래스가 잡아서 처리한 것이 아니라 아래 메소드가 에러를 잡아 처리하는 것을 확인할 수 있었다.
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleAllException(final Exception e) {
final ResponseCode responseCode = CommonResponseCode.INTERNAL_SERVER_ERROR;
System.out.println(e.getMessage());
return handleExceptionInternal(responseCode);
}
- JwtAuthentication Filter에서 try ~ catch로 JwtException이 발생하면 request에 발생한 에러 객체를 삽입했다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
try {
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (JwtException e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
- 이후, 각 구현체에서 해당 객체들을 빼내어 resolver에게 던져줌으로써 상세한 에러 처리를 가능하게 했다.
// JwtAuthenticationEntryPoint
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
resolver.resolveException(request, response, null, (Exception) request.getAttribute("exception"));
}
// JwtAccessDeniedHandler
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
resolver.resolveException(request, response, null, (Exception) request.getAttribute("exception"));
}
- 테스트한 결과 아래와 같이 401에러가 올바르게 응답하는 것을 알 수 있었다.
다음 포스팅에서는 redis를 활용하여 refreshToken의 저장과 갱신을 다뤄보도록 하겠다.