본문 바로가기
프로젝트/Recipository

[Dev] 22.12.29. 작성자와 다른 사용자의 수정, 삭제 요청 방어(feat. Interceptor)

by 규글 2022. 12. 29.

고민

 사용자의 정보를 확인하고 수정하는 등의 기능을 작업하기에 앞서 고민이 생겼다. 게시글 작성자가 아닌 경우에 게시글을 수정, 삭제를 시도하는 경우가 생기는 경우를 어떻게 막아야 할까? 

 

 우선 지난 국비 과정에서의 경우는 게시글을 삭제하는 경우에도 GET 방식 request를 통해 동작하도록 했다. 당시에는 request 방식이 GET과 POST 만 존재하는 줄 알고 그랬으나, Spring Boot 강의를 수강하면서 PUT과 DELETE 방식이 있다는 것을 알게 되어서 이번에 그것을 적용해보았다.

 

 기본적으로 인터넷 주소창을 통해 요청하는 것은 GET 방식인데, 때문에 DELETE 방식 요청의 url을 동일하게 작성하더라도 원하는 동작을 하도록 할 수 없다. 하지만 게시글 수정하는 page로의 요청은 GET 방식으로 이루어진다. 따라서 현재는 어떤 사용자든지 게시글의 수정 form page로 이동할 수 있고, javascript로 작성해둔 form의 제출이 게시글의 작성자가 아니라도 가능한 상황이다. 이 상황에 대해 개선하는 방향은 다음의 두 가지로 생각할 수 있다.

  • 게시글 수정 form page로의 접근부터 막는 것.
  • 접근은 되더라도 form의 제출은 불가능하도록 막는 것.

 역시 접근부터 막는 것이 좋은 것 같다. 더하여 어떤 알 수 없는 방법으로 게시글의 삭제 버튼에 대한 동작을 요청할 수 있다는 점까지 포함하여 두 가지 요청을 막아보려고 한다. 이를 확장시킨다면 게시글에 작성된 댓글을 삭제하는 과정에까지 적용할 수 있을 것이다.

 

작업

 아이디어는 간단하다. 게시글 수정 페이지로의 요청, 게시글 삭제 요청, 댓글 삭제 요청에 대하여 현재 접속한 사용자의 정보와 작성된 게시글이나 댓글의 작성자의 정보가 다른 경우 동작하지 않도록 하면 된다.

 

 현재 로그인한 사용자의 정보를 불러오는 방법은 위 이미지에 작성한 두 가지가 있다. 첫째는 @AuthenticationPrincipal annotation을 활용하는 것, 둘째는 SecurityContextHolder를 활용하는 것이다. 이외에도 Principal 객체를 그대로 전달받는 방식도 있으나 이전 게시글에서도 한 번 언급한 것처럼 그 Principal은 java에 정의된 것을 binding 하는 것으로, username 밖에 가져올 수 없다.[각주:1]

 

 필자는 후자의 방법을 택해서 활용하려고 한다. 전자의 방법도 상관없지만 그만한 이유가 있다. 이후에 언급하도록 한다.

 

 예를 들어 게시글을 삭제하는 경우에 삭제 요청을 하는 로그인한 사용자와 게시글의 작성자가 다른 경우 false를 return 하도록 하여 게시글을 삭제하지 못하게 막을 수 있다. 이와 같은 내용을 게시글을 수정하는 logic과 댓글을 삭제하는 logic에 넣을 수도 있지만, 이와 비슷하게 사용자와 작성자의 일치 여부를 확인해야 하는 곳이 많을 경우에 이들을 모두 복사 붙여넣기 한다면 유지 및 보수 면에서 효율적이지 않을 것을 예상할 수 있다. 물론 그 전에 복사 붙여넣기를 해야하는 곳이 많아지는 것은 단순한 문제일 뿐이다.

 

 이제 이 작업에 interceptor를 활용해볼 것이다. Intercept 는 '중간에 가로챈다.'는 사전적 의미를 가지는데, 말 그대로 요청을 중간에 가로챈다고 생각하면 되겠다.

 

AuthInterceptor.java

package com.example.recipository.interceptor;

import com.example.recipository.domain.Comment;
import com.example.recipository.domain.Recipe;
import com.example.recipository.domain.SpUser;
import com.example.recipository.repository.CommentRepository;
import com.example.recipository.repository.RecipeRepository;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

@Component
public class AuthInterceptor implements HandlerInterceptor {
    private final RecipeRepository recipeRepository;
    private final CommentRepository commentRepository;

    public AuthInterceptor(RecipeRepository recipeRepository, CommentRepository commentRepository) {
        this.recipeRepository = recipeRepository;
        this.commentRepository = commentRepository;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {

        // SecurityContextHolder로부터 Principal 을 불러옴
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        SpUser spUser = (SpUser)authentication.getPrincipal();

        // URI의 path variable을 Map으로 불러와서 그 중에 contentId 의 value를 가져옴
        Map<?,?> pathVariables =
                (Map<?,?>)request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
        // 초기값
        Long id = null;
        boolean beMatched = false;

        // 게시글인 경우의 key와 댓글인 경우의 key를 분기
        if(pathVariables.containsKey("contentId")){
            id = Long.parseLong((String)pathVariables.get("contentId"));

            // DB의 data와 비교해서 아닌 경우 banned page로 이동하게 하고 false를 return
            // 같은 경우는 true를 return하고 정상적으로 동작이 수행되도록 함
            Recipe recipe = recipeRepository.getRecipeByContentId(id);
            beMatched = spUser.getUsername().equals(recipe.getWriter());

        } else if(pathVariables.containsKey("commentId")){
            id = Long.parseLong((String)pathVariables.get("commentId"));

            Comment comment = commentRepository.getCommentByCommentId(id);
            beMatched = spUser.getUsername().equals(comment.getWriter());
        }

        if(!beMatched){
            response.sendRedirect(request.getContextPath() + "/banned");
        }

        return beMatched;
    }
}

