웹 요청 보안

스프링 시큐리티를 프로젝트에 설치하고 웹 요청 보안까지 실습

진행 순서

  1. 스프링 시큐리티 의존성 추가
  2. 권한 테이블 생성과 테스트 레코드 삽입
  3. 스프링 시큐리티 설정 파일 작성
  4. web.xml 파일에 시큐리티 설정 추가
  5. 기존 기존 로그인/로그아웃 메소드 제거
  6. 로그인 페이지 수정과 로그아웃 링크 수정
  7. 권한이 없는 사용자에게 보여줄 페이지 생성
  8. 스프링 시큐리티를 적용하는 코드 구현

1. 스프링 시큐리티 의존성 추가

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <maven.compiler.source>17</maven.compiler.source>
  <maven.compiler.target>17</maven.compiler.target>
  <spring.version>5.3.33</spring.version>
  <spring.security.version>5.8.10</spring.security.version>
</properties>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-web</artifactId>
  <version>${spring.security.version}</version>
</dependency>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-taglibs</artifactId>
  <version>${spring.security.version}</version>
</dependency>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-config</artifactId>
  <version>${spring.security.version}</version>
</dependency>

2. 권한 테이블 생성과 테스트 레코드 삽입

스프링 시큐리티를 사용하기 위해서 회원과 권한 테이블이 필요하다.
회원 테이블은 member를 그대로 사용한다.
권한 테이블을 새로 만들고 테스트를 위한 데이터를 인서트한다.

CREATE TABLE authorities (
  email VARCHAR2(60) NOT NULL,
  authority VARCHAR2(20) NOT NULL,
  CONSTRAINT fk_authorities FOREIGN KEY(email) REFERENCES member(email)
);

CREATE UNIQUE INDEX ix_authorities ON authorities(email, authority); 

INSERT INTO member VALUES ('hong@gmail.org','1111','홍길동','010-1111-1111');
INSERT INTO member VALUES ('im@gmail.org','1111','임꺽정','010-1111-2222');

INSERT INTO authorities VALUES ('hong@gmail.org','ROLE_USER');
INSERT INTO authorities VALUES ('hong@gmail.org','ROLE_ADMIN');
INSERT INTO authorities VALUES ('im@gmail.org','ROLE_USER');

commit;

ROLE_USER은 일반 사용자 권한, ROLE_ADMIN은 관리자 권한이다.
홍길동은 일반 사용자 권한과 관리자 권한 모두를 가지고, 임꺽정은 일반 사용자 권한만 갖는다.

3. 스프링 시큐리티 설정 파일 작성

스프링 시큐리티만을 위한 스프링 설정 파일이 필요하다.
이름과 위치에 제약은 없다.
/WEB-INF/spring 폴더에 security.xml란 이름으로 아래와 같이 생성한다.

security.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/security"
  xmlns:beans="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security.xsd">

  <http>
    <access-denied-handler error-page="/403" />
    <intercept-url pattern="/users/bye_confirm" access="permitAll" />
    <intercept-url pattern="/users/welcome" access="permitAll" />
    <intercept-url pattern="/users/signUp" access="permitAll" />
    <intercept-url pattern="/users/login" access="permitAll" />
    <intercept-url pattern="/admin/**" access="hasRole('ROLE_ADMIN')"/>
    <intercept-url pattern="/users/**" access="hasAnyRole('ROLE_ADMIN','ROLE_USER')"/>
    <intercept-url pattern="/bbs/**" access="hasAnyRole('ROLE_ADMIN','ROLE_USER')" />
    <intercept-url pattern="/**" access="permitAll" />
    
    <form-login login-page="/users/login" 
      authentication-failure-url="/users/login?error=1" 
      default-target-url="/bbs/list?boardCd=chat&amp;page=1" />
    
    <logout logout-success-url="/users/login" invalidate-session="true" />

    <session-management>
      <concurrency-control max-sessions="1"
        error-if-maximum-exceeded="true" />
    </session-management>
  </http>
  
  <global-method-security pre-post-annotations="enabled" />

  <beans:bean id="webexpressionHandler" 
    class="org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler" /> 

  <authentication-manager>
    <authentication-provider>
      <jdbc-user-service 
        data-source-ref="dataSource"
        users-by-username-query="SELECT email as username,passwd as password,1 as enabled 
          FROM member WHERE email = ?"
        authorities-by-username-query="SELECT email as username,authority 
          FROM authorities WHERE email = ?" />
    </authentication-provider>
  </authentication-manager>

