From POC To MPA
From POC To MPA
This is a follow up to a previous proof of concept using Spring Boot with Pac4J and Spring Security showing how to allow a user to login to an application using OAuth providers. In this article we’ll continue using that project and add in some things needed to get closer to a minimal practical application. The code is available on Github at https://github.com/aaronhanson/spring-boot-oauth-demo. The point of this is not necessarily to create a library or framework but more for understanding where and how things work underneath and one way to do something. That’s my disclaimer to say there are a few quick and dirty spots in here to keep things generic.
Checkout the step-4 tag to grab the code and run it with the Gradle Wrapper. It’ll complain unless you create an application.properties file. You can use the example one from src/main/resources/application.properties.example as a reference.
$ git checkout step-4 $ ./gradlew bootRun
Adding Registration
Instead of just letting anyone login it’s probably best to keep track of who has created an account so the application can do things like store settings, bill, etc..
Setting Up The Datastore
To start with for this example we’ll go simple and add in the Spring Boot JDBC starter and H2 embedded database, which will reset each time we restart the application.
Let’s modify our build.gradle and add the following dependencies:
compile 'org.springframework.boot:spring-boot-starter-jdbc' compile 'com.h2database:h2'
For convenience we can create a schema.sql file in the resources directory and add the following properties to our application.properties. In your application this would be however you decide to setup your database.
# datastore configuration spring.datasource.driverClassName=org.h2.Driver spring.datasource.url=jdbc:h2:mem:oauth-demo spring.datasource.username=sa spring.datasource.password= spring.datasource.initialize=true spring.datasource.continueOnError=false
In the schema.sql file we simply create whatever tables we need for the application and they’ll get bootstrapped in for us. In a production application it’s likely you’d use Liquibase or some similar tool to manage the datastore, and would also likely have some roles or permissions as well. In this example a ‘user’ role will be granted by default on successful authentication.
src/main/resources/schema.sql
CREATE TABLE account ( id IDENTITY, display_name VARCHAR, provider VARCHAR NOT NULL, provider_user_id VARCHAR NOT NULL, PRIMARY KEY (id) );
Success Handler
Instead of the default success handler for the OAuth client filter we want to create our own that checks to see if the user is registered or not and redirect them to the registration page if this is the first time they’re visiting the site or have yet to register. We can do this by creating our own handler and doing whatever logic we need to which in this case is checking to see if the authentication object has any authorities. If not it redirects to the registration page. Since we’re granting a ‘user’ authority by default we can use this rather than hitting a datastore. I am also doing something a bit non-standard here in clearing the security context. This is done to force the user to register. If the user is left authenticated they could navigate away from the registration and we don’t want that. If they do navigate away they’ll have to login again and be redirected to the registration again.
package springboot.pac4j import groovy.util.logging.Slf4j import org.pac4j.core.profile.UserProfile import org.pac4j.springframework.security.authentication.ClientAuthenticationProvider import org.pac4j.springframework.security.authentication.ClientAuthenticationToken import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler import org.springframework.security.web.savedrequest.HttpSessionRequestCache import org.springframework.security.web.savedrequest.RequestCache import org.springframework.security.web.savedrequest.SavedRequest import org.springframework.util.StringUtils import springboot.pac4j.service.AccountService import javax.servlet.ServletException import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse @Slf4j class ClientSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private RequestCache requestCache = new HttpSessionRequestCache() String registrationUrl = "/registration" @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { SavedRequest savedRequest = requestCache.getRequest(request, response) if (authentication instanceof ClientAuthenticationToken) { if (authentication.authorities.isEmpty()) { request.session.setAttribute("savedAuthToken", authentication) // "log them out" until they finish the registration SecurityContextHolder.clearContext() getRedirectStrategy().sendRedirect(request, response, registrationUrl) return } } if (savedRequest == null) { super.onAuthenticationSuccess(request, response, authentication) return } String targetUrlParameter = getTargetUrlParameter() boolean hasTargetUrl = targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)) if (isAlwaysUseDefaultTargetUrl() || hasTargetUrl) { requestCache.removeRequest(request, response) super.onAuthenticationSuccess(request, response, authentication) return } clearAuthenticationAttributes(request) // redirect back to where the user was going getRedirectStrategy().sendRedirect(request, response, savedRequest.getRedirectUrl()) } }
In order to query the datastore we’ll need a simple service to fetch the data we need. We’ll use some straight JDBC queries for this demo but this can be implemented with whatever datastore library you prefer.
package springboot.pac4j.service import groovy.util.logging.Slf4j import org.springframework.beans.factory.annotation.Autowired import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Service import springboot.pac4j.util.GenericRowMapper @Service @Slf4j class AccountService { @Autowired JdbcTemplate jdbcTemplate Map lookupAccountByProvider(String providerName, String providerUserId) { List results = jdbcTemplate.query( "select * from account where provider = ? and provider_user_id = ?", [providerName, providerUserId] as Object[], new GenericRowMapper() ) if (results.size() > 1) { throw new Exception("multiple accounts by provider [${providerName}] for id [${providerUserId}]") } return results.isEmpty() ? [:] : results[0] } Boolean createAccountForProvider(String providerName, String providerUserId, String displayName) { log.debug("creating new account for displayName=${displayName} using provider=${providerName} with id ${providerUserId}") int result = jdbcTemplate.update( "insert into account (display_name, provider, provider_user_id) values (?, ?, ?)", displayName, providerName, providerUserId ) if (result != 1) { log.warn("creation of account for provider [${providerName}] and id [${providerUserId}] failed") return false } return true } }
Account Controller
Next we’ll create the controller for handling the account registration. This controller will have two actions in it: one that is just a page to display account information (for you to implement) and the other to handle the registration form. The registration action is a bit more complicated due to the forced registration logic.
src/main/groovy/springboot/pac4j/controller/AccountController.groovy
package springboot.pac4j.controller import groovy.util.logging.Slf4j import org.pac4j.core.credentials.Credentials import org.pac4j.core.profile.UserProfile import org.pac4j.springframework.security.authentication.ClientAuthenticationToken import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.authentication.AnonymousAuthenticationToken import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.web.savedrequest.HttpSessionRequestCache import org.springframework.security.web.savedrequest.SavedRequest import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMethod import springboot.pac4j.security.ClientUserDetailsService import springboot.pac4j.service.AccountService import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse @Controller @Slf4j class AccountController { final String savedAuthAttribute = "savedAuthToken" @Value('${registration.successful.redirect.url:/}') String defaultRedirectUrl @Autowired ClientUserDetailsService clientUserDetailsService @Autowired AccountService accountService @RequestMapping("/account") @PreAuthorize('isAuthenticated()') String accounts() { return "account/index" } @RequestMapping(value="/registration") @PreAuthorize("permitAll") String registration(HttpServletRequest request, HttpServletResponse response) { Authentication auth = (Authentication) SecurityContextHolder.context.authentication Authentication registrationAuth = (Authentication) request.session.getAttribute(savedAuthAttribute) if (isPostRequest(request)) { if (registrationAuth && registrationAuth instanceof ClientAuthenticationToken) { if (registrationAuth.authorities.isEmpty()) { registerNewAccount(registrationAuth, request) resetAuthorizationToken(registrationAuth) request.session.removeAttribute(savedAuthAttribute) SavedRequest savedRequest = (SavedRequest) new HttpSessionRequestCache().getRequest(request, response) if (savedRequest) { return "redirect:${savedRequest.getRedirectUrl()}" } } } } else if (!isAuthenticated() && registrationAuth) { return "account/registration" } return "redirect:${defaultRedirectUrl}" } protected void registerNewAccount(ClientAuthenticationToken registrationAuth, HttpServletRequest request) { ClientAuthenticationToken registrationToken = (ClientAuthenticationToken) registrationAuth UserProfile profile = registrationToken.userProfile String displayName = request.getParameter("displayName") ?: '' accountService.createAccountForProvider(registrationToken.clientName, profile.id, displayName) } protected boolean isPostRequest(final HttpServletRequest request) { return request.method.equalsIgnoreCase(RequestMethod.POST.toString()) } protected boolean isAuthenticated() { Authentication auth = SecurityContextHolder.context.authentication return !(auth instanceof AnonymousAuthenticationToken) } protected void resetAuthorizationToken(ClientAuthenticationToken savedAuthToken) { UserDetails userDetails = clientUserDetailsService.loadUserDetails(savedAuthToken) SecurityContextHolder.context.authentication = new ClientAuthenticationToken( savedAuthToken.credentials as Credentials, savedAuthToken.clientName, savedAuthToken.userProfile, userDetails.authorities, userDetails ) } }
In the SecurityConfig.groovy we want to modify the clientFilter to set the custom success handler. This is what handles redirecting the user to the registration page if necessary after they log in.
ClientAuthenticationFilter clientFilter() { return new ClientAuthenticationFilter( clients: clients, sessionAuthenticationStrategy: sas(), authenticationManager: clientProvider as AuthenticationManager, authenticationSuccessHandler: clientSuccessHandler() ) } ClientSuccessHandler clientSuccessHandler() { return new ClientSuccessHandler() }
Sharing Sessions
It’s unlikely that we’d only ever run one instance of an application so in practice we’d have a load balancer for our requests. This poses a problem with the current implementation in that if one of our servers goes down the user if forced to log in again which in some cases might not really be too much of an inconvenience, but we can do better without much effort.
In this case we’ll use Redis to store our session cache which will allow our multiple instances to share and allow for a better user experience.
In order to get Redis wired in we need to add a couple more dependencies.
build.gradle
compile 'org.springframework.session:spring-session:1.1.0.M1' compile "org.springframework.boot:spring-boot-starter-redis"
And then we need to configure the application.properties for using Redis. In this case I have a local instance running, if you’re using a non-local instance you can adjust your values.
application.properties
# for redis http sessions spring.redis.host=localhost spring.redis.port=6379 spring.redis.password=
And then to get the application configured to use Redis for session data we need to add the @EnableRedisHttpSession annotation to one of our config classes. For this demo we’ll create an HttpSessionConfig.groovy class to add the annotation to, but it could go on another config class as well.
src/main/groovy/springboot/pac4j/conf/HttpSessionConfig.groovy
package springboot.pac4j.conf import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession @EnableRedisHttpSession class HttpSessionConfig { }
Now if we tried to run the application it would start up but we’d get an exception about some serialization. With the version of the Pac4J we’re using we need to wire in a couple of classes to get around some things.
First we need a concrete UserDetails class. The Pac4J library version uses an anonymous inner class which works fine but doesn’t play well when trying to use Redis for the sessions. We can just pull out the implementation and create our own class.
src/main/groovy/springboot/pac4j/security/ClientUserDetails.groovy
package springboot.pac4j.security import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.userdetails.UserDetails class ClientUserDetails implements UserDetails { private static final long serialVersionUID = 6523314653561682296L String username String providerId Collection authorities String password boolean accountNonExpired = true boolean accountNonLocked = true boolean credentialsNonExpired = true boolean enabled = true }
And then we need a customized version of the UserDetailsService. Again we can just grab the Pac4J implementation and tweak it just a bit by using the concrete class.
src/main/groovy/springboot/pac4j/security/ClientUserDetailsService.groovy
package springboot.pac4j.security import groovy.util.logging.Slf4j import org.pac4j.springframework.security.authentication.ClientAuthenticationToken import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.AuthenticationUserDetailsService import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UsernameNotFoundException import springboot.pac4j.service.AccountService @Slf4j public class ClientUserDetailsService implements AuthenticationUserDetailsService { AccountService accountService public UserDetails loadUserDetails(final ClientAuthenticationToken token) throws UsernameNotFoundException { Map account = accountService.lookupAccountByProvider(token.clientName, token.userProfile.id) String username = account.containsKey("displayName") ? account.displayName : "" final List authorities = new ArrayList() for (String role: token.getUserProfile().getRoles()) { authorities.add(new SimpleGrantedAuthority(role)) } if (!account.isEmpty() && authorities.isEmpty()) { // default to user role authorities.add(new SimpleGrantedAuthority("ROLE_USER")) } return new ClientUserDetails(username: username, providerId: token.userProfile.id, authorities: authorities) } }
Finally we need to modify the Pac4J configuration to wire our version of the UserDetailsService.
src/main/groovy/springboot/pac4j/conf/Pac4jConfig.groovy
@Bean ClientUserDetailsService clientUserDetailsService() { return new ClientUserDetailsService(accountService: accountService) } @Bean ClientAuthenticationProvider clientProvider() { return new ClientAuthenticationProvider( clients: clients(), userDetailsService: clientUserDetailsService() ) }
Now we can start up the application and when we login in with a provider it should prompt us to register.
$ ./gradlew bootRun
We can run the application on another port but we won’t be able to log in with the alternate port due to how the OAuth redirects. If we log in with the normal application we should be able to visit the other application and see that we’re already logged in. If we instead ran this behind a load balancer like Nginx then we’d be able to shut down one or the other and still be OK.
$ ./gradlew build $ java -Dserver.port=8081 -jar build/libs/spring-boot-oauth-demo.jar
Misc things
I mentioned before about handling users that are already logged in when they try to hit the login page directly again. This can be done a variety of ways but one simple one in this case is to check and see if the user is anonymous or not when they’re reaching the login page. We can check that by adding the following method to the LoginController.
protected boolean isAuthenticated() { Authentication auth = SecurityContextHolder.context.authentication return !(auth instanceof AnonymousAuthenticationToken) }
And then we just call that up from in the controller action and redirect somewhere, like ‘/’, if the user is already logged in.
if (isAuthenticated()) { return "redirect:/" }
Authorities
There is an AdminController in the application too that is only available if the user is an ‘admin’. Since this example does not load authorities from the datastore there is no way for any use to have this authority but I included it to show that the security permissions work for restricting access. After logging in go ahead and try to browse to /admin and you should see an access denied response.