Error
요청을 보낸 컨트롤러의 코드는 아래와 같다.
@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
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 를 수도 없이 바꿔가며 테스트 한 후 였다!
GPT 와의 키보드 배틀에서 진 후 다시 구글링을 시도 했고, 우연히 이 포스팅을 보게 되었다.
위와 같은 상황이 공통적으로 발생하고 있었다.
해당 글에서는 Custom Authentication
클래스 를 사용하고 있는데, 커스텀 Authentication 클래스를 사용할 때 권한을 인가할지라도 인증은 되지 않아 authenticated 값이 자동으로 설정되지 않았던 것이다.
이 부분에서 힌트를 얻어 Authentication 객체를 생성하는 부분을 찾기 시작했다.
로그인 컨트롤러
Authentication 부분을 보면 UsernamePasswordAuthenticationToken
을 생성할 때 파라미터를 두 개 요구하는 생성자를 활용하고 있다.
UsernamePasswordAuthenticationToken.class
그리고 UsernamePasswordAuthenticationToken 클래스를 보면 내가 사용하고 있는 생성자는 setAuthenticated(false); 인 것을 확인할 수 있다.
권한을 인가할지라도 인증은 되지 않는 상황이 나에게도 벌어지고 있었던 것이다.
Solution
permitAll()
)에서 UsernamePasswordAuthenticationToken
을 생성할 때는 authorities가 없다. authenticated()
)의 경우 JwtAuthenticationFilter 의 doFilterInternal 메서드에서 토큰이 유효할 경우 authorities를 포함한 UsernamePasswordAuthenticationToken
을 생성한다.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 계정의 토큰으로 요청하는 경우에만 정상적으로 응답이 오는 것을 확인할 수 있다.