Spring Security

http://spring.io/blog/2010/08/02/spring-security-in-google-app-engine/ explains:

  1. Security context setup with Google account authentication (not Multiple Authentication)
  2. How to create a custom Authentication class
  3. Add credentials to your Google account information
  4. Access control based on credentials information
  5. Store user information (Google account + credentials) in datastore (using native API)
  6. Prohibit bad user access

We will implement our site that can operate without storing user information as persistent data. Therefore, keeping user information in the datastore and preventing inappropriate user access is not implemented.

Add Spring Security dependencies to pom.xml

pom.xml
<properties>
  <!-- omit -->
  <spring.security.version>5.8.10</spring.security.version>
</properties>
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] -->      

The following are the files needed for Google account and Spring security interworking.

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);
  }

}

You must write Spring configuration for Spring Security as a separate file.

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">
     
  <!--1. Security exclusion settings-->
  <http pattern="/_ah/login" security="none" />
  <http pattern="/_ah/logout" security="none" />
   
  <http entry-point-ref="gaeEntryPoint"><!--2. Authentication entry point-->
    <access-denied-handler error-page="/403" /><!--3. error page-->
    <intercept-url pattern="/admin/**" access="hasRole('ROLE_ADMIN')" />
    <intercept-url pattern="/**" access="permitAll" />
    <custom-filter position="PRE_AUTH_FILTER" ref="gaeFilter" /><!--4. Authentication Filter-->
  </http>

  <authentication-manager alias="authenticationManager"><!--5. Authentication Manager-->
    <authentication-provider ref="gaeAuthenticationProvider" /><!--6. Authentication Provider-->
  </authentication-manager>
	
</beans:beans>
  1. This configuration is inevitable because login and logout perform outside.
  2. The authentication entry point redirects the user to the login page.
  3. It specifies the page to which the system redirects unauthorized users. Our view resolver will interpret /403 as /WEB-INF/views/403.jsp, so we need to create 403.jsp in the /WEB-INF/views folder and add a handler method that handles /403 requests to the controller.
  4. The authentication filter extracts the information (ID and password) required for authentication from the POST request of the login form. It saves the extracted data in the Authentication object and passes the Authentication object to the Authentication Manager.
  5. The authentication manager delegates the authentication decision to one of the authentication providers.
  6. Authentication providers verify who the user is. They compare the password retrieved from the repository with the password stored in the authentication object. If the passwords are the same, create a Principal and add it to the authentication object and return the authentication object.

You can split the Spring configuration into separate files.

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" /><!--Authentication Entry Point-->

  <bean id="gaeFilter" class="net.java_school.spring.security.GaeAuthenticationFilter"><!--Authentication Filter-->
    <property name="authenticationManager" ref="authenticationManager" /><!--Authentication Manager-->
  </bean>
      
  <bean id="gaeAuthenticationProvider" 
      class="net.java_school.spring.security.GoogleAccountsAuthenticationProvider" /><!--Authentication Provider-->
	
</beans>

Modify guestbook-servlet.xml as follows.

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. This config must be in this file.-->
      
  <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. Most of the Spring security configuration is in the security.xml file. However, this configuration must be in a Spring configuration file ending in -servlet.xml.

Modify 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. Spring configuration files-->
      /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. Spring configuration files to load before the configuration file (guestbook-servlet.xml) related to the Spring MVC servlet is loaded.

Modify guestbook.jsp

Before
<a href="<%= userService.createLogoutURL("/guestbook/?guestbookName=" + guestbookName) %>">sign out</a>
After
<a href="/logout">sign out</a>

To use Spring Security, all requests except the GET method must have the CSRF token parameter.

In guestbook.jsp, add the CSRF token parameter to the form.

<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>

Modify appengine-web.xml

Make the session available by adding the following to appengine-web.xml:

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

Modify 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;
  }

}

Create Files for Testing

/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>

Local Test

mvn clean
mvn appengine:run

Visit the http://localhost:8080 and click the Guestbook link.

Access Homepage

Click Sign in link.

Guestbook Sign in

On the login page, check Sign in as Administrator and log in.

Sign in by Admin

Guestbook

Log in as an administrator and revisit the http://localhost:8080 and click the Admin link.

Access Homepage and Admin

Admin Page

Go back to the http://localhost:8080/guestbook and sign out and click Sign in link.

Guestbook Sign in

Log in as a regular user.

Sign in by normal user

Visit http://localhost:8080. Confirm that the Admin link disappears.

Access Homepage

Try to access the admin page by typing http://localhost:8080/admin in the address bar of your web browser to confirm the system show the 403.jsp to the user.

Access Denied

Error 405 Request method 'POST' not supported
This error occurs when the CSRF token is not transmitted.
HTTP Status 405 Request method 'POST' not supported
This error occurs when the HTTP method is different from the way the controller receives it.
Error: request POST not supported
This error occurs for various reasons, so you should look at the Google App Engine console logs.
References