주요개발 환경
SpringBoot 3.x
React 18.x
Spring Security
문제 개요
클라이언트에서 localhost:3000
으로 메인페이지가 설정되어있고 해당 URL을 호출하면 아래와 같이 서버에 fetch
를 보내게 된다.
const response = await fetch('http://localhost:8080/products', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
처음에는 "일단 프로그램을 동작하도록 구현해놓고 나중에 리팩터링하자" 라는 생각으로 메인페이지에서는 인증이 필요없음에도 불구하고 HTTP 헤더에 세션스토리지로부터 조회한 JWT토큰을 함께 요청하도록 하였습니다.
그 결과, SpringSecurity설정에서 아래와 같은 예외가 발생하였습니다.
(예외 경로 일부 생략)
java.lang.NumberFormatException: Cannot parse null string
provider.TokenProvider.validate(TokenProvider.java:44)
common.filter.AuthenticationFilter.doFilterInternal(AuthenticationFilter.java:51)
io.jsonwebtoken.MalformedJwtException: JWT strings must contain exactly 2 period characters. Found: 0
추가로 비정상적으로 작동하던 BEFORE코드를 첨부하고 문제 개요를 마치겠습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationFilter authenticationFilter;
//TODO 현재 모든 요청에대해서 authenticationFilter가 동작하는데 부분적으로 작동하도록 설정하기 옵시디언 필터 메모 참고
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/","/error", "/users/**").permitAll()
.anyRequest().authenticated()
);
http.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("Start my filter ...");
try {
String token = parseBearerToken(request);
if (token == null) {
log.info("토큰이 비어있습니다.");
filterChain.doFilter(request, response);
return;
}
//토큰이 위조되었는지 확인하고 위조되지 않은 토큰일 경우 USER의 식별자 반환
String userId = tokenProvider.validate(token);
if (userId == null) {
log.info("유효한 토큰이 아닙니다.");
filterChain.doFilter(request, response);
}
User user = userRepository.findById(Long.valueOf(userId))
.orElseThrow(() -> new BadRequestException(DEFAULT_MESSAGE));
String role = user.getUserType()
.toString();
log.debug("user.getRole.toString: {}", role);
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
// 수정 user.getUsername -> user.getId UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getId(), null, authorities);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
securityContext.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(securityContext);
} catch (Exception e) {
e.printStackTrace();
}
filterChain.doFilter(request, response);
}
private String parseBearerToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
boolean hasAuthorization = StringUtils.hasText(authorization);
if (!hasAuthorization) {
log.warn("요청에 토큰이 존재하지 않습니다.");
return null;
}
boolean isBearer = authorization.startsWith("Bearer ");
if (!isBearer) {
log.warn("토큰이 존재하지만 Bearer토큰이 아닙니다.");
return null;
}
return authorization.substring(7);
}
}
export default function MainProductListView() {
const [products, setProducts] = useState([]);
const token = sessionStorage.getItem('token');
useEffect(() => {
async function fetchProducts() {
try {
const response = await fetch('http://localhost:8080/products', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('상품을 불러오는데 실패했습니다.');
}
const data = await response.json();
setProducts(data);
} catch (error){
console.error('Error fetching products', error);
}
}
fetchProducts();
}, []);
(불필요하다 판단되는 코드는 생략)
문제 원인 찾고 해결해보기
- 요구사항은 클라이언트의 메인페이지인
localhost:3000
에 접근시 등록되어있는 상품들의 목록에 접근할 수 있도록 하려했지만 클라이언트에서 HTTP요청시에 컨트롤러에 접근하기 전에 직접 커스텀한AuthenticationFilter
필터의User user = userRepository.findById(Long.valueOf(userId))
에서null
을 parse하려는 예외가 1차적으로 발생한다. - 모든상품의 목록을 조회하는
/products
에 대해서 비로그인상태, 로그인상태를 구분하지않도록 접근할 수 있도록 설정해야한다.
Security Config
에 적용한requestMatchers()
에 포함되는 경로는 메서드 파라미터에 포함되는 경로에 대해서는 인증을 수행할 필요가 없다는 의미일뿐 필터가 동작하지 않는다는 것을 의미하지는 않는다.
위 개념을 활용하여 구현하면 필터가 동작함으로써 Token을 해석하고 USER를 조회하는 과정에서null
을 parse하는 예외는 해결할 수 있을듯하다.
문제해결해보기SecurityContext
에 등록한 커스텀 필터인 AuthenticationFilter
에서 doFilterInternal()
메서드가 동작할 때 전처리로 요청 URI를 확인하여 /products
가 포함되면 dofilter()
메서드를 호출하여 다음필터로 넘겨버리는 방법을 사용했다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI()
.startsWith("/products")) {
filterChain.doFilter(request, response);
return;
}
...
User user = userRepository.findById(Long.valueOf(userId))
.orElseThrow(() -> new BadRequestException(DEFAULT_MESSAGE));
....이하 생략
그 결과 유저를 조회하는 코드까지 호출되지 않도록하였고 예외를 막을 수 있었다.
이제 하나의 문제를 해결하였고 다시 메인페이지에서 상품을 조회하도록 요청하니 클라이언트에서 아래와 같은 에러가 발생하였다.
MainProductListView.jsx:12
GET http://localhost:8080/products 403 (Forbidden)
fetchProducts @ MainProductListView.jsx:12
MainProductListView.jsx:23 Error fetching products Error: 상품을 불러오는데 실패했습니다.at fetchProducts (MainProductListView.jsx:18:1)
Forbidden이 발생한 것을 보아하니 이 역시 Security에서 파생된 권한관련 문제라고 생각하였다.
문제를 해결하기위해서 에러에서 명시된 프론트코드 MainProductListView.jsx를 가보니 아래와 같이 필요없는 코드가 작성되어있는 것을 확인하였다.
const token = sessionStorage.getItem('token');
useEffect(() => {
async function fetchProducts() {
try {
const response = await fetch('http://localhost:8080/products', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
/products
요청을 보낼때에는 모든 사용자에게 인증, 인가 필요없이 접근하도록 백엔드에서 설정하였기때문에 클라이언트는 토큰을 가지고있지 않는 경우도 있을뿐더러 설령 로그인하여 토큰을 발급받았다하였더라도 굳이 백엔드로부터 해당 토큰을 인증받을 필요가없기때문에 위코드를 제거하였다.
또한 권한설정이 옳바르게 되어있는지 확인하기위해 SecurityConfig
클래스를 확인해 보았고, "/products"
에 대한 접근시 인증을 처리하도록 설정되어있었다.
때문에 "/products"
경로를 permitAll
하도록 설정해주었다.
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/","/error", "/users/**", "/products").permitAll()
.anyRequest().authenticated()
);
클라이언트에서는 localhost:3000
으로 메인페이지 리소스를 요청하지만 실제 프론트 서버에서는 localhost:8080/products
경로를 통해서 백엔드에 상품목록에 대한 리소스를 요청하기때문에 인증 범위에대해서 "/products"
를 추가해주어야했다.
위의 과정을 통해 에러를 잡으니 비로그인상태에서도 메인페이지에서 상품목록들을 조회할 수 있었다!
사이드 이펙트 확인해보기
SecurityConfig
클래스에 "/products"
경로에 대한 접근을 인증하지 않도록 추가해주었다.
하지만 내 프로젝트에서는 상품을 등록할 때에는 반드시 로그인이 되어있는 상태에서 상품추가에 접근할 수 있도록 해야만한다.
따라서 상품등록 리소스인 GET - /products/new
에 비로그인 상태에서 접근이 불가능한지 확인할 필요가 있다.
상품을 등록하는 과정은 아래와 같다.
- 로그인
- 상품등록 폼으로 이동
- 상품등록 POST 요청을 통하여 서버에 상품을 등록
위 과정에서 만약 로그인이 되어있지 않은 상태라면 로그인창으로 리다이렉트해야한다.
현재 애플리케이션에서 확인해보니 비로그인상태에서 상품등록페이지로 이동은 가능하지만 모든 폼을 입력한후 등록하기 버튼을 클릭하니 403Forbbiden이 발생한다.
원하는 요구사항은 상품등록 페이지에 접근자체를 못하도록하는 것이다.
이 문제에 대해서는 다음 포스팅에서 해결해보도록하겠다.
'디버깅 & 리팩터링' 카테고리의 다른 글
회원가입 폼, 상품등록 폼 제공을 프론트에서? 백에서? (2) | 2024.10.02 |
---|
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!