What I am trying to achieve is:
- users, authorities, clients and access tokens stored in a database (i.e. MySQL) accessed via jdbc
- API exposes endpoints for you to ask "can I have an OAuth2 bearer token? I know the client ID and secret"
- API lets you access MVC endpoints if you supply a Bearer token in your request header
I got pretty far with this — the first two points are working.
I was not able to use a completely default OAuth2 setup for my Spring Boot application, because the standard table names are already in-use in my database (I have a "users" table already, for example).
I constructed my own instances of JdbcTokenStore, JdbcClientDetailsService, and JdbcAuthorizationCodeServices manually, configured them to use the custom table names from my database, and set up my application to use these instances.
So, here's what I have so far. I can ask for a Bearer token:
# The `-u` switch provides the client ID & secret over HTTP Basic Auth
curl -u8fc9d384-619a-11e7-9fe6-246798c61721:9397ce6c-619a-11e7-9fe6-246798c61721 \
'http://localhost:8080/oauth/token' \
-d grant_type=password \
-d username=bob \
-d password=tom
I receive a response; nice!
{"access_token":"1ee9b381-e71a-4e2f-8782-54ab1ce4d140","token_type":"bearer","refresh_token":"8db897c7-03c6-4fc3-bf13-8b0296b41776","expires_in":26321,"scope":"read write"}
Now I try to use that token:
curl 'http://localhost:8080/test' \
-H "Authorization: Bearer 1ee9b381-e71a-4e2f-8782-54ab1ce4d140"
Alas:
{
"timestamp":1499452163373,
"status":401,
"error":"Unauthorized",
"message":"Full authentication is required to access this resource",
"path":"/test"
}
This means (in this particular case) that it has fallen back to anonymous authentication. You can see the real error if I add .anonymous().disable()
to my HttpSecurity:
{
"timestamp":1499452555312,
"status":401,
"error":"Unauthorized",
"message":"An Authentication object was not found in the SecurityContext",
"path":"/test"
}
I investigated this more deeply by increasing the logging verbosity:
logging.level:
org.springframework:
security: DEBUG
This reveals the 10 filters through which my request travels:
o.s.security.web.FilterChainProxy : /test at position 1 of 10 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
o.s.security.web.FilterChainProxy : /test at position 2 of 10 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
w.c.HttpSessionSecurityContextRepository : No SecurityContext was available from the HttpSession: null. A new one will be created.
o.s.security.web.FilterChainProxy : /test at position 3 of 10 in additional filter chain; firing Filter: 'HeaderWriterFilter'
o.s.security.web.FilterChainProxy : /test at position 4 of 10 in additional filter chain; firing Filter: 'LogoutFilter'
o.s.security.web.FilterChainProxy : /test at position 5 of 10 in additional filter chain; firing Filter: 'BasicAuthenticationFilter'
o.s.security.web.FilterChainProxy : /test at position 6 of 10 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'
o.s.security.web.FilterChainProxy : /test at position 7 of 10 in additional filter chain; firing Filter: 'SecurityContextHolderAwareRequestFilter'
o.s.security.web.FilterChainProxy : /test at position 8 of 10 in additional filter chain; firing Filter: 'SessionManagementFilter'
o.s.security.web.FilterChainProxy : /test at position 9 of 10 in additional filter chain; firing Filter: 'ExceptionTranslationFilter'
o.s.security.web.FilterChainProxy : /test at position 10 of 10 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'
o.s.s.w.a.i.FilterSecurityInterceptor : Secure object: FilterInvocation: URL: /test; Attributes: [authenticated]
o.s.s.w.a.ExceptionTranslationFilter : Authentication exception occurred; redirecting to authentication entry point
org.springframework.security.authentication.AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext
at org.springframework.security.access.intercept.AbstractSecurityInterceptor.credentialsNotFound(AbstractSecurityInterceptor.java:379) ~[spring-security-core-4.2.3.RELEASE.jar:4.2.3.RELEASE]
That's what it looks like if Anonymous users are disabled. If they're enabled: AnonymousAuthenticationFilter
is added into the filter chain just after SecurityContextHolderAwareRequestFilter
, and the sequence ends more like this:
o.s.security.web.FilterChainProxy : /test at position 11 of 11 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'
o.s.s.w.a.i.FilterSecurityInterceptor : Secure object: FilterInvocation: URL: /test; Attributes: [authenticated]
o.s.s.w.a.i.FilterSecurityInterceptor : Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@9055c2bc: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
o.s.s.access.vote.AffirmativeBased : Voter: org.springframework.security.web.access.expression.WebExpressionVoter@5ff24abf, returned: -1
o.s.s.w.a.ExceptionTranslationFilter : Access is denied (user is anonymous); redirecting to authentication entry point
org.springframework.security.access.AccessDeniedException: Access is denied
at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:84) ~[spring-security-core-4.2.3.RELEASE.jar:4.2.3.RELEASE]
Either way: no good.
Essentially it indicates to me that we are missing some step in the filter chain. We need a filter that would read the header of the ServletRequest, then populate the security context's authentication:
SecurityContextHolder.getContext().setAuthentication(request: HttpServletRequest);
I wonder how to get such a filter?
This is what my application looks like. It's Kotlin, but hopefully it should make sense to the Java eye.
Application.kt:
@SpringBootApplication(scanBasePackageClasses=arrayOf(
com.example.domain.Package::class,
com.example.service.Package::class,
com.example.web.Package::class
))
class MyApplication
fun main(args: Array<String>) {
SpringApplication.run(MyApplication::class.java, *args)
}
TestController:
@RestController
class TestController {
@RequestMapping("/test")
fun Test(): String {
return "hey there"
}
}
MyWebSecurityConfigurerAdapter:
@Configuration
@EnableWebSecurity
/**
* Based on:
* https://stackoverflow.com/questions/25383286/spring-security-custom-userdetailsservice-and-custom-user-class
*
* Password encoder:
* http://www.baeldung.com/spring-security-authentication-with-a-database
*/
class MyWebSecurityConfigurerAdapter(
val userDetailsService: MyUserDetailsService
) : WebSecurityConfigurerAdapter() {
private val passwordEncoder = BCryptPasswordEncoder()
override fun userDetailsService() : UserDetailsService {
return userDetailsService
}
override fun configure(auth: AuthenticationManagerBuilder) {
auth
.authenticationProvider(authenticationProvider())
}
@Bean
fun authenticationProvider() : AuthenticationProvider {
val authProvider = DaoAuthenticationProvider()
authProvider.setUserDetailsService(userDetailsService())
authProvider.setPasswordEncoder(passwordEncoder)
return authProvider
}
override fun configure(http: HttpSecurity?) {
http!!
.anonymous().disable()
.authenticationProvider(authenticationProvider())
.authorizeRequests()
.anyRequest().authenticated()
.and()
.httpBasic()
.and()
.csrf().disable()
}
}
MyAuthorizationServerConfigurerAdapter:
/**
* Based on:
* https://github.com/spring-projects/spring-security-oauth/blob/master/tests/annotation/jdbc/src/main/java/demo/Application.java#L68
*/
@Configuration
@EnableAuthorizationServer
class MyAuthorizationServerConfigurerAdapter(
val auth : AuthenticationManager,
val dataSource: DataSource,
val userDetailsService: UserDetailsService
) : AuthorizationServerConfigurerAdapter() {
private val passwordEncoder = BCryptPasswordEncoder()
@Bean
fun tokenStore(): JdbcTokenStore {
val tokenStore = JdbcTokenStore(dataSource)
val oauthAccessTokenTable = "auth_schema.oauth_access_token"
val oauthRefreshTokenTable = "auth_schema.oauth_refresh_token"
tokenStore.setDeleteAccessTokenFromRefreshTokenSql("delete from ${oauthAccessTokenTable} where refresh_token = ?")
tokenStore.setDeleteAccessTokenSql("delete from ${oauthAccessTokenTable} where token_id = ?")
tokenStore.setDeleteRefreshTokenSql("delete from ${oauthRefreshTokenTable} where token_id = ?")
tokenStore.setInsertAccessTokenSql("insert into ${oauthAccessTokenTable} (token_id, token, authentication_id, " +
"user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)")
tokenStore.setInsertRefreshTokenSql("insert into ${oauthRefreshTokenTable} (token_id, token, authentication) values (?, ?, ?)")
tokenStore.setSelectAccessTokenAuthenticationSql("select token_id, authentication from ${oauthAccessTokenTable} where token_id = ?")
tokenStore.setSelectAccessTokenFromAuthenticationSql("select token_id, token from ${oauthAccessTokenTable} where authentication_id = ?")
tokenStore.setSelectAccessTokenSql("select token_id, token from ${oauthAccessTokenTable} where token_id = ?")
tokenStore.setSelectAccessTokensFromClientIdSql("select token_id, token from ${oauthAccessTokenTable} where client_id = ?")
tokenStore.setSelectAccessTokensFromUserNameAndClientIdSql("select token_id, token from ${oauthAccessTokenTable} where user_name = ? and client_id = ?")
tokenStore.setSelectAccessTokensFromUserNameSql("select token_id, token from ${oauthAccessTokenTable} where user_name = ?")
tokenStore.setSelectRefreshTokenAuthenticationSql("select token_id, authentication from ${oauthRefreshTokenTable} where token_id = ?")
tokenStore.setSelectRefreshTokenSql("select token_id, token from ${oauthRefreshTokenTable} where token_id = ?")
return tokenStore
}
override fun configure(security: AuthorizationServerSecurityConfigurer?) {
security!!.passwordEncoder(passwordEncoder)
}
override fun configure(clients: ClientDetailsServiceConfigurer?) {
val clientDetailsService = JdbcClientDetailsService(dataSource)
clientDetailsService.setPasswordEncoder(passwordEncoder)
val clientDetailsTable = "auth_schema.oauth_client_details"
val CLIENT_FIELDS_FOR_UPDATE = "resource_ids, scope, " +
"authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, " +
"refresh_token_validity, additional_information, autoapprove"
val CLIENT_FIELDS = "client_secret, ${CLIENT_FIELDS_FOR_UPDATE}"
val BASE_FIND_STATEMENT = "select client_id, ${CLIENT_FIELDS} from ${clientDetailsTable}"
clientDetailsService.setFindClientDetailsSql("${BASE_FIND_STATEMENT} order by client_id")
clientDetailsService.setDeleteClientDetailsSql("delete from ${clientDetailsTable} where client_id = ?")
clientDetailsService.setInsertClientDetailsSql("insert into ${clientDetailsTable} (${CLIENT_FIELDS}," +
" client_id) values (?,?,?,?,?,?,?,?,?,?,?)")
clientDetailsService.setSelectClientDetailsSql("${BASE_FIND_STATEMENT} where client_id = ?")
clientDetailsService.setUpdateClientDetailsSql("update ${clientDetailsTable} set " +
"${CLIENT_FIELDS_FOR_UPDATE.replace(", ", "=?, ")}=? where client_id = ?")
clientDetailsService.setUpdateClientSecretSql("update ${clientDetailsTable} set client_secret = ? where client_id = ?")
clients!!.withClientDetails(clientDetailsService)
}
override fun configure(endpoints: AuthorizationServerEndpointsConfigurer?) {
endpoints!!
.authorizationCodeServices(authorizationCodeServices())
.authenticationManager(auth)
.tokenStore(tokenStore())
.approvalStoreDisabled()
.userDetailsService(userDetailsService)
}
@Bean
protected fun authorizationCodeServices() : AuthorizationCodeServices {
val codeServices = JdbcAuthorizationCodeServices(dataSource)
val oauthCodeTable = "auth_schema.oauth_code"
codeServices.setSelectAuthenticationSql("select code, authentication from ${oauthCodeTable} where code = ?")
codeServices.setInsertAuthenticationSql("insert into ${oauthCodeTable} (code, authentication) values (?, ?)")
codeServices.setDeleteAuthenticationSql("delete from ${oauthCodeTable} where code = ?")
return codeServices
}
}
MyAuthorizationServerConfigurerAdapter:
@Service
class MyUserDetailsService(
val theDataSource: DataSource
) : JdbcUserDetailsManager() {
@PostConstruct
fun init() {
dataSource = theDataSource
val usersTable = "auth_schema.users"
val authoritiesTable = "auth_schema.authorities"
setChangePasswordSql("update ${usersTable} set password = ? where username = ?")
setCreateAuthoritySql("insert into ${authoritiesTable} (username, authority) values (?,?)")
setCreateUserSql("insert into ${usersTable} (username, password, enabled) values (?,?,?)")
setDeleteUserAuthoritiesSql("delete from ${authoritiesTable} where username = ?")
setDeleteUserSql("delete from ${usersTable} where username = ?")
setUpdateUserSql("update ${usersTable} set password = ?, enabled = ? where username = ?")
setUserExistsSql("select username from ${usersTable} where username = ?")
setAuthoritiesByUsernameQuery("select username,authority from ${authoritiesTable} where username = ?")
setUsersByUsernameQuery("select username,password,enabled from ${usersTable} " + "where username = ?")
}
}
Any ideas? Could it be that I need to somehow install the OAuth2AuthenticationProcessingFilter
into my filter chain?
I do get such messages on startup… could these be related to the problem?
u.c.c.h.s.auth.MyUserDetailsService : No authentication manager set. Reauthentication of users when changing passwords will not be performed.
s.c.a.w.c.WebSecurityConfigurerAdapter$3 : No authenticationProviders and no parentAuthenticationManager defined. Returning null.
EDIT:
It looks like installing OAuth2AuthenticationProcessingFilter
is the job of a ResourceServerConfigurerAdapter
. I have added the following class:
MyResourceServerConfigurerAdapter:
@Configuration
@EnableResourceServer
class MyResourceServerConfigurerAdapter : ResourceServerConfigurerAdapter()
And I confirm in the debugger that this causes ResourceServerSecurityConfigurer
to enter its configure(http: HttpSecurity)
method, which does look like it tries to install a OAuth2AuthenticationProcessingFilter
into the filter chain.
But it doesn't look like it succeeded. According to Spring Security's debug output: I still have the same number of filters in my filter chain. OAuth2AuthenticationProcessingFilter
is not in there. What's going on?
EDIT2: I wonder if the problem is that I have two classes (WebSecurityConfigurerAdapter
, ResourceServerConfigurerAdapter
) trying to configure HttpSecurity. Is it mutually exclusive?