이번 포스팅에서는 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를 만들 때 해당 필터를 상속받아 사용하라고 권장하고 있다.
사용자 요청이 들어오면 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와 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 어노테이션을 붙이고 인자로 받은 유저 권한이 있는 사용자만 허용이 가능하게 할 수 있다.
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에서 설정했다.