Spring Security, JWT, Redis (3)

Ajisai·2024년 3월 2일
0

Spring Security

목록 보기
4/7

https://velog.io/@kirisame/Spring-Security-JWT-Redis-2

이 글과 이어진다.

할 일

  • SecurityConfig
    • Spring security를 적용하기 위한 구성 요소(AuthToken, AuthTokenProvider, AuthTokenFilter, ...)를 정의했으므로, 이를 사용하기 위한 Spring security Configuration을 작성한다.
    • AuthTokenProvier, AuthTokenFilter를 Spring bean으로 등록한다.
    • 사실 AuthTokenFilter라는 Custom filter를 정의했을 뿐, 실제로 요청이 이 filter를 거치는 게 아니다.
      • 요청이 filter를 거치게 하려면 FilterChain에 등록해야 하며, 이 절차 역시 SecurityConfig에서 작성한다.
  • AuthTokenFilter 예외 처리
    • 모든 요청에서 인증 토큰을 필요로 하는 것은 아니다.
      따라서 이에 대한 처리가 필요하다.

SecurityConfig

여기서 작성해야 하는 내용

  • AuthTokenFilter, AuthTokenProvider 등 필요한 요소를 Java Bean으로 등록한다.
  • FilterChain에 AuthTokenFilter를 등록한다.
  • 인증 토큰을 필요로 하지 않는 요청에 대해서만 인증을 요구한다.
import java.util.Arrays;
import java.util.List;

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import com.example.demo.user.auth.entrypoint.JwtAuthenticationEntryPoint;
import com.example.demo.user.auth.filter.AuthTokenFilter;
import com.example.demo.user.auth.handler.JwtAccessDeniedHandler;
import com.example.demo.user.auth.token.AuthTokenProvider;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity //EntryPoint, AccessDeniedHandler를 사용하기 위함
@RequiredArgsConstructor
public class SecurityConfig {
	private final AuthTokenProvider tokenProvider;

	// 인증 토큰 없이 접근 가능한 URL
	private final String[] PERMIT_ALL = new String[] {
		"/api/user/login",
		"/api/user/join",
        
        //Swagger 3를 사용하는 경우 아래의 주석을 해제 한다.
		// "/swagger-ui", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/configuration/**"
	};

	@Bean
	public AuthTokenFilter authTokenFilter() {
		return new AuthTokenFilter(tokenProvider, redisService);
	}

	@Bean
    // 인증 단계에서 예외 발생 시 여기로 간다.
	public JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint() {
		return new JwtAuthenticationEntryPoint();
	}

	@Bean
    // 원래는 인가 관련 예외(AccessDeniedException) 발생 시 여기로 간다.
    // '원래는'이라고 한 이유는 실제로 이 handler가 실행된 걸 못 봤기 때문....
    // 이게 유효하려면 요청 마다 권한 설정을 해줘야 한다.
	public JwtAccessDeniedHandler jwtAccessDeniedHandler() {
		return new JwtAccessDeniedHandler();
	}

	@Bean
    // Spring security에서 제공하는 form login 사용 시 
    // 이 Bean에 의해 패스워드가 인코딩되지만
    // 현재는 해당사항이 없다(이전 글 참조).
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder(10); // default rounds: 10
	}

	@Bean
    // 핵심적인 부분.
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		// Stateless하므로 CSRF 방어가 불필요하다.
		http.csrf(AbstractHttpConfigurer::disable);
		
        // Sessrion 정책을 stateless로 설정한다 ≡ Session을 비활성화한다(사용하지 않는다).
        http.sessionManagement(
			(session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		);
        
        // UsernamePasswordAuthenticationFilter 앞에 AuthTokenFilter를 등록한다.
        // 즉 어떤 요청에 대해 AuthTokenFilter를 거친 후 UsernamePasswordAuthenticationFilter를 거치게 된다.
		http.addFilterBefore(authTokenFilter(), UsernamePasswordAuthenticationFilter.class);
		
        // 요청 인가에 관한 옵션 설정
        http
			.authorizeHttpRequests(
				authorizationManagerRequestMatcherRegistry ->
					authorizationManagerRequestMatcherRegistry
						.requestMatchers(PERMIT_ALL).permitAll()  // PERMIT_ALL의 패턴 해당하는 URL에 대한 요청은 허용한다.
                        // Swagger 3 접근 허용이 안된다면 다음을 추가한다.
                        // .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
						.anyRequest().authenticated() // 그 외의 요청은 모두 인증을 요구한다.
			)
			.exceptionHandling(
				configurer -> configurer
					.authenticationEntryPoint(jwtAuthenticationEntryPoint()) // 인증 관련 예외 발생 시 여기로 이동된다.
					.accessDeniedHandler(jwtAccessDeniedHandler())
			);

		return http.build();
	}
}

http.authorizeHttpRequests() 사용 시 주의사항

일단 순서가 유의미하다는 게 가장 중요하다. 방화벽 정책처럼 먼저 설정한 게 우선이다.

http
	.authorizeHttpRequests(
		authorizationManagerRequestMatcherRegistry ->
			authorizationManagerRequestMatcherRegistry
				.requestMatchers(PERMIT_ALL).permitAll()  
				.anyRequest().authenticated() 
	);

이 경우 정책은 다음과 같다.

  1. PERMIT_ALL 허용
  2. 모든 요청에 대해 인증 요구

PERMIT_ALL에 대해서는 인증을 요구하지 않는 게 우선되므로, 뒤에서 모든 요청에 대해 인증을 요구하면 PERMIT_ALL을 제외한 요청에 대해서만 인증을 요구하게 된다.

http
	.authorizeHttpRequests(
		authorizationManagerRequestMatcherRegistry ->
			authorizationManagerRequestMatcherRegistry
				.anyRequest().authenticated()
                .requestMatchers(PERMIT_ALL).permitAll()
	);

이 경우 정책은 다음과 같다.

  1. 모든 요청에 대해 인증 요구
  2. PERMIT_ALL 허용

모든 요청에 대해 인증 요구 가 우선되므로 뒤에서 PERMIT_ALL에 대해 허용해봤자 적용되지 않는다.

JwtAuthenticationEntryPoint

import java.io.IOException;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(
		HttpServletRequest request,
		HttpServletResponse response,
		AuthenticationException authException
	) throws IOException {
		// 인증 실패 시 처리
	}
}

JwtAccessDeniedHandler

import java.io.IOException;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class JwtAccessDeniedHandler implements AccessDeniedHandler {
	@Override
	public void handle(
		HttpServletRequest request,
		HttpServletResponse response,
		AccessDeniedException accessDeniedException
	) throws IOException {
		 // 인가 실패 시 처리
	}
}

두 경우 모두 적절한 처리 내용을 넣으면 된다.
내 경우 적절한 에러를 응답으로 보내주기만 했다. 필요한 절차는 상황에 맞게 추가하거나 제거하면 된다.

이것으로 SecurityConfig 작성이 끝났다.
다음은 AuthTokenFilter에서 예외 처리를 추가할 차례다.

AuthTokenFilter

...

public class AuthTokenFilter extends OncePerRequestFilter {
	private final AuthTokenProvider tokenProvider; 

	@Override
	protected void doFilterInternal(
		HttpServletRequest request,
		HttpServletResponse response,
		FilterChain filterChain
	) throws ServletException, IOException {
		String token = request.getHeader("Authorization"); //헤더에서 토큰 정보를 얻는다.

		try {
			// claim을 얻어냄으로써 다음을 검증한다.
			// 1. 우리가 발급한 토큰이 맞는가?
			AuthToken authToken = new AuthToken(token);
			if (tokenProvider.validate(authToken)) {
                //토큰
				Authentication authentication = tokenProvider.getAuthentication(authToken);
				SecurityContextHolder.getContext().setAuthentication(authentication);
			}

			filterChain.doFilter(request, response);
		} catch (ExpiredJwtException je) {
            //예외 처리
		} 

	}

}

예외는 tokenProvider.validate(authToken)에서 발생한다.
필터에서 토큰과 관련해 예외를 발생시키는 경우는 토큰이 없는 경우가 아니라 토큰이 만료된 경우다.
따라서 validate도 다음과 같이 수정한다.

AuthTokenProviderImpl

...
	@Override
	public boolean validate(AuthToken token) throws ExpiredJwtException {
		// token의 claim을 얻는 과정에서 예외 발생 ≡ token이 유효하지 않다
		Claims claims = token.getClaims(this.key);

		return claims != null;
	}
...

원래는 JwtExceptionthrow 했지만, 이 경우 토큰이 필요없는 요청에서도 예외가 발생한다(token == null이니까).
따라서 토큰이 만료된 경우 발생하는 예외인 ExpiredJwtExceptionthrow 한다.
(JwtException은 JWT 관련 여러 예외의 super class다.)

그런데 여기서 throw 되는 예외는 token.getClaims()에서 발생하는 예외고, token.getClaims()JwtExceptionthrow 하고 있다. 따라서 AuthToken 클래스에서 getClaims()도 수정할 필요가 있다.

AuthToken

...
    public Claims getClaims(SecretKey key) throws ExpiredJwtException {
        Claims claims = null;
        try {
            claims = Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
        } catch (ExpiredJwtException e) {
            throw e;
        } catch (JwtException ignored) {

        }

        return claims;
    }
...

이 경우 ExpiredJwtException을 제외한 다른 JWT 예외는 모두 무시되며(ignored), ExpiredJwtExceptionthrow 하게 된다.
이렇게 하는 이유는 요청에 토큰이 없는 경우 발생하는 JWT 예외를 무시하기 위함이다.
이제 이에 맞춰 AuthTokenFilter를 다시 수정해보자.

AuthTokenFilter

...

