스프링 시큐리티

http://spring.io/blog/2010/08/02/spring-security-in-google-app-engine/에서는 구글 어카운트와 스트링 시큐리티를 함께 사용하는 방법을 제공한다. 주소에서 소개하는 내용을 다음과 같다.

  • 구글 어카운트 인증으로 시큐리티 컨텍스트 세팅 (다중 인증Multiple Authentication 아님)
  • 사용자 정의 인증Authentication 클래스 구현
  • 구글 어카운트 정보에 권한 정보 추가
  • 권한 정보를 기반으로 접근 제어
  • 사용자 정보(구글 어카운트 + 권한)를 데이터스토어에 저장 (네이티브 API 사용)
  • 불량 사용자 접근 금지

소개한 주소에서 참고하여, 사용자 정보를 영속 데이터로 저장하지 않고도 운영할 수 있는 사이트를 구현해 볼 것이다. 따라서 위 항목에서 데이터스토어에 사용자 정보를 저장하는 것과 불량 사용자 접근 금지는 구현하지 않는다.

스프링 시큐리티 추가

pom.xml의 properties 엘리먼트에 다음을 추가한다.

pom.xml
<properties>
  <!-- 중간 생략 -->

  <spring.security.version>5.8.10</spring.security.version>
</properties>

pom.xml의 dependencies 엘리먼트에 다음을 추가한다.

pom.xml
<!-- [START Spring Security] -->
<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>
<!-- [END Spring Security] -->      

다음은 구글 어카운트와 스프링 시큐리티 연동에 필요한 파일이다.

AppRole.java
package net.java_school.spring.security;

import org.springframework.security.core.GrantedAuthority;

public enum AppRole implements GrantedAuthority {
  ROLE_ADMIN (0),
  ROLE_USER (1);
  
  private int bit;
  
  AppRole (int bit) {
    this.bit = bit;
  }
  
  @Override
  public String getAuthority() {
    return toString();
  }
  
  public int getBit() {
    return bit;
  }
  
  public void setBit(int bit) {
    this.bit = bit;
  }

}
GaeUser.java
package net.java_school.user;

import java.io.Serializable;
import java.util.EnumSet;
import java.util.Set;

import net.java_school.spring.security.AppRole;

@SuppressWarnings("serial")
public class GaeUser implements Serializable {
  private String userId;
  private String email;
  private String nickname;
  private Set<AppRole> authorities;
  
  public GaeUser() {}
  
  public GaeUser(String userId, String email, String nickname) {
    this.userId = userId;
    this.email = email;
    this.nickname = nickname;
    this.authorities = EnumSet.of(AppRole.ROLE_USER);
  }
    
  public GaeUser(String userId, String email, String nickname, Set<AppRole> authorities) {
    this.userId = userId;
    this.email = email;
    this.nickname = nickname;
    this.authorities = authorities;
  }

  public String getUserId() {
    return userId;
  }

  public void setUserId(String userId) {
    this.userId = userId;
  }

  public String getNickname() {
    return nickname;
  }

  public void setNickname(String nickname) {
    this.nickname = nickname;
  }

  public String getEmail() {
    return email;
  }

  public void setEmail(String email) {
    this.email = email;
  }

  public Set<AppRole> getAuthorities() {
    return authorities;
  }

  public void setAuthorities(Set<AppRole> authorities) {
    this.authorities = authorities;
  }
    
}
GoogleAccountsAuthenticationEntryPoint.java
package net.java_school.spring.security;

import java.io.IOException;

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

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;

public class GoogleAccountsAuthenticationEntryPoint implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response, 
      AuthenticationException authException) throws IOException, ServletException {
      
    UserService userService = UserServiceFactory.getUserService();
    response.sendRedirect(userService.createLoginURL(request.getRequestURI()));
  }
}
GaeUserAuthentication.java
package net.java_school.spring.security;

import java.util.Collection;
import java.util.HashSet;

import net.java_school.user.GaeUser;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

@SuppressWarnings("serial")
public class GaeUserAuthentication implements Authentication {
  private final GaeUser principal;
  private final Object details;
  private boolean authenticated;
    
  public GaeUserAuthentication (GaeUser principal, Object details) {
    this.principal = principal;
    this.details = details;
    this.authenticated = true;
  }
    