 HandlerInterceptor를 implement한 class를 만들어주고 preHandle method를 override 한다. 글자 그대로 특정 동작에 앞서서(pre-) 동작하는 method라고 생각하면 되겠다. 전달받는 path variable에 맞는 DB로부터의 게시글, 댓글 data의 작성자와 비교하여 일치하지 않는 경우 ban page로 redirect 시키고 false를 return시키고, 일치하는 경우는 그대로 true를 return 하여 원래 요청에 맞는 동작이 수행되도록 하는 내용을 작성했다.

 이렇게 Interceptor를 활용했기 때문에 Security Context Holder를 활용한 것이다.

 

 이때 attribute의 key는 controller의 Mapping 에 작성해준 사항을 기반으로 mapping 되더라. 그래서 이에 따라 interceptor에서도 분기했다.

 

 Path variables를 Map으로 가져오는 것은 블로그를 참고했다.[각주:2] [각주:3]

 

MvcConfiguration.java

package com.example.recipository.config;

import com.example.recipository.interceptor.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
    private final AuthInterceptor authInterceptor;

    public MvcConfiguration(AuthInterceptor authInterceptor) {
        this.authInterceptor = authInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/user/content/**", "/user/del-comment/**");
    }
}

 앞서 만든 AuthInterceptor를 WebMvcConfigurer를 implements 했던 MvcConfiguration에서 addInterceptors method를 override 하여 등록해준다. 이때 특정 request path pattern에서 interceptor가 동작하도록 path를 설정해줄 수 있다.

 기존에는 프로젝트 작업을 시작하기 전에 이런 저런 테스트를 하고 모두 주석 처리를 했던 class였는데, 이렇게 interceptor를 활용하는데에 사용되어 그 존재의 의미를 찾게된 친구이다.

 

SpringSecurityConfig.java

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(request ->
                        request.antMatchers("/", "/signinform", "/signin",
                                        "/duplcheck", "/content/**", "/banned")
                                .permitAll()
                                .anyRequest().authenticated()
                )
                
                (...)

 그리고 WebSecurityConfigurerAdapter 를 extends 한 SpringSecurityConfig 에서 ban page로의 request를 permitAll 하기 위해 추가해주었다.

 

PageController.java

    // 게시글 수정 및 삭제, 댓글 삭제에 대해 권한이 없는 경우
    // ban page로 이동
    @GetMapping("/banned")
    public String banned(){
        return "pages/banned";
    }

 Ban page로의 이동 요청에 대한 controller의 method를 추가해주었다.

 

banned.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>접근할 수 없습니다.</title>
</head>
<link rel="stylesheet" href="/lib/css/bootstrap.css">
<body>
    <div class="container text-center" style="margin-top: 100px;">
        <h1>당신에게는 유효하지 않은 동작입니다.</h1>
        <button class="btn btn-secondary" onclick="location.href='/'">메인 페이지로 이동</button>
    </div>
</body>
</html>

 마지막으로 ban page 의 구성이다. 별다른 특별한 점은 없다.

 

content.js

        promise.then(function(response){
            return response.json();
        }).then(function(data){
            // 삭제 성공 여부에 따라 다른 동작을 하도록
            // 이것은 작성자가 다른 요청의 경우 data에 beDelete 대신 에러 data가 나오는데
            // 그 중에서 path 정보가 있다.
            if(data.path != null){
                location.href = data.path;
            } else if(data.beDeleted){
                alert("댓글을 삭제하였습니다.");
                document.querySelector("#input" + data.commentId).value = "삭제된 댓글입니다.";
                document.querySelector("#reply" + data.commentId).remove();
                document.querySelector("#delete" + data.commentId).remove();
            } else if(!data.beDeleted){
                alert("댓글 삭제에 실패하였습니다. 문제가 반복된다면 문의 바랍니다.");
            }
        });

 

 게시글을 수정하기 위한 update form page로 이동하는 GET request의 경우는 interceptor를 통과하여 바로 redirect page로 이동하지만, 게시글을 삭제하고 댓글을 삭제하는 PUT, DELETE request의 경우는 fetch 를 통해서 ajax request가 수행된다. 그로 인해 달라지는 점은 ajax request를 보내고 response를 받을 때 원하는 data를 받는 것이 아니라 오류에 대한 data를 받게 된다는 것인데, 그 path 에 대한 값으로 interceptor에서 redirect 시킨 내용을 얻을 수 있다. 따라서 이에 대한 동작을 추가적으로 작성해준 것이다.

 

 

 작성자가 아닌 사용자로부터의 게시글 update form page로의 이동 요청과, 게시글과 댓글에 대한 삭제 요청에 대하여 interceptor를 추가하는 것으로 고민에 대한 해결을 마쳤다. 이어서 유저 정보와 관련된 기능을 작업하기에 앞서 다음과 같이 수정하고 넘어가야 할 내용들이 있다.

  • 요청 URL을 보다 명확하게 변경
  • 게시글이나 작성자 항목에 로그인 Email이 아닌 Nickname 정보를 저장하고 출력하도록 변경

 이들을 간단히 체크한 뒤에 작업을 이어나가도록 한다.

 

Reference

댓글