How to Configure an OAuth2 Authentication With Spring Security (Part 1)
Want to learn how to configure OAuth 2 authentication with Spring Security? Check out this post to learn more about OAuth 2 authentication with different data sources.
Join the DZone community and get the full member experience.
Join For FreeAs 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, the spring-security-oauth2 lib is a natural choice.
However, there are next to nothing articles out there showing how to connect 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 three 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:
<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.
<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:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
We can’t inject directly the AuthenticationManager
bean anymore in Spring-Boot 2.0, but it still is required by Spring Security. Therefore, we need to implement a small hack in order to gain access to this object:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class AuthenticationMananagerProvider extends WebSecurityConfigurerAdapter {
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class AuthenticationMananagerProvider extends WebSecurityConfigurerAdapter {
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
Let’s configure our ResourceServer
. According to spring-security-oauth2 docs: “A ResourceServer, which 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.”
@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:
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:
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 is what the User
, SecurityGroup
, and their respective repositories look like:
@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;
}
@N1qlPrimaryIndexed
@ViewIndexed(designDoc = "user")
public interface UserRepository extends CouchbasePagingAndSortingRepository<User, String> {
List<User> findByUsername(String username);
}
@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;
}
@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:
public class BasicEntity {
@Getter(PROTECTED)
@Setter(PROTECTED)
@Ignore
protected String _class;
}
Finally, here is the implementation of our SecurityConfig
class:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@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();
}
@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;
}
}
Let’s break this class into small pieces to understand what is going on:
@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.
@Bean
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}
For now, we are going to use an in-memory token store. We will see this in part two, as well as how to use Couchbase as a token store.
@Autowired
public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService)
.passwordEncoder(encoder());
}
Here is where the magic happens, as 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.
@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):
@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 AuthorizationServer
:
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);
}
}
Well done! Now, you can start your app and call it via Postman or Jquery:
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 increase significantly the performance of your login.
Stay tuned!
Published at DZone with permission of Denis W S Rosa, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments