🖥️ 컴퓨터 공부/Spring

SpringSecurity

le2donguk 2025. 8. 27. 23:20

들어가기

최종 프로젝트를 끝나고 코드 리뷰를 하고 있었는데.. 스프링 시큐리티 부분이 많이 신경 쓰였다. 최종 프로젝트 때는 기존에 수업시간에 만들어 논걸 조금씩만 바꿔가면서 스프링 시큐리티에 대해 아무런 이해 없이 쓰다 보니 에러가 터질 때 혹은 SecurityContext를 이 애 활용을 하지 않고 코드를 만들 었었다 

그래서 이번기회에 스프링 시큐리티 중 로그인 인증 파트가 어떻게 동작하는지 확인해보고 , 스프링 시큐리티에서 수업시간에 만든 jwt 관련 필터들 도 알아보고자 한다

 

전체구조

 

1. 요청과 UsernamePasswordAuthenticationFilter

가장먼저 로그인 요청이 PUT /login으로 오면 UsernamePasswordAuthenticationFilter가 해당 요청을 가로채고 자신이 처리한다 이렇게만 알고 넘어가고 싶었는데 그게 어떻게 가능한지 실제로 UsernamePasswordAuthenticationFilter코드를 봤습니다

실제로 코들 봤더니 생성자가 아래와 같았다

private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login","POST");
public UsernamePasswordAuthenticationFilter() {
	super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}

 

 

이 내용은 POST /login 이 들어오면 요청을 가로채는 게 default로 설정되어 있음을 알 수 있었고 

 

실제 인증이 진행되는 attemptAuthentiction 은 아래와 같이 구현 되어 있었다

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
		throws AuthenticationException {
	if (this.postOnly && !request.getMethod().equals("POST")) {
		throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
	}
	String username = obtainUsername(request);
	username = (username != null) ? username.trim() : "";
	String password = obtainPassword(request);
	password = (password != null) ? password : "";
	UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
			password);
	// Allow subclasses to set the "details" property
	setDetails(request, authRequest);
	return this.getAuthenticationManager().authenticate(authRequest);
}

 

내용을 보니 request에서 username과 password를 파싱하고 UsernamePasswordAuthenticationToken을 만들어 AutenticationManger의 authenticate 함수를 실행시키고 거기에 token을 넘겨주는 역할을 한다 

그러면 파싱을 어떻게 하는지 궁금해서 더 찾아 봤는데

 

	@Nullable
	protected String obtainPassword(HttpServletRequest request) {
		return request.getParameter(this.passwordParameter);
	}

	/**
	 * Enables subclasses to override the composition of the username, such as by
	 * including additional values and a separator.
	 * @param request so that request attributes can be retrieved
	 * @return the username that will be presented in the <code>Authentication</code>
	 * request token to the <code>AuthenticationManager</code>
	 */
	@Nullable
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(this.usernameParameter);
	}

 

이렇게 되어있었다.. 어?? getParameter를 쓴다.. 이 말은.. 쿼리파라미터나 HTML Form을 통해서 data를 전송하면 상관은 없지만 우리가 최종 프로젝트 했을 때 사용했던 json 방식으로 data를 주고받으면 인식을 못한다는 거였다..

그리고 이름을 username과 password로 default 가 되어  있기 때문에 이름을 바꾸려면 추가 작업이 필요했다..

 

일단 동작과정이 어떻게 되는지 궁금했기 때문에 parameter로 data가 들어왔다 생각하고 다음 과정을 계속 따라가 보기로 했다 

 

2. AuthenticationManger와 ProviderManager 그리고 인증

 

2.1 Provider 결정

그림을 보자면 지금 AuthenticationManager의 authenticate()를 실행시키고 거기에 UsernamePasswordAuthenticationToken을 넘겨준 상태다 

AuthenticationManger는 인터페이스에 대한 기본 구현체는 ProviderManager 다

이 ProviderManager는 여러 인증 방법을 List에 저장하고 있는데 그 종류는 오른쪽 CasAuthenticationProvider, RemoteAuthenticationProvider , LdapAutheticationProvider , DaoAuthenticationProvider 등이 있다

우리는 DB에서 data를 꺼내와 인증하는 방식을 쓸 거 기 때문에 DaoAuthenticationProvider를 쓸 것이다

 

여기서 잠깐!!

어떻게 Security가 우리가 DaoAuthenticationManger를 쓸 거라고 인지하는 걸까?? 

이 Provider는 UserDetailService를 반드시 필요하다 그런데 우리는 CustomUserDetailsService를 구현하고 이걸 빈으로 등록해 놨다 그럼 Spring이 이걸 감지해 DaoAuthenticationManger라고 인지하는 것!!

 

2.2UserDetailService와 User

그래서 DaoAuthenticationProvider 가 UserDetailService의 loadUserByUsername를 호출하고 이 함수는 DB에서 user 에 대한 정보를 가져온다 이때 가져온 data를 담을 그릇 이 필요한데 그걸 User class가 담당했다 

User는 인증에 필요한 정보 username, password, 권한 정보만 가져온다.. 나는 그 외 생일이라던지 , 주소정보라던지 이런 것들을 더 가져오고 싶어서 CustomUser라는 걸 만들어서 그곳에 data를 담기로 했다

 

3 DaoAuthenticationProvider의 인증 

이렇게 CustomUser에 담긴 data가 DaoAuthenticationProvider에 도착하면 이 클래스는 UsernamePasswordAuthenticationToken 값과 CustomUser을

그리고 인증이 성공을 하면 인증 토큰이 만들어지고  인증이 실패하면 예외가 바로 발생한다 

