As you might have noticed in my previous blog posts, I am a big fan of Spring + Java and Spring + Kotlin. Consequently, whenever I need to implement an OAuth 2.0 authentication, spring-security-oauth2 lib is a natural choice.
However, there is next to nothing out there in terms of showing how to bring Spring Security and OAuth2 together — connecting spring-security-oauth2 with different data sources other than inMemory and JDBC. As we have to configure a lot of stuff, I will divide this tutorial into 3 parts: How to authenticate a user, how to configure a token store and how to configure dynamic clients. So, let’s get started!
First, I am assuming that you are using one of the latest versions of spring-security-oauth2:
1 2 3 4 5 |
<dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.3.RELEASE</version> </dependency> |
Second, I am using Couchbase with Spring Data. If you are using any other data source, you can still reuse a lot of code from this blog series.
1 2 3 4 5 |
<dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-couchbase</artifactId> <version>3.0.5.RELEASE</version> </dependency> |
Additionally, I have added Lombok as a dependency to reduce Java’s boilerplate:
1 2 3 4 5 |
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> |
Let’s configure our Resource Server, according to Spring Security documentation relating to spring-security-oauth2: “A Resource Server (can be the same as the Authorization Server or a separate application) serves resources that are protected by the OAuth2 token. Spring OAuth provides a Spring Security authentication filter that implements this protection. You can switch it on with @EnableResourceServer on an @Configuration class, and configure it (as necessary) using a ResourceServerConfigurer”
1 2 3 4 5 6 7 8 9 10 11 |
@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { private static final String RESOURCE_ID = "resource_id"; @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId(RESOURCE_ID).stateless(false); } } |
Now, let’s implement an interface called UserDetailsService. It is the interface responsible to be the bridge between your data source and Spring Security OAuth:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import com.bc.quicktask.standalone.model.CustomUserDetail; import com.bc.quicktask.standalone.model.SecurityGroup; import com.bc.quicktask.standalone.model.User; import com.bc.quicktask.standalone.repository.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Slf4j @Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Autowired private SecurityGroupService securityGroupService; @Override public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException { List<User> users = userRepository.findByUsername(name); if(users.isEmpty()) { throw new UsernameNotFoundException("Could not find the user "+name); } User user = users.get(0); List<SecurityGroup> securityGroups = securityGroupService.listUserGroups(user.getCompanyId(), user.getId()); return new CustomUserDetail(user, securityGroups.stream() .map(e->e.getId()) .collect(Collectors.toList()) ); } } |
In the code above, we are returning a class of the type UserDetails, which is also from Spring. Here is its implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
Data public class CustomUserDetail implements UserDetails { private User user; private List<String> groups; public CustomUserDetail(User user, List<String> groups) { this.user = user; this.groups = groups; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return user.getIsEnabled(); } } |
I could have just made the User class implement the UserDetails directly. However, as my use case also requires the list of groups in which the user is in, I have added the implementation above.
Here are how the User, SecurityGroup and their respective repositories look like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Data public class User extends BasicEntity implements Serializable { @Id @NotNull private String id; @Field @NotNull private String username; @Field @NotNull private String companyId; @Field @NotNull private String password; @NotNull private Boolean isEnabled; @Field private Boolean isVisible; } |
1 2 3 4 5 6 7 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "user") public interface UserRepository extends CouchbasePagingAndSortingRepository<User, String> { List<User> findByUsername(String username); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Document @Data @NoArgsConstructor @Builder public class SecurityGroup extends BasicEntity implements Serializable { @Id private String id; @NotNull @Field private String name; @Field private String description; @NotNull @Field private String companyId; @Field private List<String> users = new ArrayList<>(); @Field private boolean removed = false; } |
1 2 3 4 5 6 7 8 9 10 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "securityGroup") public interface SecurityGroupRepository extends CouchbasePagingAndSortingRepository<SecurityGroup, String> { @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and companyId = $1 and removed = false " + " AND ARRAY_CONTAINS(users, $2) ") List<SecurityGroup> listUserGroups(String companyId, String userId); } |
The BasicEntity class is also a small hack to better work with Spring Data and Couchbase:
1 2 3 4 5 6 7 |
public class BasicEntity { @Getter(PROTECTED) @Setter(PROTECTED) @Ignore protected String _class; } |
Finally, here is the implementation of our SecurityConfig class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
@Configuration @EnableWebMvc public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService customUserDetailsService; @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } @Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers("/oauth/token").permitAll() .antMatchers("/api-docs/**").permitAll() .anyRequest().authenticated() .and().anonymous().disable(); } @Bean public TokenStore tokenStore() { return new InMemoryTokenStore(); } @Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } @Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } } |
We can’t inject directly the AuthenticationManager bean anymore in Spring-Boot 2.0, but it still is required by the Spring Security framework. Therefore, we need to implement a small hack in order to gain access to this object:
1 2 3 4 5 |
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } |
Let’s break this class into small pieces to understand what is going on:
1 2 3 4 |
@Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } |
My user’s password is in plain text, so I just return a new instance of NoOpPasswordEncoder. A common standard is to return an instance of the BCryptPasswordEncoder class.
1 2 3 4 |
@Bean public TokenStore tokenStore() { return new InMemoryTokenStore(); } |
For now, we are going to use an in-memory token store, we will see in part 2 how to also use Couchbase as a token store.
1 2 3 4 5 |
@Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } |
Here is where the magic happens with OAuth2 and Spring Boot. Specifically, we are telling Spring to use our CustomUserDetailsService to search for users. This block of code is the core part of what we have done so far.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } |
This block will allow us to make requests using CORS (Cross-Origin Resource Sharing)
1 2 3 4 |
@Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } |
And finally, if you need to call your API via JQuery, you also need to add the code above. Otherwise, you will get a “Response for preflight does not have HTTP ok status.” Error.
There is just one thing left now, we need to add an Authorization Server:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.TokenStore; @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { static final String CLIENT_ID = "android-client"; static final String CLIENT_SECRET = "android-secret"; static final String GRANT_TYPE_PASSWORD = "password"; static final String AUTHORIZATION_CODE = "authorization_code"; static final String REFRESH_TOKEN = "refresh_token"; static final String IMPLICIT = "implicit"; static final String SCOPE_READ = "read"; static final String SCOPE_WRITE = "write"; static final String TRUST = "trust"; static final int ACCESS_TOKEN_VALIDITY_SECONDS = 1*60*60; static final int REFRESH_TOKEN_VALIDITY_SECONDS = 6*60*60; @Autowired private TokenStore tokenStore; @Autowired private AuthenticationManager authenticationManager; @Override public void configure(ClientDetailsServiceConfigurer configurer) throws Exception { configurer .inMemory() .withClient(CLIENT_ID) .secret(CLIENT_SECRET) .authorizedGrantTypes(GRANT_TYPE_PASSWORD, AUTHORIZATION_CODE, REFRESH_TOKEN, IMPLICIT ) .scopes(SCOPE_READ, SCOPE_WRITE, TRUST) .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY_SECONDS). refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY_SECONDS); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(tokenStore) .authenticationManager(authenticationManager); } @Override public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } } |
Well done!, now you can start your app and call it via Postman or Jquery:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
var data = { "grant_type": "password", "username": "myuser", "password":"mypassword", "client_id":"android-client", "client_secret":"android-secret" } $.ajax({ 'url': "http://localhost:8080/oauth/token", 'type': 'POST', "crossDomain": true, "headers": { 'Authorization': 'Basic YW5kcm9pZC1jbGllbnQ6YW5kcm9pZC1zZWNyZXQ=', //android-client:android-secret in Base64 'Content-Type':'application/x-www-form-urlencoded'}, "data":data, 'success': function (result) { console.log( "My Access token = "+ result.access_token); console.log( "My refresh token = "+ result.refresh_token); console.log("expires in = "+result.expires_in) succesCallback() }, 'error': function (XMLHttpRequest, textStatus, errorThrown) { errorCallback(XMLHttpRequest, textStatus, errorThrown) } }); |
Boosting performance
If you are using Couchbase, I suggest you use the username as the key of your document. It will allow you to use the Key-Value Store instead of executing N1QL queries, which will significantly increase the performance of your login.
If you have any questions about Couchbase, OAuth security, or optimizing Spring and OAuth2 authentication, just tweet me at @deniswsrosa
Very nice article. Could you please share the code for this post?