이번에 프로젝트를 진행하면서 Spring Security를 사용해보았다.
간단한 이론 정도 알고 있었는데, 실제로 사용해보니 더욱 디테일한 내용들이 많이 있었다.
Spring Security를 완벽하게 사용하려면 많은 경험이 필요할 것으로 보인다.
이번 포스팅은 간단하게 Spring Security를 사용하고 경험한 내용들을 요약, 정리하여 처음 접하는 분들에게 조금이나마 도움이 됐으면 한다.
프로젝트에 사용한 스프링부트 버전은 3.1.2, 스프링 시큐리티 버전은 6.1.2 버전을 사용했다.
Spring Security란?
스프링 시큐리티(Spring Security)란 Spring에서 제공해주는 인증(Authentication) 과 인가(Authorization)에 대한 처리를 위임하는 별도의 프레임워크이다.
프레임워크가 없어도 개발을 할 수 있듯, 스프링 시큐리티가 없어도 스프링에서 인증과 인가를 충분히 구현할 수 있다.
인증, 인가를 훨씬 더 효율적이고 편리하게 구현할 수 있게 도와주는 프레임워크라고 보면 된다.
다만 충분한 학습이 되지 않았을 경우는 오히려 시간이 업무량이 늘어나기 때문에 충분한 학습 후 사용하길 권장한다.
스프링 시큐리티는 기본적으로 필터(Filter) 단에서 동작한다.
Filter는 Dispatcher Servlet 전에 위치하기 때문에 요청이 가장 먼저 도달하는 지점이다.
즉 애플리케이션 서버 최앞단에서 인증, 인가를 수행해준다고 보면 된다.
Spring Security 전체 흐름
스프링 시큐리티의 목적은 결국 Authentication이라는 인증 정보가 담긴 객체를 필요한 시점에 사용하기 위해 인증, 인가 후에 만들어서 보관하는 것이 목적이다.
클라이언트로 부터 받은 유저 인증 정보를 인증, 인가하고 Authentication 객체를 만들어 SecurityContext에 보관해두고 필요한 곳에서 Authentication 객체를 사용하는 것이다.
- Authentication 객체 생성
- 사용자 인증 요청이 오면, AuthenticationFilter가 Authentication 객체를 생성하고, 요청 정보를 AuthenticationManger에게 전달
- 인증 및 Authentication 객체 채우기
- AuthenticationManager는 AuthenticationProvider들을 조회하며 인증을 요구(커스텀 필터 등 조회)
- AuthenticationProvider는 UserDetailsService를 통해 요청 정보를 기반으로 사용자 정보를 DB에서 조회 후 Authentication에 정보를 채움
- Authentication 저장, 관리
- AuthenticationManager는 반환된 Authentication 객체를 AuthenticationFilter로 전달
- AuthenticationFilter는 Authentication 객체를 SecurityContextHolder에 저장
Spring Security 기본 설정
스프링 시큐리티를 활용하려면 가장 먼저 의존성을 추가해줘야 한다.
implementation 'org.springframework.boot:spring-boot-starter-security'
이전에 Spring Security 설정을 위해 사용했던 WebSecurityConfigurerAdapter는 deprecated 되었다.
현재 Spring Security 6.1.2 버전에서는 FilterChain 의 역할을 하는 메소드를 직접 구현하여 Bean 으로 등록해 주어야한다.
Spring Security 6.1.2 버전에서는 스프링 시큐리티의 기존 문법과 달라진 부분이 많았다.
달라진 문법은 Spring Security의 java docs에 예제 코드와 함께 잘 설명되어 있어서 참고해서 구현할 수 있었다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((authorizeRequests) -> {
authorizeRequests
.requestMatchers("경로 명시").hasRole("USER")
.requestMatchers("경로 명시").permitAll()
.anyRequest().authenticated();
})
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(AbstractHttpConfigurer::disable)
.cors(withDefaults())
.headers((headers) ->
headers
.frameOptions((frameOptions) -> frameOptions.sameOrigin())
)
.exceptionHandling((exceptionHandling) ->
exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(new CustomAccessDeniedHandler())
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Spring Security로 JWT 인증, 인가 구현
스프링 시큐리티에서는 원하는 인증, 인가 필터를 직접 구현하고 스프링 시큐리티 설정에서 Filter 순서를 설정하여 커스텀한 인증, 인가를 구현할 수 있다.
구현한 JWT Filter를 설정한 부분은 위의 스프링 시큐리티 기본 설정 예제 코드에서 .addFiletrBefore 부분이다.
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
이번 프로젝트에서는 기본으로 제공하는 인증, 인가 필터가 아닌 JWT를 활용하여 인증, 인가를 구현하고자 했다.
JWT 토큰 서명에는 크게 대칭키 방식과 비대칭키 방식이 있는데, 빠르게 구현하고자 구현에 익숙한 대칭키 방식을 채택했다.
가장 먼저 관련된 의존성 라이브러리를 추가해주자
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
다음으로 본격적으로 JWT 토큰과 관련된 인증, 인가 필터를 구현해야 한다.
스프링 부트에서는 필터를 여러 방법으로 구현할 수 있는데, 가장 편한 구현 방법은 필터를 상속받아 사용하는 것이다.
대표적으로 상속하는 객체는 GenericFilterBean 또는 OncePerRequestFilter이다.
이 중에서 GenericFilterBean의 중복 필터 사용 문제를 해결해 나온 것이 OncePerRequestFilter로 OncePerRequestFilter 사용을 권장한다.
아래는 OncePerRequestFilter를 상속한 JwtAuthenticationFilter 예제 코드이다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(servletRequest);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
JwtAuthenticationFilter에서 주입받고 있는 JwtTokenProvider에 대해 살펴보자.
JwtTokenProvider는 JWT 토큰을 생성하고, 검증하는 역할을 하고 있다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final UserDetailsService userDetailsService;
@Value("${설정 경로 명시}")
private String secretKey;
@Value("${설정 경로 명시}")
private final long ACCESS_TOKEN_EXPIRATION_TIME;
@Value("${설정 경로 명시}")
private final long REFRESH_TOKEN_EXPIRATION_TIME;
// SecretKey 에 대해 인코딩 수행
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
}
// JWT accessToken 생성
public String createAccessToken(int userUid, List<String> roles) {
Claims claims = Jwts.claims().setSubject(Integer.toString(userUid));
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return token;
}
// JWT refreshToken 생성
public String createRefreshToken(int userUid, List<String> roles) {
Claims claims = Jwts.claims().setSubject(Integer.toString(userUid));
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return token;
}
// JWT 토큰으로 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
return new UsernamePasswordAuthenticationToken(userDetails, "",
userDetails.getAuthorities());
}
// JWT 토큰에서 회원 구별 정보 추출
public String getUsername(String token) {
String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody()
.getSubject();
return info;
}
// HTTP Request Header 에 설정된 토큰 값을 가져옴
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// JWT 토큰의 유효성 + 만료일 체크
public boolean validateToken(String token) {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
}
}
Spring Security 예외처리
스프링 시큐리티에서 발생한 예외들은 어떻게 처리할까?
스프링 시큐리티에서 예외처리와 관련된 인터페이스를 제공해주고 있다.
인증과 관련된 예외는 AuthenticationEntryPoint 인터페이스를 상속하여 commence 메소드를 통해 구현해주면 되며,
권한와 관련된 예외는 AccessDeniedHandler 인터페이스를 상속하여 handle 메소드를 통해 구현해주면 된다.
구현한 예외처리는 스프링 시큐리티 설정에서 다음과 같이 추가해 줄 수 있다.
.exceptionHandling((exceptionHandling) ->
exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(new CustomAccessDeniedHandler())
)
인증 예외를 처리한 AuthenticationEntryPoint 인터페이스 구현체 예제 코드는 다음과 같다.
@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public CustomAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
resolver.resolveException(request, response, null, (Exception) request.getAttribute("exception"));
}
}
여러 예외처리 방법이 있겠지만, 여기서는 ControllerAdvice에서 공통 예외처리를 할 수 있도록 handlerExceptionResolver를 주입받아 발생한 예외를 던져주는 방식으로 처리했다.
Fillter는 애플리케이션 서버 최앞단에 존재하기 때문에 ExceptionHandler가 발생한 예외를 인지하지 못하는 문제가 있기 때문에 위와같이 resolver를 직접 주입해줘서 구현했다.
권한 예외를 처리한 AccessDeniedHandler 인터페이스 구현체 예제 코드는 다음과 같다.
@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException exception) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
log.info("[handle] 권한 예외 발생");
response.setStatus(403);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(new BaseExceptionResponse(AUTHORITY_ERROR)));
}
}
권한 예외는 곧바로 response를 만들어 내려주는 방식으로 구현해봤다.
참고
https://thalals.tistory.com/436
https://mangkyu.tistory.com/173
'👨💻 개발' 카테고리의 다른 글
AWS Elastic BeanStalk, Docker를 활용한 CI/CD 환경 구축 (2) | 2023.11.15 |
---|---|
깃(Git), 깃허브(Github) 활용하기 (0) | 2021.12.28 |
스프링과 스프링부트 차이(Spring vs Spring boot) (0) | 2021.12.11 |
웹 애플리케이션 서버(WAS)란? 웹 서버(WS)와의 차이 (0) | 2021.12.11 |
서블릿(Servlet)이란? 서블릿에서 스프링까지 (0) | 2021.12.11 |