java-school logo

웹 요청 보안

스프링 시큐리티를 프로젝트에 설치하고 웹 요청 보안까지 실습한다. 다음 순서로 진행한다.

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

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

pom.xml에 다음과 같이 스프링 시큐리티의 의존성을 추가한다.

<properties>
	<spring.version>4.3.9.RELEASE</spring.version>
	<spring.security.version>4.2.3.RELEASE</spring.security.version>
	<jdk.version>1.8</jdk.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. 스프링 시큐리티 설정 파일 작성 및 ..-servlet.xml에 있는 기존 설정을 특성별로 떼어 새로운 파일에 작성

스프링 시큐리티만을 위한 스프링 설정 파일을 /WEB-INF 폴더에 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>
		<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="/images/**" access="permitAll" />
		<intercept-url pattern="/css/**" 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')" />
		
		<form-login 
			login-page="/users/login" 
			authentication-failure-url="/users/login?error=1" 
			default-target-url="/bbs/list?boardCd=free&amp;page=1" />
		
		<logout 
			logout-success-url="/users/login" 
			invalidate-session="true"  />
		
	</http>

	<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이므로 생략할 수 있다.

위에서 우리는 스프링 시큐리티만의 위한 스프링 설정 파일을 따로 만들었다. spring-bbs-servlet.xml 파일에서 빈의 특성별로 따로 스프링 설정 파일을 만들 수 있다. 여기서는 아래와 같이 spring-bbs-servlet.xml에서 빈을 떼어내 applicationContext.xml 라는 이름으로 /WEB-INF 폴더에 생성한다.

applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" 
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:p="http://www.springframework.org/schema/p"
	xsi:schemaLocation="http://www.springframework.org/schema/beans 
		http://www.springframework.org/schema/beans/spring-beans.xsd">
	
	<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
		<property name="dataSource" ref="dataSource" />
		<property name="configLocation" value="classpath:net/java_school/mybatis/Configuration.xml" />
	</bean>
	
	<bean id="dataSource" 
		class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
		<property name="driverClassName" value="oracle.jdbc.driver.OracleDriver"/>
		<property name="url" value="jdbc:oracle:thin:@127.0.0.1:1521:XE"/>
		<property name="username" value="java"/>
		<property name="password" value="school"/>
		<property name="maxActive" value="100"/>
		<property name="maxWait" value="1000"/>
		<property name="poolPreparedStatements" value="true"/>
		<property name="defaultAutoCommit" value="true"/>
		<property name="validationQuery" value=" SELECT 1 FROM DUAL" />
	</bean>
    
	<bean id="multipartResolver"
		class="org.springframework.web.multipart.commons.CommonsMultipartResolver"
		p:maxUploadSize="104857600" p:maxInMemorySize="10485760" />
	
</beans>

4. ..-servlet.xml 파일에 시큐리티 설정 추가

위에서 스프링 시큐리티만을 위한 스프링 설정 파일을 만들었지만, 스프링 시큐리티 설정 중 반드시 ..-servlet.xml 파일에 설정해야 것이 있다.

spring-bbs-servlet.xml 파일을 열고 아래 강조한 부분을 참고하여 스프링 시큐리티 설정을 추가한다.

spring-bbs-servlet.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:security="http://www.springframework.org/schema/security" 
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:context="http://www.springframework.org/schema/context"
	xmlns:mvc="http://www.springframework.org/schema/mvc"
	xmlns:mybatis="http://mybatis.org/schema/mybatis-spring"
	xmlns:p="http://www.springframework.org/schema/p"
	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://www.springframework.org/schema/context
		http://www.springframework.org/schema/context/spring-context.xsd
		http://www.springframework.org/schema/mvc
		http://www.springframework.org/schema/mvc/spring-mvc.xsd
		http://mybatis.org/schema/mybatis-spring 
		http://mybatis.org/schema/mybatis-spring.xsd">
		
	<security:global-method-security pre-post-annotations="enabled" />
	
	<!-- 중간 생략  -->
	
</beans>	

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

web.xml
<?xml version="1.0" encoding="ISO-8859-1"?>
<!--
 Licensed to the Apache Software Foundation (ASF) under one or more
  contributor license agreements.  See the NOTICE file distributed with
  this work for additional information regarding copyright ownership.
  The ASF licenses this file to You under the Apache License, Version 2.0
  (the "License"); you may not use this file except in compliance with
  the License.  You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
-->


<web-app xmlns="http://java.sun.com/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
                      http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
  version="3.0"
  metadata-complete="true">
    
	<display-name>Spring BBS</display-name>
	
	<context-param>
		<param-name>contextConfigLocation</param-name>
		<param-value>
			/WEB-INF/applicationContext.xml
			/WEB-INF/security.xml
		</param-value>
	</context-param>

	<listener>
		<listener-class>
		org.springframework.web.context.ContextLoaderListener
		</listener-class>	
	</listener>
	
	<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>
		<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, loginUsers-menu.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>

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

/WEB-INF/views/users/loginUsers-menu.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<h1>회원</h1>
<ul>
    <li>
        <ul>
            <li><a href="/users/editAccount">내정보 수정</a></li>
            <li><a href="/users/changePasswd">비밀번호 변경</a></li>
            <li><a href="/users/bye">탈퇴</a></li>
        </ul>
    </li>
</ul>

8. 권한이 없는 사용자에게 보여줄 페이지 생성

테스트를 위해 권한이 없는 사용자가 잘못된 자원을 요청했을 때 보여줄 페이지를 작성한다.

/WEB-INF/views/noAuthority.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; 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:port/list?boardCd=free&page=1를 방문한다. 로그인 페이지로 이동하게 되는데, im@gmail.org/1111로 로그인한다. 로그인이 성공하면 게시판으로 이동하게 된다.

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

참고