Niklas
Niklas

Reputation: 25391

Migrating away from Spring Security OAuth 2

I'm having a Spring Boot Auth Microservice. It uses the Oauth2 spring cloud starter dependency which is deprecated nowadays.

buildscript {
  dependencies {
    classpath "org.springframework.boot:spring-boot-gradle-plugin:2.1.9.RELEASE"
  }
}

dependencies {
  implementation "org.springframework.boot:spring-boot-starter-actuator"
  implementation "org.springframework.boot:spring-boot-starter-data-jpa"
  implementation "org.springframework.boot:spring-boot-starter-web"
  implementation "org.springframework.cloud:spring-cloud-starter-oauth2:2.1.5.RELEASE"
}

The Schema was taken from here: https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/test/resources/schema.sql

It also has a custom user_details table. The JPA class is implementing UserDetails. I've also provided an implementation for UserDetailsService which looks up the user in my custom table.

OAuth Configuration is quite forward:

AuthorizationServerConfiguration - where oauth is configured:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableAuthorizationServer
class AuthorizationServerConfiguration : AuthorizationServerConfigurerAdapter() {
    @Autowired private lateinit var authenticationManager: AuthenticationManager
    @Autowired private lateinit var dataSource: DataSource

    @Autowired
    @Qualifier("customUserDetailsService")
    internal lateinit var userDetailsService: UserDetailsService

    @Autowired
    private lateinit var passwordEncoder: BCryptPasswordEncoder

    override fun configure(endpoints: AuthorizationServerEndpointsConfigurer) {
        endpoints
                .tokenStore(JdbcTokenStore(dataSource))
                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService)
    }

    override fun configure(clients: ClientDetailsServiceConfigurer) {
        // This one is used in conjunction with oauth_client_details. So like there's one app client and a few backend clients.
        clients.jdbc(dataSource)
    }

    override fun configure(oauthServer: AuthorizationServerSecurityConfigurer) {
        oauthServer.passwordEncoder(passwordEncoder)
    }
}

WebSecurityConfiguration - needed for class above:

@Configuration
class WebSecurityConfiguration : WebSecurityConfigurerAdapter() {
  @Bean // We need this as a Bean. Otherwise the entire OAuth service won't work.
  override fun authenticationManagerBean(): AuthenticationManager {
    return super.authenticationManagerBean()
  }

  override fun configure(http: HttpSecurity) {
    http.sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
  }
}

ResourceServerConfiguration - to configure access for endpoints:

@Configuration
@EnableResourceServer
class ResourceServerConfiguration : ResourceServerConfigurerAdapter() {
  override fun configure(http: HttpSecurity) {
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and().cors().disable().csrf().disable()
        .authorizeRequests()

        .antMatchers("/oauth/token").authenticated()
        .antMatchers("/oauth/user/**").authenticated()

        .antMatchers("/oauth/custom_end_points/**").hasAuthority("my-authority")

        // Deny everything else.
        .anyRequest().denyAll()
  }
}

These few lines give me a lot.

select client_id, access_token_validity, authorities, authorized_grant_types, refresh_token_validity, scope from oauth_client_details;
     client_id     | access_token_validity |          authorities          |          authorized_grant_types           | refresh_token_validity |  scope
-------------------+-----------------------+-------------------------------+-------------------------------------------+------------------------+---------
 backend           |                864000 | mail,push,app-register        | mail,push,client_credentials              |                 864000 | backend
 app               |                864000 | grant                         | client_credentials,password,refresh_token |                      0 | app

This is used by the app if there's no oauth token yet.

Other microservices also use this to protect their endpoints - such as in this example:

@Configuration @EnableResourceServer class ResourceServerConfig : ResourceServerConfigurerAdapter() {
  override fun configure(http: HttpSecurity) {
    http.authorizeRequests()
        // Coach.
        .antMatchers("/api/my-api/**").hasRole("my-role")
        .antMatchers("/registration/**").hasAuthority("my-authority")
  }
}

Their set up is quite easy:

security.oauth2.client.accessTokenUri=http://localhost:20200/oauth/token
security.oauth2.client.userAuthorizationUri=http://localhost:20200/oauth/authorize
security.oauth2.resource.userInfoUri=http://localhost:20200/oauth/user/me
security.oauth2.client.clientId=coach_client
security.oauth2.client.clientSecret=coach_client

The first three properties just go to my authorization server. The last two properties are the actual username + password that I've also inserted inside the oauth_client_details table. When my microservice wants to talk to another microservice it uses:

val details = ClientCredentialsResourceDetails()
details.clientId = "" // Values from the properties file.
details.clientSecret = "" // Values from the properties file.
details.accessTokenUri = "" // Values from the properties file.
val template = OAuth2RestTemplate(details)
template.exchange(...)

Now my question is - how can I get all of this with the built in Support from Spring Security using Spring Boot? I'd like to migrate away from the deprecated packages and retain all tokens so that users are still logged in afterwards.

Upvotes: 2

Views: 1531

Answers (2)

Niklas
Niklas

Reputation: 25391

So I've ended up developing my own authentication system with a migration API from the old Spring Security OAuth 2 to my system. That way you are not logged out and need to re-login.

I'll describe how I did it in case anyone is interested.

In my scenario it is 2 'microservices'. One being the deprecated auth and the other leveraging it.

Legacy Authentication System

  • To either get a token as a user you'd send a request to /oauth/token with your username + password.
  • To refresh a token another request to /oauth/token with your refresh token.
  • Both cases return your access token + refresh token. You can execute this multiple times per devices and you'd always end up with the same tokens. This is important later.
  • Tokens are stored as MD5 hashed.