이렇게 발생하게 되면 SecurityContext에 아무것도 등록이 안되는 것은 물론 이 예외를 바로 잡아서 AuthenticationFailureHandler 가 예외처리를 시작한다

 

4.UsernamePasswordAuthenticationFilter

인증 토큰이 이 필터에 도착하면 SecurityContext에 CustomUser에 담긴 principle 이  AuthenticaitonSuccessHandler 가 수행되면서 SecurityContext에 저장이 된다 

 

이렇게 되면 폼 로그인이나 파라미터로 /login에 대한 인증 과정이 끝났다

 

그런데 우리는 JWT로 로그인 유지 + 인가+ 인증까지 하고 싶다 그렇게 하기 위해선 2개의 Filter를 이 FilterChain에 넣어줘야 한다

 

JWT와 SpringSecurity

 

jwtUsernamePasswordAuthenticationFilter의 역할은 기존에 UsernamePasswordAuthentictionFilter의 역할이었던 

1. url 매칭 & 파라미터 파싱하기

2. Token 만들기

3. Handler 부르기  역할을 모두 수행해야 한다 

 

1. url 매칭 & 파라미터 파싱하기

url을 기존에 /login으로 하면 여기서도 수행하고 밑에 내려가서 UsernamePasswordAuthenticationFilter에서도 수행하게 된다

더욱 문제인 건 거기까지 내려가면 json이 돼서 파싱이 일어나지도 않게 된다 

그래서 url을 /login 이아닌 /v1/auth/login 이런 식으로 바꿔주면 UsernamePasswordAuthenticationFilter 동작도 안 하고 위에 말한 문제가 해결된다

 

이때 Jacksonlibrary를 활용해서 Dto로 json 파싱을 시도해서 자동으로 받으면 json 문제도 해결이 된다

마지막으로 UsernamePasswordAuthenticationFilter의 원래 역할인 파싱 해서 username과 password를 이용해 토큰을 만들어서 AuthenticationManager에게 넘기는 역할을 해야 한다

그래서 DTO 에서 값을 꺼내서 토큰을 만들고 AuthenticationManager에게 넘겨주면 된다

 

마지막으로 Handler를 구성하는 건 setter를 써서 나만의 custom Handler를 등록했다

말은 긴데 코드로 보면 다음과 같다

@Log4j2
@Component
public class JwtUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {



    public JwtUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager, LoginSuccessHandler loginSuccessHandler, LoginFailureHandler loginFailureHandler) {
        super(authenticationManager);
        setFilterProcessesUrl("/v1/auth/login");
        setAuthenticationSuccessHandler(loginSuccessHandler);
        setAuthenticationFailureHandler(loginFailureHandler);
    }

    //로그인 요청 url인 경우 로그인 작업 처리
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,HttpServletResponse response) throws AuthenticationException {
        //요청BODY의 JSON에서 username,passwordLoginDTO
        LoginDTO login= LoginDTO.of(request);

        //인증토큰(UsernamePasswordAuthenticationToken)구성
        UsernamePasswordAuthenticationToken authenticationToken= new UsernamePasswordAuthenticationToken(login.getUser_id(), login.getPassword());

        //AuthenticationManager에게인증요청
        return getAuthenticationManager().authenticate(authenticationToken);
    }
}

 

결국에는 getAuthenticationManager(). authenticate를 호출하기 때문에 인증까지 쭉 진행된다

 

그럼 다음 필터 JwtAuthenticationFilter에 알아보자

 

JwtAuthenticationFilter

 위에 필터는 처음 로그인 했을 때 다뤘다면 이 필터는 요청을 보낼 때 jwt 토큰이 유효한지  , 유효시간이 지나지 않았는지를 담당하는 필터다 이 검증은 모든 요청에 한 번만 검증하면 되기 때문에 OncePerRequestFilter를 상속받았다

 



@Component
@Log4j2
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final JwtProcessor jwtProcessor;
    private final UserDetailsService userDetailsService;

    private Authentication getAuthentication(String token) {
        String username = jwtProcessor.getUsername(token);
        UserDetails principal = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());
    }


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
        String bearerToken=request.getHeader(AUTHORIZATION_HEADER);
        if(bearerToken!=null && bearerToken.startsWith(BEARER_PREFIX)){
            String token=bearerToken.substring(BEARER_PREFIX.length());

            //토큰에서 사용자정보추출 및 Authentication객체 구성후 SecurityContext에저장
            Authentication authentication=getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        super.doFilter(request,response,filterChain);
    }
}

 

1. 내용을 보면 request header에서 jwt 토큰을 추출하고

2. 토큰에서 사용자 정보를 추출 + jwt 토큰 검증까지 진행한다 이 과정은 jwtProcessor 가 담당하게 된다

3. 사용자 정보가 잘 꺼내졌으면 이 내용을 SecurityContext에 저장한다

 

이렇게 하면 모든 요청에 대해서 jwt 검증 + 사용자 정보를 securitycontext에 저장할  수 있게 되고 json으로 로그인 data를 줘도 문제없이 잘 돌아간다

 

마무리..

기존에는 이런 SecurityContext에 사용자 정보가 있는지 몰라서 service 단에서 또 jwt parsing을 하고 거기서 또 사용자 정보를 꺼내고 이런 과정을 두 번 가져갔었다

그리고 매 컨트롤러에 거의 이런 작업을 하다 보니.. 많이 아쉽다..

 

사실 jwtAuhtenticationFilter 다음에 jwt 예외처리를 담당하는 필터가 또 있다 거기선 모든 예외처리를 담당하는데 조금 더 그 부분을 상세하게 작성하면 좋을 것 같다..!!

 

security가 정말 어렵게 느껴졌는데 그래도 흐름이 어떻게 흘러가는지 알게 돼서 뜻깊었다