읽은 책 정리/코드로 배우는 스프링 웹 프로젝트

[Spring] 37 기존 프로젝트에 스프링 시큐리티 접목하기

포포015 2021. 3. 3. 23:57

-스프링 시큐리티를 사용할때 POST 방식의 전송은 반드시 CSRF 토큰을 사용하도록 추가해야한다.

 

기존의 프로젝트에 시큐리티 접목하는 작업은 아래와 같은 순서로 진행 한다.

 

1) 로그인, 회원가입 페이지 작성

2) 기존화면과 컨트롤러에 시큐리티 관련 내용 추가

3) Ajax 부분의 변경

 

-기존의 예제에서 새로운 프로젝트를 작성한다.

 관련 설정 추가

* security-context.xml 추가 / org.zerock.security 및 이하 패키지 추가 /

org.zerock.domain 내에  MemberVO와 AuthVO 클래스 추가/

web.xml에 security-context.xml 설정과 필터 추가 / 

MemberMapper 인터페이스와 MemberMapper.xml 추가 / 

org.zerock.controller 패키지에 CommonController 추가

 

 

로그인 페이지 처리

로그인 페이지를 작성할때 신경써야 하는부분은 아래와 같다

1) JSTL이나 스프링 시큐리티의 태그를 사용할수 있도록 선언.

2) CSS나 JS 파일 링크는 절대 경로로 선언

3) <form> 태그 내의 <input>태그의 name 속성을 스프링 시큐리티에 맞게 수정

4) CSRF 토큰 항목 추가

5) JavaScript를 통한 로그인 전송

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
    <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
                        <form role="form" method="post" action="/login">
                            <fieldset>
                                <div class="form-group">
                                    <input class="form-control" placeholder="userid" name="username" type="email" autofocus>
                                </div>
                                <div class="form-group">
                                    <input class="form-control" placeholder="Password" name="password" type="password" value="">
                                </div>
                                <div class="checkbox">
                                    <label>
                                        <input name="remember" type="checkbox" value="Remember Me">Remember Me
                                    </label>
                                </div>
                                <!-- Change this to a button or input when using this as a form -->
                                <a href="index.html" class="btn btn-lg btn-success btn-block">Login</a>
                            
                                    <!--  csrf 공격 방어를 위해 동적 생성 -->
                                <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token }" />
                                
                            </fieldset>
                        </form>
 
<script type="text/javascript">
    
    $(".btn-success").on("click"function(e){
        
        e.preventDefault();
        $("form").submit();
        
    });
    
</script>
cs

 

이대로 로그인 하면 ,

이전의 예제는 로그인 성공후 CustomLoginSuccessHandler를 이용해서, 사용자의 권한에 따라 이동하기로 했기때문에

제대로 처리 되지않을것이다.

 

스프링 시큐리티는 기본적으로 로그인후 처리를 SavedRequestAwareAuthenticationSuccessHandler라는 클래스를 이용

(해당 클래스는 사용자가 원래 보려고 했던 페이지의 정보를 유지해서 로그인후 다시 원했던 페이지로 이동하는방식)

 

SavedRequestAwareAuthenticationSuccessHandler를 이용하는 설정은 기존의 XML에서

customLoginSuccess 를 빈 설정을 사용하지않고,

authentication-success-handler-ref 속성을 사용안해야한다. 아래와같이 변경

1
        <security:form-login login-page="/customLogin"/>
cs

 

게시물 작성 시 스프링 시큐리티 처리

게시물 리스트의 경우 아무제약없이 보여주고, 게시물 작성시 로그인한 사용자에 한해서 처리 한다.

servlet-context.xml에는 스프링 시큐리티 관련 설정을 추가하고, Controller에 어노테이션을 통해 제어 한다.

1
2
//네임스페이스로 시큐리티 추가후 ,버전 5.0을 지워줌    
<security:global-method-security pre-post-annotations="enabled" secured-annotations="enabled"/>

 
cs

흐름) 게시물작성 > 로그인되있는경우 > 바로 작성페이지으로 이동 .

                          로그인 안된경우 > 로그인 페이지로 이동 > 로그인후 > 작성 페이지로 이동

 