  @Override
  public String getName() {
    return principal.getEmail();
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return new HashSet<GrantedAuthority>(principal.getAuthorities());
  }

  @Override
  public Object getCredentials() {
    throw new UnsupportedOperationException();
  }

  @Override
  public Object getDetails() {
    return null;
  }

  @Override
  public Object getPrincipal() {
    return principal;
  }

  @Override
  public boolean isAuthenticated() {
    return authenticated;
  }

  @Override
  public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
    this.authenticated = authenticated;
  }

}
GaeAuthenticationFilter.java
package net.java_school.spring.security;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.web.filter.GenericFilterBean;

import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserServiceFactory;

public class GaeAuthenticationFilter extends GenericFilterBean {
  private final AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> ads = 
    new WebAuthenticationDetailsSource();
  
  private AuthenticationManager authenticationManager;
  
  private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
  
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
      throws IOException, ServletException {
      
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication == null) {
      // User isn't authenticated. Check if there is a Google Accounts user
      User googleUser = UserServiceFactory.getUserService().getCurrentUser();

      if (googleUser != null) {
        /*
        User has returned after authenticating through GAE. 
        Need to authenticate to Spring Security. 
        */
        PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(googleUser, null);
        
        token.setDetails(ads.buildDetails((HttpServletRequest) request));
        try {
          authentication = authenticationManager.authenticate(token);
          // Setup the security context
          SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (AuthenticationException e) {
          // Authentication information was rejected by the authentication manager
          failureHandler.onAuthenticationFailure((HttpServletRequest)request, (HttpServletResponse)response, e);
          
          return;
        }
      }
    }

    chain.doFilter(request, response);
  }

  public void setAuthenticationManager(AuthenticationManager authenticationManager) {
    this.authenticationManager = authenticationManager;
  }

  public void setFailureHandler(AuthenticationFailureHandler failureHandler) {
    this.failureHandler = failureHandler;
  }
}
GoogleAccountsAuthenticationProvider.java
package net.java_school.spring.security;

import net.java_school.user.GaeUser;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;

import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserServiceFactory;

public class GoogleAccountsAuthenticationProvider implements AuthenticationProvider {
    
  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    User googleUser = (User) authentication.getPrincipal();
    
    GaeUser gaeUser = new GaeUser(googleUser.getUserId(), googleUser.getEmail(), googleUser.getNickname());
    
    if (UserServiceFactory.getUserService().isUserAdmin()) {
      gaeUser.getAuthorities().add(AppRole.ROLE_ADMIN);
    }

    return new GaeUserAuthentication(gaeUser, authentication.getDetails());
  }
  
  @Override
  public final boolean supports(Class<?> authentication) {
    return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
  }

}

스프링 시큐리티만을 위한 스프링 설정은 별도의 파일로 작성해야 한다.

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 pattern="/_ah/login" security="none" />
  <http pattern="/_ah/logout" security="none" /><!--1. 시큐리티 예외 설정-->

  <http entry-point-ref="gaeEntryPoint"><!--2. 인증 진입점-->
    <access-denied-handler error-page="/403" /><!--3. 에러 페이지-->
    <intercept-url pattern="/admin/**" access="hasRole('ROLE_ADMIN')" />
    <intercept-url pattern="/**" access="permitAll" />
    <custom-filter position="PRE_AUTH_FILTER" ref="gaeFilter" /><!--4. 인증 필터-->
  </http>

  <authentication-manager alias="authenticationManager"><!--5. 인증 관리자-->
    <authentication-provider ref="gaeAuthenticationProvider" /><!--6. 인증 공급자-->
  </authentication-manager>
	