</beans:beans>

<form-login> 요소의 login-page 속성 기본값은 /login,
login-processing-url 속성 기본값은 POST /login,
username-parameter 속성 기본값은 username,
password-parameter 속성 기본값은 password,
authentication-failure-url 속성 기본값은 /login?error=1이다.
기본값 이외의 값을 주려면 속성을 생략해선 안 된다.
사용자 로그인 페이지(/users/login)를 사용하고 로그인 실패시 다시 로그인 페이지로 이동하게 하려면, login-page 속성 뿐만 아니라 authentication-failure-url 속성을 명시해야 하며 http 요소에 다음이 있어야 한다.

<intercept-url pattern="/users/login" access="permitAll" />

http의 use-expressions의 속성 기본값이 true이므로 생략할 수 있다.

5. web.xml 시큐리티 설정 추가

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                      http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
  version="4.0">
      
  <display-name>Spring BBS</display-name>
  
  <listener>
    <listener-class>org.springframework.security.web.session.HttpSessionEventPublisher</listener-class>
  </listener>

  <filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
      <param-name>encoding</param-name>
      <param-value>UTF-8</param-value>
    </init-param>
    <init-param>
      <param-name>forceEncoding</param-name>
      <param-value>true</param-value>
    </init-param>
  </filter>

  <filter>
    <filter-name>springSecurityFilterChain</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
  </filter>

  <filter-mapping>
    <filter-name>encodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

  <filter-mapping>
    <filter-name>springSecurityFilterChain</filter-name>
    <url-pattern>/*</url-pattern>  
  </filter-mapping> 
  
  <servlet>
    <servlet-name>spring-bbs</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>/WEB-INF/spring/*.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <servlet-mapping>
    <servlet-name>spring-bbs</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>

  <error-page>
    <error-code>403</error-code>
    <location>/WEB-INF/views/noAuthority.jsp</location>
  </error-page>
</web-app>

6. 기존 기존 로그인/로그아웃 메소드 제거

UsersController.java에서 로그인과 로그아웃 메소드를 제거한다.

UsersController.java
/*
@RequestMapping(value="/login", method=RequestMethod.POST)
public String login(String email, String passwd, HttpSession session) {
  User user = userService.login(email, passwd);
  if (user != null) {
    session.setAttribute(WebContants.USER_KEY, user);
    return "redirect:/users/changePasswd";
  } else {
    return "redirect:/users/login";
  }
}

@RequestMapping(value="/logout", method=RequestMethod.GET)
public String logout(HttpSession session) {
  session.removeAttribute(WebContants.USER_KEY);
  //로그아웃하면 로그인페이지로
  return "redirect:/users/login";
}
*/

7. 로그인 페이지 수정과 로그아웃 링크 수정

login.jsp, header.jsp를 수정한다.

/WEB-INF/views/users/login.jsp
<c:if test="${not empty param.error }">
  <h2>${SPRING_SECURITY_LAST_EXCEPTION.message }</h2>
</c:if>
<c:url var="loginUrl" value="/login" />
<form id="loginForm" action="${loginUrl }" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<table>
<tr>
  <td style="width: 200px;">Email</td>
  <td style="width: 390px"><input type="text" name="username" style="width: 99%;" /></td>
</tr>
<tr>
  <td>Password</td>
  <td><input type="password" name="password" style="width: 99%;" /></td>