@PreAuthorize 어노테이션 ( 표현식을 통해 제어 로그인이 인증된 사용자만 처리하도록.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    @PreAuthorize("isAuthenticated()"//인증된 사용자라면 true
    @GetMapping("/register")
    public void register(){
        
    }
 
    @PreAuthorize("isAuthenticated()"//인증된 사용자라면 true
    @PostMapping("/register")
    public String register(BoardVO board,RedirectAttributes rttr) {
 
        if(board.getAttachList() != null) {
            
            board.getAttachList().forEach(attach -> log.info(attach));
            
        }
        
        log.info("register 컨트롤러 --------:"+board);
        // 등록처리 후 , rttr객체로 result라는 변수로 리다이렉트 해서 일회성으로 bno 값을보내줌
 
        if(board.getAttachList() != null) {
            board.getAttachList().forEach(attach -> log.info("컨트롤러 attach -------" +attach));
        }
        
        log.info("------");
        
        service.register(board);
//        rttr.addFlashAttribute("result", board.getBno());
        
        return "redirect:/board/list";
        }
cs

 

게시물 작성시 로그인한 사용자의 아이디 출력 (시큐리티 태그라이브러리 설정)

-스프링 시큐리티에서는 username이 사용자의 아이디 이다.

-스프링 시큐리티를 사용할때 POST 방식의 전송은 반드시 CSRF 토큰을 사용하도록 추가해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<%@ taglib uri="http://www.springframework.org/security/tags" prefix="sec" %>
 
            <form role="form" action="/board/register" method="post">
                    <div class="form-group">
                        <label>Title</label>
                        <input class="form-control" name="title">
                    </div>
                    
                    <div class="form-group">
                        <label>Content</label>
                    <textarea class="form-control col-sm-5" rows="5" name="content"></textarea>
                    </div>
 
                
                    <div class="form-group">
                        <label>Writer</label>
                         <input class="form-control" name="writer" value='<sec:authentication property="principal.username"/>' readonly="readonly">
                    </div>
                 <button type="submit" class="btn btn-default">Submit Button</button>
                 <button type="reset" class="btn btn-default">Reset Button</button>
            
                <!--  csrf 공격 방어를 위해 동적 생성 -->
                <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token }" />
            
            </form>
cs

 

스프링 시큐리티 한글처리

스프링 시큐리티 적용이후 한글이 깨지는 문제가 발생할 경우, 

한글처리는 web.xml을 이용해서 스프링의 EncodingFilter를 이용해 처리하는데, 

시큐리티를 필터로 적용할때는 필터의 순서를 주의해서 설정 1) 인코딩 2) 시큐리티 필터 적용 순으로 적용한다

 

게시물 조회와 로그인처리

일반적인 경우라면 게시물 조회는 로그인 여부에 관계없이 처리되지만,

게시물의 조회화면에서 현재 로그인한 사용자만이 수정/삭제 작업 할수 있는 기능을 활성화 시켜줘야함

ex) 로그인 하지 않았거나 다른 사용자가 작성한 글의 경우 List 이동 버튼만 표시

    로그인한 사용자가 작성한 글의 경우 수정/삭제 버튼 활성화

 

1
2
3
4
5
6
7
     <sec:authentication property="principal" var="pinfo"/>
        <sec:authorize access="isAuthenticated()">
         <c:if test="${pinfo.username eq board.writer }"> <!-- 작성자와 동일한지 확인 -->
           <button data-oper='modify' class="btn btn-default" >Modify</button>
          </c:if>
       </sec:authorize>
           <button data-oper='list' class="btn btn-info" >List</button>
cs

<sec:authentication>태그를 매번 이용하는것이 불편하기 때문에 로그인과 관련된 정보인 principalsms 

아예 JSP 내에서 pinfo 라는 이름의 변수로 사용하도록 지정. (작성자와 동일하면 버튼 활성화)

 

게시물 조회 화면에서 댓글 추가버튼

댓글도 로그인 한 사용자만 댓글을 추가할수 있도록 <sec:authrize>를 이용해 댓글버튼을 활성화/비활성화처리

1
2
3
4
5
6
          <div class="panel-heading">
             <i class="fa fa-comments fa-fw"></i> Reply
            <sec:authorize access="isAuthenticated()"> <!-- 로그인한 사용자만 댓글 달수있게  -->                    
             <button id="addReplyBtn" class="btn btn-primary btn-xs pull-right">New Reply</button>
            </sec:authorize>
           </div>
cs

 

