Our goal is to create another endpoint to retrieve sensitive data. It will be accessible only for the admin users - users having ROLE_ADMIN assigned. As it might be helpful later, let’s allow administrators to list all the active authentication tokens.
The plan is as follows: we need users store where we can register people and assign them a suitable role. Then we need to configure authorization in the Spring framework. The last thing it to implement Admin resource and annotate it in way that only admin users can access it.
#Let’s do it! #changeSet:Users store
We will create a new table in the database to store the data. Let’s add the following changeset to the database migration:
id: users store author: sebastian changes:
- createTable:
tableName: app_user columns:
- column:
name: user_email type: varchar(256) constraints:
primaryKey: true nullable: false
- column:
name: user_pass type: varchar(256)
- column:
name: user_role type: varchar(256)
view rawdb.changelog-master.yaml
We just keep email, password and the role. Keep in mind it’s just a sample application, you should never keep the credentials in plain text.
We also need a class representing this entity:
@Entity
@Table(name = "app_user") public class AppUser {
@Id @Column
private String userEmail;
@Column
private String userPass;
@Column
private String userRole;
(...)
}
view rawAppUser.java
And a DAO able to retrieve the data from database:
public interface AppUserRepository extends JpaRepository<AppUser, String> {
}
view rawAppUserRepository.java
Yeah, it’s just so easy! Thanks to built-in JPA support, you don’t need to write SQL command on your own.
In the next change-set I added one admin and one regular user. It’s just for testing. Normally you won’t register your users that way 