</beans:beans>
  1. 스프링 시큐리티 예외 설정으로 로그인과 로그아웃을 사이트 외부에서 하기에 이 설정은 불가피하다.
  2. 인증 진입점은 단순히 사용자를 로그인 페이지로 리다이렉트한다.
  3. 권한이 없는 사용자에게 보여줄 페이지를 지정한다. guestbook-servlet.xml의 뷰 리졸버는 /403을 /WEB-INF/views/403.jsp로 해석할 것이기에, /WEB-INF/views 디렉터리에 403.jsp를 생성해야 한다. 또한, 컨트롤러에 /403 요청을 처리하는 핸들러 메소드도 만들어야 한다.
  4. 인증 필터는 로그인 서식의 POST 요청으로부터 인증에 필요한 정보(아이디와 패스워드)를 추출한다. 추출한 정보로 인증Authentication 객체에 저장한다. 부분적으로 채워진 인증 객체를 인증 관리자Authentication Manager에 전달한다.
  5. 인증 관리자는 필터로부터 인증 판단을 위임받는다. 인증 관리자는 인증 공급자 리스트로 설정한다.
  6. 실제로 사용자가 누구인지 검증하는 것은 인증 공급자이다. 인증 공급자는 데이터베이스와 같은 저장소에서 가져온 비밀번호와 인증 객체에 저장된 비밀번호를 비교한다. 비밀번호가 같으면 신원 객체Principal를 만들어 인증 객체에 추가하고 인증 객체를 반환한다.

스프링 설정을 나눠서 별도의 파일로 만들 수 있다.

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" 
  xsi:schemaLocation="http://www.springframework.org/schema/beans 
    http://www.springframework.org/schema/beans/spring-beans.xsd">

  <bean id="gaeEntryPoint" 
    class="net.java_school.spring.security.GoogleAccountsAuthenticationEntryPoint" /><!--인증 진입점-->

  <bean id="gaeFilter" class="net.java_school.spring.security.GaeAuthenticationFilter"><!--인증 필터-->
    <property name="authenticationManager" ref="authenticationManager" /><!--인증 관리자-->
  </bean>
      
  <bean id="gaeAuthenticationProvider" 
    class="net.java_school.spring.security.GoogleAccountsAuthenticationProvider" /><!--인증 공급자-->
	
</beans>

guestbook-servlet.xml 파일을 다음과 같이 수정한다.

guestbook-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" 
    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">
    
  <security:global-method-security pre-post-annotations="enabled" /><!--1. 반드시 이곳에-->
      
  <mvc:resources mapping="/resources/**" location="/resources/" />
  
  <mvc:annotation-driven />
  
  <context:component-scan base-package="net.java_school.guestbook" />
  
  <bean id="internalResourceViewResolver" 
      class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass">
      <value>org.springframework.web.servlet.view.JstlView</value>
    </property>
    <property name="prefix">
      <value>/WEB-INF/views/</value>
    </property>
    <property name="suffix">
      <value>.jsp</value>
    </property>
  </bean>

</beans>
  1. 스프링 시큐리티 설정은 분리해서 security.xml을 만들었다. 하지만 이 설정만큼은 -servlet.xml로 끝나는 스프링 설정 파일에 있어야 한다.

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">
           
  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value><!--1. 스프링 설정 파일 목록-->
      /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-mapping>
    <filter-name>encodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
  
  <filter>
    <filter-name>springSecurityFilterChain</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
  </filter>
      
  <filter-mapping>
    <filter-name>springSecurityFilterChain</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

  <!-- [START Objectify] -->  
  <filter>
    <filter-name>ObjectifyFilter</filter-name>
    <filter-class>com.googlecode.objectify.ObjectifyFilter</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>ObjectifyFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
  <listener>
    <listener-class>net.java_school.guestbook.OfyHelper</listener-class>
  </listener>
  <!-- [END Objectify] -->
  
  <servlet>
    <servlet-name>guestbook</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  
  <servlet-mapping>
    <servlet-name>guestbook</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>
  
</web-app>
  1. 스프링 MVC 서블릿과 관련된 설정 파일(guestbook-servlet.xml)이 로드되기 전에 로드할 스프링 설정 파일 목록

guestbook.jsp 수정

guestbook.jsp을 열고 Sign In은 그대로 두고 Sign out 링크를 아래와 같이 수정한다.

수정 전
<a href="<%= userService.createLogoutURL("/guestbook/?guestbookName=" + guestbookName) %>">sign out</a>
수정 후
<a href="/logout">sign out</a>

스프링 시큐리티를 사용하게 되면 GET 방식을 제외한 모든 요청에는 CSRF 토큰 파라미터가 있어야 한다. guestbook.jsp에서 아래 폼에 CSRF 토큰 파라미터를 추가한다.