게시물의 수정/삭제

게시물 수정과 삭제는 브라우저에서 로그인한 사용자만 접근할수 있지만,

사용자가 URL을 조작해도 접근이 가능하기때문에 화면과 POST방식으로 처리되는 부분에서 CSRF토큰과 시큐리티적용

(수정과 삭제는 현재 로그인한 사용자와 게시물의 작성자가 동일한경우에만 접근가능) @PreAuthorize 표현식을 이용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    
                <!-- 로그인한 사용자와 게시물의 작성자인 경우만 수정과 삭제 버튼이 보이도록 검증-->
                <sec:authentication property="principal" var="pinfo"/>
                    <sec:authorize access="isAuthenticated()">
                        <c:if test="${pinfo.username eq board.writer }">
                    <button type="submit" data-oper='modify' class="btn btn-default">Modify</button> 
                    <button type="submit" data-oper='remove' class="btn btn-danger">Remove</button>
                        </c:if>
                    </sec:authorize>
                    <button type="submit" data-oper='list' class="btn btn-info">List</button>
                    
                        <!--  csrf 공격 방어를 위해 동적 생성 -->
                <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token }" />
            
cs

 

BoardController에서의 제어

로그인한 사용자와 현재 파라미터로 전달되는 작성자가 일치하는지 체크한다.

@PreAuthorize의 경우 문자열로 표현식을 지정할수 있는데, 이때 컨트롤러에 전달되는 파라미터를 같이 사용할수 있음

- 기존에는 삭제 부분에선 게시물번호 bno만 받았지만 , 작성자를 의미하는 writer를 추가해서 @PreAuthorize로 검사

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    @PreAuthorize("principal.username == #board.writer"// 메서드 실행전 ,로그인한 사용자와 파라미터로 전달되는 작성자가 일치하는지 체크 (문자열로 표현식지정.)
    @PostMapping("/modify")
    public String modify(BoardVO board,@ModelAttribute("cri") Criteria cri, RedirectAttributes rttr) {
        log.info("modify:" + board);
        
        if(service.modify(board)) {
            rttr.addFlashAttribute("result""success");
        }
        rttr.addAttribute("pageNum",cri.getPageNum());
        rttr.addAttribute("amount", cri.getAmount());
 
        rttr.addAttribute("type", cri.getType());
        rttr.addAttribute("keyword", cri.getKeyword());
        return "redirect:/board/list";
    }
    
    @PreAuthorize("principal.username == #writer")  // 메서드 실행전 ,로그인한 사용자와 파라미터로 전달되는 작성자가 일치하는지 체크 (문자열로 표현식지정.)
    @PostMapping("/remove")
    public String remove(@RequestParam("bno") Long bno, Criteria cri, RedirectAttributes rttr, String writer) {
        log.info("컨트롤러 remove:" + bno);
        log.info("remove....." + bno);
        
        List<BoardAttachVO> attachList = service.getAttachList1(bno);
        
        if(service.remove(bno)) {
            deleteFiles(attachList);
            rttr.addFlashAttribute("result""success");
        }
        
            //list에 위에서 주석처리한 값들을 돌려보내줌
        return "redirect:/board/list" + cri.getListLink();
    }
cs

기존과 달라진 부분은 파라미터로 writer가 추가된 부분과,

해당 파라미터를 @PreAuthorize에서 #writer를 이용해 체크한부분(게시물 수정은 객체에 담겨있으므로 꺼내확인)

 

Ajax와 스프링 시큐리티 처리

Ajax를 이용하는 경우 약간의 추가설정이 필요하다. 예제는 파일업로드와 댓글 부분이 Ajax 이므로

로그인한 사용자만 해당기능 사용 할수있게 수정.

스프링 시큐리티가 적용되면 POST,PUT,PACTH,DELETE 같은 방식으로 데이터를 전송하는경우

반드시 추가적으로 'X-CSRF-TOKEN'과 같은 헤더정보를 추가해서 CSRF 토큰값을 전달하도록 수정해야함

1) Ajax는 JS를 이용하기때문에 브라우저에서 CSRF토큰과 관련된 값을 변수로 선언하고 전송시 포함

 

게시물 등록시 첨부파일의 처리(register.jsp)

기존의 코드에서 csrfHeaderName와 csrfTokenValue 변수를추가한다(브라우저에서코드가 변경되서 생성)