Spring Security OAuth has these tables defined:

  • oauth_access_token (access tokens)
  • oauth_approvals (don't know what for, is always empty in my case)
  • oauth_client_details (contains a basic authorization method when you're not authorized)
  • oauth_client_token (empty in my case)
  • oauth_code (empty in my case)
  • oauth_refresh_token (refresh tokens)
  • user_details (contains the user data)
  • user_details_user_role (association between user + roles)
  • user_role (your roles)

I really didn't use the multi roles functionality, but in any case it's trivial to take that into consideration as well.

New Authentication System

  • Access token & refresh tokens are uuid4's that I SHA256 into my table.
  • I can query them easily and check for expiration and throw appropriate HTTP status codes.
  • I ended up doing a per device (it's just a UUID generated once in the frontend) system. That way I can distinguish when a user has multiple devices (AFAIK, this isn't possible with the old system).
  • We need these new endpoints
    • Login with email + password to get an authentication
    • Migration call from the old tokens to your new ones
    • Logout call which deletes your authentication
    • Refresh access token call

Thoughts

  1. I can keep using the user_details table since only my code interacted with it and I expose it via Springs UserDetailsService.
  2. I'll create a new authentication table that has a n:1 relationship to user_details where I store a device id, access token, access token expiry & refresh token per user.
  3. To migrate from the old to the new system, my frontend will send a one time migration request, where I check for the given access token if it's valid and if it is, I generate new tokens in my system.
  4. I'll handle both systems in parallel by distinguishing at the header level Authorization: Bearer ... for the old system & Authorization: Token ... for the new system

Code snippets

I use Kotlin, so in order to have type safety and not accidentally mix up my old / new token I ended up using a sealed inline classes:

sealed interface AccessToken

/** The token from the old mechanism. */
@JvmInline value class BearerAccessToken(val hashed: String) : AccessToken

/** The token from the new mechanism. */
@JvmInline value class TokenAccessToken(val hashed: String) : AccessToken

To get my token from an Authorization header String:

private fun getAccessToken(authorization: String?, language: Language) = when {
  authorization?.startsWith("Bearer ") == true -> BearerAccessToken(hashed = hashTokenOld(authorization.removePrefix("Bearer ")))
  authorization?.startsWith("Token ") == true -> TokenAccessToken(hashed = hashTokenNew(authorization.removePrefix("Token ")))
  else -> throw BackendException(Status.UNAUTHORIZED, language.errorUnauthorized())
}

internal fun hashTokenOld(token: String) = MessageDigest.getInstance("MD5").digest(token.toByteArray(Charsets.UTF_8)).hex()
internal fun hashTokenNew(token: String) = MessageDigest.getInstance("SHA-256").digest(token.toByteArray(Charsets.UTF_8)).hex()

Verifying the tokens with type safety gets pretty easy:

when (accessToken) {
  is BearerAccessToken -> validateViaDeprecatedAuthServer(role)
  is TokenAccessToken -> {
    // Query your table for the given accessToken = accessToken.hashed
    // Ensure it's still valid and exists. Otherwise throw appropriate Status Code like Unauthorized.
    // From your authentication table you can then also get the user id and work with your current user & return it from this method.
  }
}

The validateViaDeprecatedAuthServer is using the old authentication sytem via the Spring APIs and returns the user id:

fun validateViaDeprecatedAuthServer(): String {
  val principal = SecurityContextHolder.getContext().authentication as OAuth2Authentication
  requireElseUnauthorized(principal.authorities.map { it.authority }.contains("YOUR_ROLE_NAME"))
  return (principal.principal as Map<*, *>)["id"] as? String ?: throw IllegalArgumentException("Cant find id in principal")
}

Now we can verify if a given access token from a frontend is valid. The endpoint which generates a new token from the old one is also quite simple:

fun migrateAuthentication(accessToken: AccessToken) when (origin.accessToken(language)) {
  is BearerAccessToken -> {
    val userId = validateViaDeprecatedAuthServer(role)
    // Now, create that new authentication in your new system and return it.
    createAuthenticationFor()
  }
  is TokenAccessToken -> error("You're already migrated")
}

Creating authentication in your new system might look like this:

fun createAuthenticationFor() {
  val refreshToken = UUID.randomUUID().toString()
  val accessToken = UUID.randomUUID().toString()

  // SHA256 both of them and save them into your table.

  return refreshToken to accessToken
}

Then you only need some glue for your new 'login' endpoint where you need to check that the email / password matches a given user in your table, create an authentication & return it.

Logout just deletes the given authentication for your user id + device id.

Afterthoughts

I've been using this system now for the last few days and so far it's working nicely. Users are migrating. No one seems to be logged out which is exactly what I've wanted.

One downside is that since the old authentication system didn't distinguish between devices, I have no way of knowing when a user has successfully migrated. He could be using 1 device or 10. I simply don't know. So both systems will need to live side by side for a rather long time and slowly I'll phase out the old system. In which case, I'll force logout you and you need to re-login (and potentially install a new App version if you haven't updated).

Note that the new system is limited to my own needs, which is exactly what I want. I'd prefer it to be simple and maintainable than the Spring Blackbox authentication system.

Upvotes: 1

Laures
Laures

Reputation: 5479

We are also running a spring security authorization server and looked into this. Right now there is no replacement for the authorization server component in spring and there does not seem to be a timeline to implement one. Your best option would be to look into an existing auth component like keycloak or nimbus. alternatively there are hosted service like okta or auth0.

Keeping your existing tokens will be a bit of a challange as you would need to import them into your new solution. Our current tokens are opaque while newer auth-solutions tend to use some version of jwt, so depending on your tokens, keeping them may not even be an option.

Right now we consider accepting both old and new tokens for a time until the livetime of our old tokens ends, at wich point we would move fully to the new infrastukture.

Upvotes: 1

Related Questions