- changeSet:
id: insert default users author: sebastian changes:
- insert:
tableName: app_user columns:
- column:
name: user_email
value: "admin1@pm.com"
- column:
name: user_pass value: "admin123"
- column:
name: user_role value: "ROLE_ADMIN"
- insert:
tableName: app_user columns:
- column:
name: user_email value: "user1@pm.com"
- column:
name: user_pass value: "user123"
- column:
name: user_role value: "ROLE_USER"
view rawdb.changelog-master.yaml Ok, we are done with the users!
#Spring Security Configuration Not so much to do here.
First enable authorization in the security config.
@Configuration @EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter {
(...)
}
view rawSecurityConfig.java
#As you can see, the only one thing we modified is setting securedEnabled = true.
Then we need to modify our authentication provider a little bit because we need to read the users from the database.
public class DefaultAuthenticationProvider implements AuthenticationProvider { (...)
@Override
public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
if (authentication.getName() == null || authentication.getCredentials() == null) { return null;
}
if (authentication.getName().isEmpty() || authentication.getCredentials().toString().isEmpty()) { return null;
}
final Optional<AppUser> appUser = this.appUserRepository.findById(authentication.getName()); if (appUser.isPresent()) {
final AppUser user = appUser.get();
final String providedUserEmail = authentication.getName();
final Object providedUserPassword = authentication.getCredentials();
if (providedUserEmail.equalsIgnoreCase(user.getUserEmail()) && providedUserPassword.equals(user.getUserPass())) { return new UsernamePasswordAuthenticationToken( user.getUserEmail(),
user.getUserPass(),
Collections.singleton(new SimpleGrantedAuthority(user.getUserRole())));
}
}
throw new UsernameNotFoundException("Invalid username or password.");
}
(...)
}
view rawDefaultAuthenticationProvider.java
The main difference is injecting user repository here, retrieving the user from the database and adding the role.
At this point we have fully working application with the difference that the users are kept in the database instead of memory.
#Administration resource - the ugly way
It’s time to add new endpoint to the application which will allow to read all the active authentication tokens. Let’s assume the only thing we have for now is OAuth 2 database table. See its definition below.
tableName: oauth_access_token columns:
- column:
name: authentication_id type: varchar(256) constraints: primaryKey: true nullable: false
- column: name: token_id
type: varchar(256)
- column: name: token type: bytea
- column:
name: user_name type: varchar(256)
- column: name: client_id
type: varchar(256)
- column:
name: authentication
type: bytea
- column:
name: refresh_token type: varchar(256)
view rawdb.changelog-master.yaml
#We are interested in the token column which is byte data. But we can simply deserialize it to org.springframework.security.oauth2.common.DefaultOAuth2AccessToken and that way retrieve the token’s value.
@RestController @RequestMapping( value = {"/admin"},
produces = MediaType.APPLICATION_JSON_VALUE
)
@Validated
public class Admin {
private final JdbcTemplate jdbc; @Autowired
public Admin(DataSource dataSource) { this.jdbc = new JdbcTemplate(dataSource);
}
@RequestMapping(method = RequestMethod.GET, path = "/token/list") @ResponseStatus(HttpStatus.OK)
@Secured({"ROLE_ADMIN"}) public List<String> findAllTokens() {
final List<byte[]> tokens = jdbc.queryForList("select token from oauth_access_token", byte[].class);
return tokens.stream().map(this::deserializeToken).collect(Collectors.toList());
}
private String deserializeToken(byte[] tokenBytes) { try {
ByteArrayInputStream bis = new ByteArrayInputStream(tokenBytes); ObjectInput in = new ObjectInputStream(bis);
DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) in.readObject(); return token.getValue();
} catch (Exception e) {
// ignore
}
return null;
}
}
view rawAdmin.java
#So the new endpoint /token/list will return a list of the active tokens for all the users. Secure endpoints
We are almost done with the authorization. The last and the simplest thing is to annotate the endpoints with a required role. An example you can see above, in the admin panel.
@RequestMapping(method = RequestMethod.GET, path = "/token/list") @ResponseStatus(HttpStatus.OK)
@Secured({"ROLE_ADMIN"}) public List<String> findAllTokens() {
(...)
}
view rawAdmin.java
Notice @Secured({"ROLE_ADMIN"}) in the code. It means that ADMIN role is required to access this endpoint. That’s it! It was the last piece when it comes to authorization!
Step 2: Testing
As you may notice, after the changes we are not able to build the project because of the failing tests. In this section I will show you how to check it manually. Then we will fix the automated tests.
Manual testing
First, request an authentication token for the regular user.
http -a my-client:my-secret --form POST http://localhost:8080/oauth/token username='user1@pm.com' password='user123' grant_type='password'
view rawrequest-user-token.sh This should return a token.
{
"access_token": "bee01da9-1450-4ee0-b89f-6848a1027abe", "refresh_token": "3c3ccc0d-8f00-48a7-9e2d-269420dfbb19", "scope": "read write trust",
"token_type": "bearer"
}
view rawuser-token-response-1.json
Now, let’s try to access admin panel using the above.
http http://localhost:8080/admin/token/list access_token=='bee01da9-1450-4ee0-b89f-6848a1027abe' view rawaccess-admin-panel-user-token-1.sh
You should see HTTP/1.1 403 error.
{
"error": "access_denied", "error_description": "Access is denied"
}
view rawaccess-admin-panel-user-response-1
That works as expected. Regular user cannot access administration endpoint. Let’s repeat the above steps for the admin user.
Requesting token:
http -a my-client:my-secret --form POST http://localhost:8080/oauth/token username='admin1@pm.com' password='admin123' grant_type='password'
view rawrequest-admin-token.sh Token response:
{
"access_token": "20e847bb-83c0-488a-a953-5da7b13463e6", "refresh_token": "7cd148b5-659f-4c0e-8e7e-da458a835b60", "scope": "read write trust",
"token_type": "bearer"
}
view rawadmin-token-response-1.json Requesting a list of all active tokens:
http http://localhost:8080/admin/token/list access_token=='20e847bb-83c0-488a-a953-5da7b13463e6' view rawaccess-admin-panel-admin-token-1.sh
You should see the list containing all of the tokens.
[
"bee01da9-1450-4ee0-b89f-6848a1027abe", "20e847bb-83c0-488a-a953-5da7b13463e6"
]
view rawaccess-admin-panel-admin-response Great! So far, so good!
#Automated tests
The tests are failing because the mock users don’t have required roles assigned. So let’s add another user that will be able to access the resources.
@TestConfiguration public class TestConfig {
@Bean @Primary
public UserDetailsService userDetailsService() {
User basicUser1 = new org.springframework.security.core.userdetails.User( "user1@test.com",
"password",
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
User basicUser2 = new org.springframework.security.core.userdetails.User( "user2@test.com",
"password", Collections.emptyList());
return new InMemoryUserDetailsManager(basicUser1, basicUser2);
}
}
view rawTestConfig.java
And then modify the test suite. public class HelloMvcTest {
@Autowired
private MockMvc mockMvc;
@Test @WithUserDetails("user1@test.com")
public void shouldAllowUserWithUserRole() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/api/hello?name=Seb")
.accept(MediaType.ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$.greetings", is("Welcome Seb (user1@test.com)!")));
}
@Test @WithUserDetails("user2@test.com")
public void shouldRejectUserWithNoAuthorities() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/api/hello?name=Seb")
.accept(MediaType.ALL))
.andExpect(status().isForbidden());
}
@Test
public void shouldRejectIfNoAuthentication() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/api/hello?name=Seb")
.accept(MediaType.ALL))
.andExpect(status().isUnauthorized());
}
}
view rawHelloMvcTest.java
As you can see, we test that the user without any role cannot access the /api/hello endpoint but it’s available for the second user with the ROLE_USER role.
With this fixes we should be able to build the project.
Revocation - the missing part.
Sometimes you may want to invalidate the token. It might be helpful for the user log-out use case. In this section we will see how to implement it in the Spring Boot service.
Step 3: Authentication token revocation Git tag: authentication-token-revocation.
Our goal is to create another endpoint that can be used to revoke authentication token. We want to minimize the implementation and use DefaultTokenServices which is already available in the Spring framework.
The plan is as follows: we need to create and register DefaultTokenServices bean and then create a new endpoint using it to revoke the token. Additionally, we will prettify the administration panel to get rid of reading the token from the database directly.
Let’s do it!
#Registering DefaultTokenServices
We will create another configuration class that will be responsible for exposing two beans: * TokenStore - it’s already present but it we will move it to the new class to keep logically related beans together in one place * DefaultTokenServices - a new bean that can be used to manipulate a token
@Configuration
public class TokenStoreConfig {
@Autowired
private DataSource dataSource;
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
@Bean
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices(); defaultTokenServices.setTokenStore(tokenStore()); defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
}
view rawTokenStoreConfig.java
After that, we can inject the TokenStore in the AuthorizationServerConfig to avoid code duplication.
Token revocation endpoint
Now, let’s create another endpoint that will use the DefaultTokenServices to revoke the token.
@RestController @RequestMapping( value = {"/oauth"},
produces = MediaType.APPLICATION_JSON_VALUE
)
@Validated
public class Token {
@Autowired
private DefaultTokenServices tokenServices;
@RequestMapping(method = RequestMethod.DELETE, path = "/revoke") @ResponseStatus(HttpStatus.OK)
public void revokeToken(Authentication authentication) {
final String userToken = ((OAuth2AuthenticationDetails) authentication.getDetails()).getTokenValue(); tokenServices.revokeToken(userToken);
}
}
view rawToken.java
Notice that we inject Authentication object here so this method is only available for the users that already have a valid token. It makes sense if you think about logging out function. What about the refresh token? It will be invalidated automatically so the only way to access the application again is to re-authenticate.
That’s it! Let’s give it a try!
Step 4: Testing
Authenticate both admin and regular users. Admin
http -a my-client:my-secret --form POST http://localhost:8080/oauth/token username='admin1@pm.com' password='admin123' grant_type='password'
view rawrequest-admin-token.sh
{
"access_token": "79dea377-8a6e-4f0a-9de6-1c75cfe0f53f", "refresh_token": "3e18b662-fb30-41af-983a-b6d57ad9f265", "scope": "read write trust",
"token_type": "bearer"
}
view rawadmin-token-response-2.json
http -a my-client:my-secret --form POST http://localhost:8080/oauth/token username='user1@pm.com' password='user123' grant_type='password'
view rawrequest-user-token.sh
{
"access_token": "1ff62212-8e94-4e65-9349-018d14569a80", "refresh_token": "2647edf2-81cf-4fa8-9009-822be7ca44ed", "scope": "read write trust",
"token_type": "bearer"
}
view rawuser-token-response-2.json List all the tokens using admin token.
http http://localhost:8080/admin/token/list access_token=='79dea377-8a6e-4f0a-9de6-1c75cfe0f53f' view rawaccess-admin-panel-admin-token-2.sh
[
"79dea377-8a6e-4f0a-9de6-1c75cfe0f53f", "1ff62212-8e94-4e65-9349-018d14569a80"
]
view rawaccess-admin-panel-admin-response-2.json Call the test endpoint using user token.
http http://localhost:8080/api/hello name=='Seb' access_token=='1ff62212-8e94-4e65-9349-018d14569a80' view rawaccess-test-endpoint-user-token-2.sh
{
"greetings": "Welcome Seb (user1@pm.com)!"
}
view rawaccess-test-endpoint-user-response-1.sh Now, revoke the user token.
http DELETE http://localhost:8080/oauth/revoke access_token=='1ff62212-8e94-4e65-9349-018d14569a80' view rawrevoke-user-token-2.sh hosted
You should just receive OK response HTTP/1.1 200. Now, try to call the test endpoint again.
http http://localhost:8080/api/hello name=='Seb' access_token=='1ff62212-8e94-4e65-9349-018d14569a80' view rawaccess-test-endpoint-user-token-2.sh
{
"error": "invalid_token",
"error_description": "Invalid access token: 1ff62212-8e94-4e65-9349-018d14569a80"
}
view rawaccess-test-endpoint-user-response-2.sh
http http://localhost:8080/admin/token/list access_token=='79dea377-8a6e-4f0a-9de6-1c75cfe0f53f' view rawaccess-admin-panel-admin-token-2.sh
And you can see that the user token is missing.
[
"79dea377-8a6e-4f0a-9de6-1c75cfe0f53f"
]
view rawaccess-admin-panel-admin-response-3.json So finally let’s try to refresh the user token.
http -a my-client:my-secret --form POST http://localhost:8080/oauth/token grant_type='refresh_token' refresh_token='2647edf2-81cf-4fa8-9009-822be7ca44ed'
view rawrefresh-user-token-2.sh It should not be allowed.
{
"error": "invalid_grant",
"error_description": "Invalid refresh token: 2647edf2-81cf-4fa8-9009-822be7ca44ed"
}
view rawrefresh-user-response-1.sh Good job! It’s working!
Troubleshooting
Depending on the Spring Security library version, you may encounter the following error while trying to refresh the token.
{
"error": "server_error", "error_description": "Internal Server Error"
}
view rawrefresh-token-error-missing-user-details-service.json And you should see something similar in the application log.
2019-02-15 23:17:50.410 WARN 40282 --- [nio-8080-exec-3] o.s.s.o.provider.endpoint.TokenEndpoint : Handling error: IllegalStateException, UserDetailsService is required.
2019-02-15 23:17:50.421 WARN 40282 --- [nio-8080-exec-3] .m.m.a.ExceptionHandlerExceptionResolver : Resolved exception caused by handler execution: java.lang.IllegalStateException: UserDetailsService is required.
view rawrefresh-token-error-missing-user-details-service.sh
To solve it, just use UserDetailsService instead of AuthenticationProvider for the user authentication. Just implement the required method:
public class DefaultUserDetailsService implements UserDetailsService { private final AppUserRepository appUserRepository;
public DefaultUserDetailsService(AppUserRepository appUserRepository) { this.appUserRepository = appUserRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { final Optional<AppUser> userEntity = appUserRepository.findById(username);
if (userEntity.isPresent()) {
final AppUser appUser = userEntity.get();
return new User(appUser.getUserEmail(), passwordNoEncoding(appUser),
Collections.singletonList(new SimpleGrantedAuthority(appUser.getUserRole())));
}
return null;
}
private String passwordNoEncoding(AppUser appUser) {
// you can use one of bcrypt/noop/pbkdf2/scrypt/sha256
return "{noop}" + appUser.getUserPass();
}
}
view rawDefaultUserDetailsService.java And configure it in the Spring Security.
@Configuration @EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter {
(...)
@Autowired
public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(new DefaultUserDetailsService(userRepository));
}
}
view rawSecurityConfig.java This should fix the issue.
Bonus: Administration resource - the pretty way
As a small addition, you can change the administration panel to retrieve tokens using DefaultTokenServices and get rid of token deserialization.
@RestController @RequestMapping( value = {"/admin"},
produces = MediaType.APPLICATION_JSON_VALUE
)
@Validated
public class Admin {
@Autowired
private TokenStore tokenStore;
@RequestMapping(method = RequestMethod.GET, path = "/token/list") @ResponseStatus(HttpStatus.OK)
@Secured({"ROLE_ADMIN"}) public List<String> findAllTokens() {
final Collection<OAuth2AccessToken> tokensByClientId = tokenStore.findTokensByClientId(Const.CLIENT_ID);
return tokensByClientId.stream().map(token -> token.getValue()).collect(Collectors.toList());
}
}
4 Comments
Good explanation
ReplyDeletenice
ReplyDeleteEnjoyed reading the article above , really explains everything in detail, the article is very interesting and effective. Thank you and good luck in the upcoming articles
ReplyDeleteThanks for this knowledge
ReplyDelete