Ajax로 데이터 전송할때는, beforeSend를 이용해서 추가적인 헤더를 지정해서 전송한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    //ajax 전송시, 'x-csrf-token' 같은 헤더 정보를 추가해서 csrf 토큰값 전달
    var csrfHeaderName = "${_csrf.headerName}";
    var csrfTokenValue = "${_csrf.token}";
    
    //beforeSend는 시큐리티 적용시, 추가적인 헤더를 지정해서 ajax전송시 csrf를 토큰값 같이전송
        $.ajax({
        url: "/uploadAjaxAction",
        processData: false,
        contentType: false
        beforeSend: function(xhr){
            xhr.setRequestHeader(csrfHeaderName, csrfTokenValue)
        },
        data:formData,
        type:"post",
        dataType: "json",
            success: function(result){
                console.log(result);
                showUploadResult(result);
            }
        });
    });
cs

첨부파일의 제거도 POST방식으로 동작하기때문에 CSRF토큰 처리를 해준다

(파일업로드와 동일하게 AJAX전송시 beforeSend 로 추가적인 헤더 지정해서 전송)

 

필요하다면 서버쪽에서도 어노테이션을 이용해 업로드시 보안을 확인할수 있다. (컨트롤러에서 @PreAuthorize를 이용)

 

게시물의 수정/삭제 에서 첨부파일의처리

이것도 게시물 등록 과 동일하게 토큰값을 생성해서 ajax전송시 헤더에 추가해서 전송하는방식으로 처리

 

댓글 기능에서의 Ajax

댓글의 경우 모든동작이 Ajax를 통해 이루어지기 때문에 , 화면과 서버쪽이 변경될 부분이 많다.

아래와 같이 설계를 해본다

서버)

댓글의 등록 > 로그인한 사용자만 댓글추가 간으

댓글의 수정과삭제> 로그인한 사용자와 댓글작성자의 아이디를 비교해서 같은경우 댓글 수정/삭제 가능

 

브라우저)

댓글의 등록 > CSRF 토큰을 같이 전송

댓글의 수정과삭제 > 기존댓글삭제시 댓글 번호만으로 처리했는데, 서버쪽에서도 사용할것이므로 댓글작성자도 전송

 

댓글의 등록

댓글의 등록은 사용자가 로그인했다면 로그인한 사용자가 댓글 작성자가 되어야 하므로, 댓글 작성자를 js 변수로 설정

<sec:authorize> 태그를 이용해 시큐리티의 username(id)를 replyer 변수에 담는다. 

1
2
3
4
5
6
7
8
9
10
11
12
    //작성자 null로 선언
    var replyer = null;
    
    //로그인 확인하고, 로그인 사용자를 replyer에 넣는다
    <sec:authorize access="isAuthenticated()">
        replyer = '<sec:authentication property="principal.username"/>';
    </sec:authorize>
    
    //ajax 전송시, 'x-csrf-token' 같은 헤더 정보를 추가해서 csrf 토큰값 전달
    var csrfHeaderName = "${_csrf.headerName}";
    var csrfTokenValue = "${_csrf.token}";
    
cs

 

예제는 모달창을 사용하기때문에 생성 누를시 , 로그인한 사용자의 id를 작성자로 고정으로 넣어준다

1
2
3
4
5
6
7
8
9
10
11
12
13
 
    //댓글 생성 누르면 기존 모달창 데이터 숨기기
    $("#addReplyBtn").on("click", function(e) {
        modal.find("input").val("");
        modal.find("input[name='replyer']").val(replyer); //replyer (시큐리티 id가 담긴)
        modalInputReplyDate.closest("div").hide();
        modal.find("button[id !='modalCloseBtn']").hide();
        
        modalRegisterBtn.show();
        
        $(".modal").modal("show");
    })
    
cs

 

JQuery 를 이용해서 Ajax로 CSRF 토큰 전송하는 방식은 첨부파일의 경우 beforeSend를 이용했지만,

기본설정으로 지정해서 사용하는것이 편하기때문에 아래코드를 사용한다.

ajaxSend()를 이용한 코드는 모든 Ajax 전송시 CSRF토큰을 같이 전송하도록 세팅됨(beforeSned 호출안해도됨)

