[ERROR] Spring Security requestMatchers.hasRole() 403 FORBIDDEN

zirryo·2024년 11월 14일
0

❌ ERROR

목록 보기
7/7
post-thumbnail

Error

1. 권한을 확인하는 요청에서 에러 발생


요청을 보낸 컨트롤러의 코드는 아래와 같다.

@PatchMapping("/members/me")
public ResponseEntity updateMemberInfo(@RequestBody MemberDto.ModifyPhoneReq dto,
                                           @RequestHeader("Authorization") String token) {
    Long id = jwtTokenProvider.getMemberIdFromJWT(token);
        memberFacadeService.modifyMemberInfo(id, dto);
    String response = "회원정보가 정상적으로 변경되었습니다.";
    return new ResponseEntity<>(response, HttpStatus.OK);
}

데이터베이스에는 "ROLE_ADMIN", "ROLE_MEMBERS" 의 형태로 등록되어 있다.


.requestMatchers("/**").permitAll() 로 설정해둔 채로 postman 테스트를 진행하다가, 사용자 권한을 기준으로 특정 엔드포인트에 대해 접근 제한을 적용하고자

        .authorizeHttpRequests(auth -> auth
//     .requestMatchers("/**").permitAll()
        .requestMatchers("/auth/**", "/join/**", "/teams/**", "/games/mostPopular", "/games/weekly").permitAll()
        .requestMatchers("/myteam/**", "/members/**", "/reservation/**").hasRole("MEMBER")
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated())

위와 같이 SecurityConfig 를 수정한 후, POSTMAN 으로 요청을 보냈더니 403 에러 가 발생했다.


콘솔에는 아무런 에러 로그도 출력되지 않았다.



Try

2. hasRole(), hasAuthority()의 차이


requestMatchers 403 이라는 키워드로 구글링을 해보니 대부분 이로 인한 문제인 경우가 많았다. chat-gpt 에게 물었을 때도 계속해서 관련 답변을 하는 것을 볼 수 있었다.

  • hasRole() : Spring Security 에서 ROLE_ 접두어가 자동으로 붙는 방식으로 처리되기 때문에, authorities에 있는 "ROLE_MEMBER"가 아니라 "MEMBER"로 권한을 비교하려고 한다.
  • hasAuthority() : "ROLE_" 접두어가 붙은 권한을 그대로 사용하므로, hasAuthority("ROLE_MEMBER")로 변경해야 합니다.

즉, Authorities 에 ROLE_ 접두사가 붙은 형태로(본인의 경우) 저장되어 있는 경우에는 hasRole("MEMBER") 혹은 hasAuthority("ROLE_MEMBER") 의 형태로 코드를 작성해야 한다.

이미 내가 작성한 코드는 해당 조건을 만족하고 있기 때문에, 이 방법으로 문제를 해결할 수 없었다. 물론 이미 두시간 동안 DB에 ROLE를 붙였다가 뗐다가... hasRole, hasAuthority 를 수도 없이 바꿔가며 테스트 한 후 였다!



3. UsernamePasswordAuthenticationToken 생성


GPT 와의 키보드 배틀에서 진 후 다시 구글링을 시도 했고, 우연히 이 포스팅을 보게 되었다.

  • 로그인 성공
  • 인증 필터를 거치지 않는 요청은 에러 발생하지 않음
  • 에러 로그 발생하지 않음

위와 같은 상황이 공통적으로 발생하고 있었다.

해당 글에서는 Custom Authentication 클래스 를 사용하고 있는데, 커스텀 Authentication 클래스를 사용할 때 권한을 인가할지라도 인증은 되지 않아 authenticated 값이 자동으로 설정되지 않았던 것이다.


이 부분에서 힌트를 얻어 Authentication 객체를 생성하는 부분을 찾기 시작했다.

로그인 컨트롤러

Authentication 부분을 보면 UsernamePasswordAuthenticationToken 을 생성할 때 파라미터를 두 개 요구하는 생성자를 활용하고 있다.


UsernamePasswordAuthenticationToken.class

그리고 UsernamePasswordAuthenticationToken 클래스를 보면 내가 사용하고 있는 생성자는 setAuthenticated(false); 인 것을 확인할 수 있다.

권한을 인가할지라도 인증은 되지 않는 상황이 나에게도 벌어지고 있었던 것이다.



Solution

4. JwtAuthenticationFilter 에서 권한 제한


  • 로그인 컨트롤러(permitAll())에서 UsernamePasswordAuthenticationToken을 생성할 때는 authorities가 없다.
    • authenticated = false
  • 다른 요청(authenticated())의 경우 JwtAuthenticationFilter 의 doFilterInternal 메서드에서 토큰이 유효할 경우 authorities를 포함한 UsernamePasswordAuthenticationToken 을 생성한다.
    • authenticated = true

JwtAuthenticationFilter ( 변경 전 )

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String jwt = getJwtFromRequest(request);

      // 토큰이 있는지, 유효한 토큰인지 검증 <- jwtTokenizer 
        if (jwt != null && jwtTokenizer.validateToken(jwt)) {
            String username = jwtTokenizer.getUsernameFromJWT(jwt);
            CustomUserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (userDetails != null) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                        // authorities 를 포함한 인증된 UsernamePasswordAuthenticationToken
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

              SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }

위의 코드에서 "/admin" 으로 시작하는 몇 개의 API가 ROLE_ADMIN 인 사용자만 허용하도록 하는 조건을 추가한다.


JwtAuthenticationFilter ( 변경 후 )

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String jwt = getJwtFromRequest(request);

      // 토큰이 있는지, 유효한 토큰인지 검증 <- jwtTokenizer 
        if (jwt != null && jwtTokenizer.validateToken(jwt)) {
            String username = jwtTokenizer.getUsernameFromJWT(jwt);
            CustomUserDetails userDetails = userDetailsService.loadUserByUsername(username);

			------------------------추가 된 부분 -----------------------------------
			if (request.getRequestURI().startsWith("/admin")) {
                if(!userDetails.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
                    response.sendError(HttpServletResponse.SC_FORBIDDEN);
                    return;
                }
            }
			------------------------추가 된 부분 -----------------------------------

            if (userDetails != null) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                        // authorities 를 포함한 인증된 UsernamePasswordAuthenticationToken
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

              SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }

SecutiryConfig ( 변경 후 )

        .authorizeHttpRequests(auth -> auth
        .requestMatchers("/auth/**", "/join/**", "/teams/**", "/games/mostPopular", "/games/weekly").permitAll()
        .anyRequest().authenticated())

로그인 하지 않은 사용자도 모두 접근가능한 엔드포인트는 permitAll(),
다른 요청들은 토큰 검증 절차를 거치도록 변경하였다.


MEMBER, ADMIN 계정으로 각각 로그인을 진행한 후 토큰만 다르게 "admin/**" 요청을 보내보면,

MEMBER 토큰으로 요청을 보낸 경우

ADMIN 토큰으로 요청을 보낸 경우

ADMIN 계정의 토큰으로 요청하는 경우에만 정상적으로 응답이 오는 것을 확인할 수 있다.




🔗 reference

0개의 댓글