<form action="/guestbook/sign" method="post">
  <div><textarea name="content" rows="3" cols="60"></textarea></div>
  <div><input type="submit" value="Post Greeting"/></div>
  <input type="hidden" name="guestbookName" value="${fn:escapeXml(guestbookName)}"/>
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
</form>

appengine-web.xml 수정

appengine-web.xml에 다음을 추가한다.

/WEB-INF/appengine-web.xml
<sessions-enabled>true</sessions-enabled>

GuestbookController.java 수정

GuestbookController.java
package net.java_school.guestbook;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;
import com.googlecode.objectify.ObjectifyService;

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Controller
public class GuestbookController {
  
  @GetMapping("/")
  public String home() {
    return "index";
  }

  @GetMapping("/admin")
  public String adminHome() {
    return "admin/index";
  }

  @GetMapping("/403")
  public String error403() {
    return "403";
  }

  @GetMapping("/logout")
  public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
    request.getSession().invalidate();
    String url = UserServiceFactory.getUserService().createLogoutURL("/guestbook");
    response.sendRedirect(url);
  }
  
  @GetMapping("/guestbook")
  public String guestbook(String guestbookName, Model model) {
    model.addAttribute("guestbookName", guestbookName);
    return "guestbook/guestbook";
  }

  @PostMapping("/guestbook/sign")
  public String sign(HttpServletRequest req, HttpServletResponse resp) {
    Greeting greeting;
    UserService userService = UserServiceFactory.getUserService();
    User user = userService.getCurrentUser();
    String guestbookName = req.getParameter("guestbookName");
    String content = req.getParameter("content");
    if (user != null) {
      greeting = new Greeting(guestbookName, content, user.getUserId(), user.getEmail());
    } else {
      greeting = new Greeting(guestbookName, content);
    }
    // Use Objectify to save the greeting and now() is used to make the call synchronously as we
    // will immediately get a new page using redirect and we want the data to be present.
    ObjectifyService.ofy().save().entity(greeting).now();
    return "redirect:/guestbook/?guestbookName=" + guestbookName;
  }

}

테스트에 필요한 파일 추가

/WEB-INF/views/index.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://www.springframework.org/security/tags" prefix="security" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Home</title>
</head>
<body>
<article>
<h1>Home</h1>
<ul>
  <li><a href="/guestbook">Guestbook</a></li>
  <security:authorize access="hasRole('ROLE_ADMIN')">
    <li><a href="/admin">Admin</a></li>
  </security:authorize>
</ul>
</article>
</body>
</html>
/WEB-INF/views/403.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Error 403</title>
</head>
<body>
<article>
<h1>403</h1>
<p>
Error 403 - Access denied
</p>
</article>
</body>
</html>
/WEB-INF/views/admin/index.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Admin</title>
</head>
<body>
<article>
<h1>Admin</h1>
<p>
Admin page
</p>
</article>
</body>
</html>

로컬 테스트

mvn clean
mvn appengine:run

http://localhost:8080을 방문하고 Guestbook 링크를 클릭한다.
Access Homepage
Sign in를 클릭한다.
Guestbook Sign in
로그인 페이지에서 Sign in as Administrator를 체크하고 로그인한다.
Sign in by Admin
Guestbook
관리자로 로그인된 상태에서 다시 http://localhost:8080을 방문한다. Admin 링크를 클릭한다.
Access Homepage and Admin
Admin Page
다시 방명록 페이지, http://localhost:8080/guestbook로 돌아와서 로그아웃하고 Sign in를 클릭한다.
Guestbook Sign in
이번에는 일반 사용자로 로그인한다.
Sign in by normal user
http://localhost:8080을 방문하여 Admin 링크가 사라진 것을 확인한다.
Access Homepage
웹 브라우저의 주소창에 localhost:8080/admin을 입력하여 관리자 페이지로의 접근을 시도한다.
Access Denied

Error 405 Request method 'POST' not supported
스프링 시큐리티를 적용한 다음 자주 보게 되는 에러다. CSRF 토큰을 전송하지 않았을 때 발생한다.
HTTP Status 405 Request method 'POST' not supported
요청 방식(HTTP method)과 컨트롤러에서 받는 방식이 달랐을 때 발생한다.
Error: request POST not supported
이 에러는 다양한 원인으로 발생한다. 구글 앱 엔진 콘솔에서 로그를 보고 대처한다.
참고