</tr>
</table>

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> 코드가 없다면, 로그인 화면으로 이동하여 로그인을 시도하면 빈 화면을 만나게 된다. 로그에 어떤 에러 메시지도 없다. 원인은 스프링 시큐리티 4의 CSRF 방지 기능이 작동하고 있기 때문이다. 스프링 시큐리티 4부터는 이 기능이 디폴트로 작동한다. 따라서 PATCH, POST, PUT, DELETE 요청에 CSRF 토큰을 포함해야 한다. 스프링 폼 태그를 사용하는 경우, 자동으로 토큰 파라미터가 추가되므로 CSRF 토큰 파라미터를 추가하지 않는다.

/WEB-INF/views/inc/header.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<h1 style="float: left;width: 150px;"><a href="../"><img src="../images/ci.gif" alt="java-school logo" /></a></h1>
<div id="memberMenu" style="float: right;position: relative;top: 7px;">
<security:authorize access="hasAnyRole('ROLE_USER','ROLE_ADMIN')">
  <security:authentication property="principal.username" var="check" />
</security:authorize>
<c:choose>
  <c:when test="${empty check}">
    <input type="button" value="로그인" onclick="location.href='/users/login'" />
    <input type="button" value="회원가입" onclick="location.href='/users/signUp'" />
  </c:when>
  <c:otherwise>
    <input type="button" value="로그아웃" id="logout" />
    <input type="button" value="내정보수정" onclick="location.href='/users/editAccount'" />
  </c:otherwise>
</c:choose>
</div>

<form id="logoutForm" action="/logout" method="post" style="display:none">
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
</form>
<script type="text/javascript" src="/resources/js/jquery-3.0.0.min.js"></script>

<script>
$(document).ready(function() {
  $('#logout').click(function() {
    $('#logoutForm').submit();
    return false;
    });
});
</script>

스프링 시큐리티는 사용자 정보를 세션에 담지 않으므로 수정하지 않으면 로그인 후 로그아웃/내정보수정 버튼을 볼 수 없다. 스프링 시큐리티 태그를 사용하는 예는 뷰 레벨 보안에서도 다룬다. 로그아웃을 위한 메뉴는 제거한다.

8. 권한이 없는 사용자가 권한이 필요한 자원을 요청했을 때 보여줄 페이지

테스트를 위해 다음 페이지를 작성한다.

/WEB-INF/views/noAuthority.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>403</title>
</head>
<body>
권한이 없습니다.
</body>
</html>

9. 스프링 시큐리티를 적용하기 위해 필요한 코드 추가