1
2
3
4
        //ajax에 beforeSend추가 전송 방식말고, 기본설정으로 지정해서 사용
    $(document).ajaxSend(function(e, xhr, options){
        xhr.setRequestHeader(csrfHeaderName, csrfTokenValue);
    });    
cs

 

ReplyController 에선 댓글등록이 로그인한 사용자인지 확인한다

1
2
3
4
5
6
7
8
9
10
11
    //댓글 등록
    @PreAuthorize("isAuthenticated()") //로그인한 사용자가 맞으면 true
    @PostMapping(value = "/new", consumes = "application/json", produces = {MediaType.TEXT_PLAIN_VALUE})
    public ResponseEntity<String> create(@RequestBody ReplyVO vo){ 
        
        log.info("create ------" + vo);
        int count = service.insert(vo);
        log.info("count ------ " + count);
        return count == 1 ?  new ResponseEntity<String>("success1", HttpStatus.OK) : new ResponseEntity<String>(HttpStatus.INTERNAL_SERVER_ERROR);
    }
    
cs

 

댓글삭제

자신이 작성한 댓글만 삭제가 가능하도록 (화면에서 로그인한 사용자와 댓글작성자 정보와 동일한지 비교)

같으면 Ajax를 통해 삭제할수 있도록, 다를경우나 로그인하지 않은경우 삭제할수없도록 제한

(기존과 달리 댓글 작성자 항목을 같이 전송해야함) rno, replyer 전송 json타입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
       //댓글삭제
         modalRemoveBtn.on("click", function (e){
           
           var rno = modal.data("rno");
           
           console.log("rno" + rno);
           console.log("relyer:"+ replyer);
           
           if(!replyer){
               alert("로그인후 삭제가 가능합니다");
               modal.modal("hide");
               return;
           }
//작성
           var originalReplyer = modalInputReplyer.val();
           
           console.log("원래 작성자" + originalReplyer);
           
           if(replyer != originalReplyer){
               
               alert("자신이 작성한 댓글만 삭제가 가능합니다");
               modal.modal("hide");
               return;
           }
           
           replyService.remove(rno, originalReplyer,function(result){
                 
               alert(result);
               modal.modal("hide");
               showList(pageNum);
           });
         });
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   //댓글 삭제
    function remove(rno, replyer, callback, error){
        $.ajax({
              type: "delete",
              url : "/replies/" + rno,
              data: JSON.stringify({rno:rno, replyer:replyer}),
              contentType: "application/json; charset=utf-8",
            success: function(deleteResult, status, xhr){
                callback(deleteResult);
            },
            error: function(xhr, status, er){
                error(er);
            }
        });
    }
cs

 

ReplyController 는 JSON으로 전송되는 데이터를 객체로 담는식으로 변경 하고 , vo.replyer로 비교한다

** 파라미터가 @RequestBody가 적용되어 JSON으로 된 데이터를 받도록 수정. 

ReplyVO 객체에는 rno와 replyer가 쌓여서 비교할수 있다.

1
2
3
4
5
6
7
8
9
//댓글삭제
    @PreAuthorize("principal.username == #vo.replyer")
    @RequestMapping(value = "/{rno}", method= {RequestMethod.DELETE})
    public ResponseEntity<String> delete(@RequestBody ReplyVO vo, @PathVariable("rno") Long rno){
        log.info("삭제 ------ " + rno);
        
        return service.delete(rno) == 1 ? new ResponseEntity<String>("success", HttpStatus.OK) : new ResponseEntity<String>(HttpStatus.INTERNAL_SERVER_ERROR);
        
    }
cs

 

댓글수정

댓글 수정은 댓글의 내용만 전송했지만, 작성자가 같이 전송하도록 수정.

수정도 컨트롤러 단에서, 삭제와 동일하게, @PreAuthorize 표현식을 이용해 (객체에 담긴 replyer 를 비교해 true를 확인)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//댓글 수정
    modalModBtn.on("click", function(e){
        
          var originalReplyer = modalInputReplyer.val();
        
           var reply = {
                   rno:modal.data("rno")
                  ,reply: modalInputReply.val()
                  ,replyer: originalReplyer};
           
           if(replyer != originalReplyer){
               alert("자신의 댓글만 삭제가능");
               modal.modal("hide");
               return;
           }
           
           replyService.update(reply, function(result){
                 
             alert(result);
             modal.modal("hide");
             showList(pageNum);
           });
         });
cs