public class AuthTokenFilter extends OncePerRequestFilter {
	private final AuthTokenProvider tokenProvider; 

	@Override
	protected void doFilterInternal(
		HttpServletRequest request,
		HttpServletResponse response,
		FilterChain filterChain
	) throws ServletException, IOException {
		String token = request.getHeader("Authorization"); //헤더에서 토큰 정보를 얻는다.

		try {
			// claim을 얻어냄으로써 다음을 검증한다.
			// 1. 우리가 발급한 토큰이 맞는가?
			AuthToken authToken = new AuthToken(token);
			if (tokenProvider.validate(authToken)) {
                //토큰
				Authentication authentication = tokenProvider.getAuthentication(authToken);
				SecurityContextHolder.getContext().setAuthentication(authentication);
			}

			filterChain.doFilter(request, response);
		} catch (ExpiredJwtException je) {
			HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;

			response.setCharacterEncoding(StandardCharsets.UTF_8.name());
			response.setContentType(MediaType.APPLICATION_JSON_VALUE);

			ResponseEntity<ErrorResponse> responseBody = new ResponseEntity<>(
				new ErrorResponse(
					unauthorized.value(),
					"인증 정보가 만료되었습니다. 다시 로그인해주세요."
				),
				unauthorized
			);

			ServletOutputStream outputStream = response.getOutputStream();
			outputStream.write(new Gson().toJson(responseBody).getBytes(StandardCharsets.UTF_8));
	}

}
  • 예외 처리에 쓰인 내용은 실제로 프로젝트에 썼던 내용이다.
    • ErrorResponse는 프로젝트에서 따로 선언한 클래스다.
    • Gson은 구글에서 제공하는 JSON parsing 라이브러리로, Jackson에 비해 사용법이 매우 간단해서 개인적으로 선호한다. 의존성 추가만 하면 끝이다.
    • 여기 쓰인 코드는 참고용일 뿐이다. 사실 저렇게 하는 게 바람직한 건지 작성한 본인도 잘 모른다.

하려고 한 일

  • SecurityConfig
    • Spring security를 적용하기 위한 구성 요소(AuthToken, AuthTokenProvider, AuthTokenFilter, ...)를 정의했으므로, 이를 사용하기 위한 Spring security Configuration을 작성한다.
    • AuthTokenProvier, AuthTokenFilter를 Spring bean으로 등록한다.
    • 사실 AuthTokenFilter라는 Custom filter를 정의했을 뿐, 실제로 요청이 이 filter를 거치는 게 아니다.
      • 요청이 filter를 거치게 하려면 FilterChain에 등록해야 하며, 이 절차 역시 SecurityConfig에서 작성한다.
  • AuthTokenFilter 예외 처리
    • 모든 요청에서 인증 토큰을 필요로 하는 것은 아니다.
      따라서 이에 대한 처리가 필요하다.

모두 했다.

다음에 할 일

  • Redis 곁들이기
    • Redis 사용을 위한 property 및 dependency 추가
    • 원래는 Refresh token을 위해 사용하는 것이 일반적이나, 여기서는 Access token 만을 위해 사용한다.

아니 Access token만 쓰는데 굳이 Redis를?

JWT는 변조에 강하지만 탈취에는 약하다.
다음과 같은 시나리오를 생각해보자.

  1. 사용자 U가 로그인에 성공해 토큰을 발급받았다.
  2. 공격자 A가 U의 토큰을 탈취했다.
  3. 사용자 U가 로그아웃했다.

만약 로그아웃에서의 절차가 쿠키 삭제 뿐이라면 A는 U의 토큰으로 서비스를 사용할 수 있게 된다(stateless이므로 A가 보내든 B가 보내든 토큰에만 의존적으로 인증한다).

즉 서버에서는 로그아웃 시 해당 사용자의 토큰을 파기된 토큰 으로 간주하고, 주어진 토큰이 파기된 토큰 인지 식별할 수 있어야 한다.
이를 위해 Redis를 사용하며, 다음과 같이 쓰인다.

  1. 사용자 로그아웃 시 해당 토큰을 Redis에 저장한다(만료기간은 토큰의 그것과 동일하게 설정한다).
  2. AuthTokenFilter에서는 요청으로 들어온 토큰이 Redis에 있는지 체크한다.
  3. 요청된 토큰이 Redis에 있다면 파기된 토큰으로 간주하고 예외를 발생시킨다.

그래서 진짜 다음에 할 일은

  • Redis 곁들이기
    • Redis 사용을 위한 property 및 dependency 추가
    • Redis 사용을 위한 Configuration 정의
    • Redis 사용을 위한 Service 정의
    • 파기된 토큰으로 요청된 경우에 대한 예외 정의
    • 로그아웃 처리 내용
  • CORS 설정하기
    • 도대체 그냥 넘어가는 법이 없는 귀염둥이 녀석...
profile
Java를 하고 싶었지만 JavaScript를 하게 된 사람

0개의 댓글

관련 채용 정보