UserMapper.xml
<insert id="insertAuthority">
  INSERT INTO authorities VALUES (#{email}, #{authority})
</insert>

<delete id="deleteAuthority">
  DELETE FROM authorities WHERE email = #{email}  
</delete>
UserMapper.java
public void insertAuthority(@Param("email") String email, @Param("authority") String authority);
  
public void deleteAuthority(@Param("email") String email);
UserService.java
//권한 추가
public void addAuthority(String email, String authority);
UserServiceImpl.java
@Override
public void addAuthority(String email, String authority) {
  userMapper.insertAuthority(email, authority);
}

@Override
public void bye(User user) {
  userMapper.deleteAuthority(user.getEmail());
  userMapper.delete(user);
}
UsersController
//중간 생략..


import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;

import java.security.Principal;
import org.springframework.ui.Model;


//중간 생략..

@RequestMapping(value="/signUp", method=RequestMethod.POST)
public String signUp(User user) {

  String authority = "ROLE_USER";

  userService.addUser(user);
  userService.addAuthority(user.getEmail(), authority);

  return "redirect:/users/welcome";
}

@RequestMapping(value="/editAccount", method=RequestMethod.GET)
public String editAccount(Principal principal, Model model) {
  User user = userService.getUser(principal.getName());
  model.addAttribute(WebContants.USER_KEY, user);

  return "users/editAccount";
}

@RequestMapping(value="/editAccount", method=RequestMethod.POST)
public String editAccount(User user, Principal principal) {
  
  user.setEmail(principal.getName());

  int check = userService.editAccount(user);
  if (check < 1) {
    throw new RuntimeException(WebContants.EDIT_ACCOUNT_FAIL);
  } 

  return "redirect:/users/changePasswd";
  
}

@RequestMapping(value="/changePasswd", method=RequestMethod.GET)
public String changePasswd(Principal principal, Model model) {
  User user = userService.getUser(principal.getName());

  model.addAttribute(WebContants.USER_KEY, user);

  return "users/changePasswd";
}

@RequestMapping(value="/changePasswd", method=RequestMethod.POST)
public String changePasswd(String currentPasswd, String newPasswd, Principal principal) {
  
  int check = userService.changePasswd(currentPasswd,newPasswd, principal.getName());

  if (check < 1) {
    throw new RuntimeException(WebContants.CHANGE_PASSWORD_FAIL);
  }  

  return "redirect:/users/changePasswd_confirm";

}

@RequestMapping(value="/bye", method=RequestMethod.POST)
public String bye(String email, String passwd, HttpServletRequest req) 
    throws ServletException {

  User user = userService.login(email, passwd);
  userService.bye(user);
  req.logout();

  return "redirect:/users/bye_confirm";
}
BbsController.java
//중간 생략..

import java.security.Principal;

//중간 생략..

@RequestMapping(value="/write", method=RequestMethod.POST)
public String write(MultipartHttpServletRequest mpRequest, Principal principal) 
    throws Exception {

  //중간 생략..
  Article article = new Article();
  article.setBoardCd(boardCd);
  article.setTitle(title);
  article.setContent(content);
  article.setEmail(principal.getName());
    
  boardService.addArticle(article);  
    
  //중간 생략..
    
  //파일데이터 삽입
  int size = fileList.size();
  for (int i = 0; i < size; i++) {
    MultipartFile mpFile = fileList.get(i);
    AttachFile attachFile = new AttachFile();
    String filename = mpFile.getOriginalFilename();
    attachFile.setFilename(filename);
    attachFile.setFiletype(mpFile.getContentType());
    attachFile.setFilesize(mpFile.getSize());
    attachFile.setArticleNo(article.getArticleNo());
    attachFile.setEmail(principal.getName());
    boardService.addAttachFile(attachFile);
  }

  //중간 생략..
}

@RequestMapping(value="/addComment", method=RequestMethod.POST)
public String addComment(Integer articleNo, 
    String boardCd, 
    Integer page, 
    String searchWord, 
    String memo,
    Principal principal) throws Exception {
    
  Comment comment = new Comment();
  comment.setArticleNo(articleNo);
  comment.setEmail(principal.getName());
  comment.setMemo(memo);

  //생략..
}

첨부 파일의 경우 <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />이 아닌 쿼리 스프링으로 CSRF 토큰을 전달해야 한다.
write.jsp와 modify.jsp 파일을 열고 <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />이 있다면 지우고, 아래와 같이 폼의 action 속성을 수정한다.

write.jsp
<sf:form action="write?${_csrf.parameterName}=${_csrf.token}" method="post" ...
modify.jsp
<sf:form action="modify?${_csrf.parameterName}=${_csrf.token}" method="post" ...

위의 방법은 쿼리 파라미터가 노출될 수 있다.
민감한 데이터를 노출되지 않도록 바디나 헤더에 두는 것이 좀 더 낫다.
이에 관한 정보는 아래 참고에 링크해 둔다.

테스트

라이브러리가 추가했으니 다시 빌드를 실행한다.
톰캣을 재실행한 후, http://localhost:8080/list?boardCd=chat&page=1를 방문한다.
로그인 페이지로 이동하게 되는데, im@gmail.org/1111로 로그인한다.
로그인이 성공하면 게시판으로 이동하게 된다.

웹 브라우저의 주소창에 http://localhost:8080/admin를 입력하여 요청한다. 임꺽정은 일반 사용자 권한만 가지고 있으므로 접근이 거부되고 noAuthority.jsp로 이동하게 된다.

참고