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.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

*