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 a site that can operate without storing user information as persistent data.
Therefore, storing user information in the datastore and prohibiting bad user access will not be implemented.

Add Spring Security dependencies to pom.xml

pom.xml
<properties>
	<!-- omit -->
	<spring.security.version>5.1.3.RELEASE</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);
    }

}

Spring configuration for spring security should be written 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 is inevitable because login and logout are performed at outside.
  2. The authentication entry point simply redirects the user to the login page.
  3. Specify the page to show to unauthorized users.
    The view resolver will interpret /403 as /WEB-INF/views/403.jsp, so you need to create 403.jsp in the /WEB-INF/views directory.
    You also need to create 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 information in the Authentication object and passes the partially filled the Authentication object to Authentication Manager.
  5. The authentication manager that has been delegated authentication judgment from the filter is set to the authentication provider list.
  6. It is the authentication provider that actually verifies who the user is.
    The authentication provider compares the password retrieved from the repository like a database 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. 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. I have created security.xml separately from the Spring security configuration. However, this setting should be in a Spring configuration file ending with -servlet.xml.

Modify web.xml as follows.

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_3_1.xsd" version="3.1">
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value><!--1. List of 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>
    <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>
	<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. List of 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 below.

<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

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

}

Adding 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.
Guestbook Sign in
On the login page, check Sign in as Administrator and login.
Sign in by Admin
Guestbook
Log in as an administrator and visit the http://localhost:8080 again.
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.
Guestbook Sign in
Log in as a normal user.
Sign in by normal user
Visit the 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.
Access Denied

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