-
Notifications
You must be signed in to change notification settings - Fork 0
Oauth2 In Practice With Spring Security
- Oauth2 In Practice With Spring Security
- 1 Introduction
- 2 Let's create a User with roles and privileges
- 3 Extend A User with Spring Security methods and attributes
- 4 Configure your application to use security expressions
- 5 Configure Oauth2 Authorization Service
- 6 Customize WebSecurityConfigurerAdapter
Given my experience in the pit, Oauth2 is one of those topics where people have no clue about what to do and is kind of surrounded by an allure of mystery. I won’t enter too much into the technical details because the web is full of places where you can learn the theory, however I will try also to cover some fundamentals to help those that are new to this topic. The focus of this guide is on a particular case: an enterprise application with “password grant” which needs to authenticate a user via his credentials and return a Javascript Web Token.
Oauth2 is an industry standard protocol for authorisation, which basically means that it checks if you have access or not to a resource in the first place. Oauth2 per se does not do any authentication checks, id est it does not verify the identity of a user. What problems does Oauth2 solve? Oauth2 main purpose is to allow third party applications to login through your app. However with the raise of Micro-service Architecture and the lack of definitive standard set in the stone for example with regard to JSON responses, programmers find it useful to have some points of reference set in the stone. In fact, by using the “password grant” type of authorisation the user will have to verify his identity through username and password. In exchange, the server, if the data provided are correct, will return a Javascript Web Token that is compliant, obviously, with the Oauth2 standards. This means that it’s something set in the stone. You can add some custom information to it anyways if you like. Also, the method used to return the JWT the first time, and the way how to send the token bearer are already set, allowing to take off your shoulders a few architectural responsibilities. In this guide I will use the Spring Security implementation because it’s something tested, publicly available and sound. There’s another approach, anyways, which is using AspectJ and create a JWT filter, but it has the disadvantage that you have to create Unit Tests for it, and if more experienced developers come on board on your project, chances are that they have some degree of experience with Spring Security already. Link to my project which uses Aspect J
In order to create a user and assign him a few roles we need to create a @ManyToMany relationship with a @JoinTable
@Data
@Entity(name = "User")
@Table(name="users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
@Column(unique = true)
private String username;
private String password;
@OneToOne
private UserInfo userInfo;
@ManyToMany(fetch = FetchType.EAGER, cascade = {CascadeType.MERGE})
@JoinTable(
name = "users_roles",
joinColumns = @JoinColumn(
name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(
name = "role_id", referencedColumnName = "id"))
private Collection<Role> roles = new HashSet<>();
}
When you configure the roles and the privileges, given some technical issues with the Hibernate and Spring JPA versions in use, you cannot initialize with eager loading two @ManyToMany collections at the same time. However, using fetch = fetchType.LAZY
will not work as expected, because it looks like that this way there is no open session when Oauth2 middleware tries to fetch the data it needs (roles and privileges which are related to our user).
The solution is to use @LazyCollection(LazyCollectionOption.FALSE)
which is an annotation that strictly belongs to Hibernate and not JPA, and works without any flaws.
As in a plain old Spring Security Scenario we need to create also roles and privileges.
@Data
@Entity(name = "Role")
@Table(name="roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(unique = true)
private String name;
@ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY, cascade = {CascadeType.MERGE})
private Collection<User> users;
@ManyToMany
@LazyCollection(LazyCollectionOption.FALSE)
@JoinTable(
name = "roles_privileges",
joinColumns = @JoinColumn(
name = "role_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(
name = "privilege_id", referencedColumnName = "id"))
private Collection<Privilege> privileges = new HashSet<>();
}
@Data
@Entity(name="Privilege")
@Table(name="privileges")
public class Privilege {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(unique = true)
private String name;
@ManyToMany(mappedBy = "privileges", cascade = {CascadeType.MERGE})
@LazyCollection(LazyCollectionOption.FALSE)
private Collection<Role> roles = new HashSet<>();
}
Before you jump off your chair, a principal is a “corporation, a program thread, an individual, anything that can have an identity”. User principal implements a Spring Security Interface that is UserDetails. We basically prefer using the composition pattern rather than using inheritance. In fact, if we decided to use inheritance we would have had issues with the constructors (having to import all the required parameters so that the signatures of the methods do match) and it has been anyways been deprecated as an approach in more recent versions. I tried that approach as well and it was a no go. Therefore, we prefer to inject user into CustomUserPrincipal and on class instantiation we assign our user to the user attribute of our object. To learn more info about UserDetails, if you have IntelliJ you can just Command(Ctrl)+Click it and read all the details. The basic Idea is that when we implement an interface, the interface is like a contract and we need to honor it, which means we need to provide an implementation for its methods.
public class CustomUserPrincipal implements UserDetails {
private static final long serialVersionUID = 1L;
private final User user;
public CustomUserPrincipal(User user) {
this.user = user;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
private List<String> getPrivileges(Collection<Role> roles) {
List<String> privileges = new ArrayList<>();
List<Privilege> collection = new ArrayList<>();
for (Role role : roles) {
collection.addAll(role.getPrivileges());
}
for (Privilege item : collection) {
privileges.add(item.getName());
}
return privileges;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
final List<GrantedAuthority> authorities = new ArrayList<>();
final Collection<Role> roles = user.getRoles();
for (String privilege : getPrivileges(roles)) {
authorities.add(new SimpleGrantedAuthority(privilege));
}
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public User getUser() {
return user;
}
}
Let's have a closer look at this code chunk
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
final List<GrantedAuthority> authorities = new ArrayList<>();
final Collection<Role> roles = user.getRoles();
for (String privilege : getPrivileges(roles)) {
authorities.add(new SimpleGrantedAuthority(privilege));
}
return authorities;
}
What this code does is to fill the list of authorities, which are those roles of our user that will be written in the token, with the roles coming from our user object.
In order for our user to be passed to our User Principal, we need to inject it somehow using Spring Boot IOC.
In order for this to happen we need to create a @Service
which is a stereotype, meaning an alias, for @Component
.
This annotation allows Component Scan to find our component, create an instance of our service and make it available to the rest of our app.
@Service public class CustomUserDetailsService implements UserDetailsService {
private UserRepository userRepository;
CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(final String username) {
User user = userRepository.findUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new CustomUserPrincipal(user);
}
}
In order to give access to a resource (endpoint for example) we need to implement some methods which will be later on used by the @Preauthorize()
annotation.
You can find all the info at this url url
Let's say you want to give access only to users with admin role, you will add @Preauthorize(hasRole(ROLE_ADMIN))
on top of your Controller method for that specific endpoint. Just out of curiosity, also @Controller is a stereotype for @Component
.
To achieve this we must the MethodSecurityExpressionOperations interface of Spring Security.
public class CustomSecurityExpressionRoot implements MethodSecurityExpressionOperations {
protected final Authentication authentication;
private AuthenticationTrustResolver trustResolver;
private RoleHierarchy roleHierarchy;
private Set<String> roles;
private String defaultRolePrefix = "ROLE_";
public final boolean permitAll = true;
public final boolean denyAll = false;
private PermissionEvaluator permissionEvaluator;
public final String read = "read";
public final String write = "write";
public final String create = "create";
public final String delete = "delete";
public final String admin = "administration";
//
private Object filterObject;
private Object returnObject;
public CustomSecurityExpressionRoot(Authentication authentication) {
if (authentication == null) {
throw new IllegalArgumentException("Authentication object cannot be null");
}
this.authentication = authentication;
}
@Override
public final boolean hasAuthority(String authority) {
throw new RuntimeException("method hasAuthority() not allowed");
}
@Override
public final boolean hasAnyAuthority(String... authorities) {
return hasAnyAuthorityName(null, authorities);
}
@Override
public final boolean hasRole(String role) {
return hasAnyRole(role);
}
@Override
public final boolean hasAnyRole(String... roles) {
return hasAnyAuthorityName(defaultRolePrefix, roles);
}
private boolean hasAnyAuthorityName(String prefix, String... roles) {
final Set<String> roleSet = getAuthoritySet();
for (final String role : roles) {
final String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}
return false;
}
@Override
public final Authentication getAuthentication() {
return authentication;
}
@Override
public final boolean permitAll() {
return true;
}
@Override
public final boolean denyAll() {
return false;
}
@Override
public final boolean isAnonymous() {
return trustResolver.isAnonymous(authentication);
}
@Override
public final boolean isAuthenticated() {
return !isAnonymous();
}
@Override
public final boolean isRememberMe() {
return trustResolver.isRememberMe(authentication);
}
@Override
public final boolean isFullyAuthenticated() {
return !trustResolver.isAnonymous(authentication) && !trustResolver.isRememberMe(authentication);
}
public Object getPrincipal() {
return authentication.getPrincipal();
}
public void setTrustResolver(AuthenticationTrustResolver trustResolver) {
this.trustResolver = trustResolver;
}
public void setRoleHierarchy(RoleHierarchy roleHierarchy) {
this.roleHierarchy = roleHierarchy;
}
public void setDefaultRolePrefix(String defaultRolePrefix) {
this.defaultRolePrefix = defaultRolePrefix;
}
private Set<String> getAuthoritySet() {
if (roles == null) {
roles = new HashSet<String>();
Collection<? extends GrantedAuthority> userAuthorities = authentication.getAuthorities();
if (roleHierarchy != null) {
userAuthorities = roleHierarchy.getReachableGrantedAuthorities(userAuthorities);
}
roles = AuthorityUtils.authorityListToSet(userAuthorities);
}
return roles;
}
@Override
public boolean hasPermission(Object target, Object permission) {
return permissionEvaluator.hasPermission(authentication, target, permission);
}
@Override
public boolean hasPermission(Object targetId, String targetType, Object permission) {
return permissionEvaluator.hasPermission(authentication, (Serializable) targetId, targetType, permission);
}
public void setPermissionEvaluator(PermissionEvaluator permissionEvaluator) {
this.permissionEvaluator = permissionEvaluator;
}
private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) {
if (role == null) {
return role;
}
if ((defaultRolePrefix == null) || (defaultRolePrefix.length() == 0)) {
return role;
}
if (role.startsWith(defaultRolePrefix)) {
return role;
}
return defaultRolePrefix + role;
}
@Override
public Object getFilterObject() {
return this.filterObject;
}
@Override
public Object getReturnObject() {
return this.returnObject;
}
@Override
public Object getThis() {
return this;
}
@Override
public void setFilterObject(Object obj) {
this.filterObject = obj;
}
@Override
public void setReturnObject(Object obj) {
this.returnObject = obj;
}
}
We can control the access on a parameter in one of our controller methods like this:
@PreAuthorize("hasPermission(#contact, 'admin')")
public void deletePermission(Contact contact, Sid recipient, Permission permission);
So before to eventually delete contact we see if the user has the admin role.
To achieve this you need to enable
In order to obtain this you need to implement one more interface named PermissionEvaluator and provide your implementations of hasPermission
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
if ((auth == null) || (targetDomainObject == null) || !(permission instanceof String)) {
return false;
}
final String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();
return hasPrivilege(auth, targetType, permission.toString().toUpperCase());
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
if ((auth == null) || (targetType == null) || !(permission instanceof String)) {
return false;
}
return hasPrivilege(auth, targetType.toUpperCase(), permission.toString().toUpperCase());
}
private boolean hasPrivilege(Authentication auth, String targetType, String permission) {
for (final GrantedAuthority grantedAuth : auth.getAuthorities()) {
System.out.println("here " + grantedAuth);
if (grantedAuth.getAuthority().startsWith(targetType)) {
if (grantedAuth.getAuthority().contains(permission)) {
return true;
}
}
}
return false;
}
}
In order to obtain more control on the access to our resources we can use privileges instead of roles.
For example, a user who has not completed the registration process yet might not be given the read permission on a certain resource from start, with the purpose to push him to take action. To do this we can use the PreAuthorize annotation like this: @PreAuthorize("hasPrivilege('read')")
.
To enable this nice piece of syntactic sugar, you need obviously, as you can see in the code chunk above, to provide an implementation of hasPrivilege
, which extends PermissionEvaluator, which by default has just hasPermission
in its contract.
In order to activate filtering in Security Expressions, this must be enabled. In order to do so some more configuration is needed.
public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
@Override
protected MethodSecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, MethodInvocation invocation) {
// final CustomMethodSecurityExpressionRoot root = new CustomMethodSecurityExpressionRoot(authentication);
final CustomSecurityExpressionRoot root = new CustomSecurityExpressionRoot(authentication);
root.setPermissionEvaluator(getPermissionEvaluator());
root.setTrustResolver(this.trustResolver);
root.setRoleHierarchy(getRoleHierarchy());
return root;
}
}
Also @Preauthorize, @PostAuthorize annotations need to be activated
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
// final DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
final CustomMethodSecurityExpressionHandler expressionHandler = new CustomMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
return expressionHandler;
}
}
We can add some custom fields to our tokens.
public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
final Map<String, Object> additionalInfo = new HashMap<>();
additionalInfo.put("organization", authentication.getName() + randomAlphabetic(4));
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
}
In order to configure different aspects of Oauth2 we need to extend AuthorizationServerConfigurerAdapter.
By doing so we can also register within Spring IOC other providers (beans).
@Configuration
is an annotation that uses CGLIB under the hood. This allows to use singleton instances of these providers giving us
the possibility to use them inside the same class. When you don't have this necessity, you can use @Component
.
@Configuration
@EnableAuthorizationServer
@DependsOn("authenticationManagerBean")
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
@Qualifier("authenticationManagerBean")
private AuthenticationManager authenticationManager;
@Autowired
@Qualifier("encoder")
private PasswordEncoder passwordEncoder;
@Autowired
private CustomUserDetailsService userDetailsService;
/*
tokenKeyAccess, checkTokenAccess take as a parameter one
of the security expressions defined in CustomSecurityExpressionRoot
*/
@Override
public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
}
@Override
public void configure(final ClientDetailsServiceConfigurer clients) throws Exception {
// A few examples with implementations of different grant types
clients.inMemory().withClient("sampleClientId").authorizedGrantTypes("implicit").scopes("read", "write", "foo", "bar").autoApprove(false).accessTokenValiditySeconds(3600).redirectUris("http://localhost:8083/")
.and().withClient("fooClientIdPassword").secret(passwordEncoder.encode("secret")).authorizedGrantTypes("password", "authorization_code", "refresh_token").scopes("foo", "read", "write").accessTokenValiditySeconds(3600)
// 1 hour
.refreshTokenValiditySeconds(2592000)
// 30 days
.redirectUris("xxx","http://localhost:8089/")
.and().withClient("barClientIdPassword").secret(passwordEncoder.encode("secret")).authorizedGrantTypes("password", "authorization_code", "refresh_token").scopes("bar", "read", "write").accessTokenValiditySeconds(3600)
// 1 hour
.refreshTokenValiditySeconds(2592000) // 30 days
.and().withClient("testImplicitClientId").authorizedGrantTypes("implicit").scopes("read", "write", "foo", "bar").autoApprove(true).redirectUris("xxx");
}
@Bean
@Primary
public DefaultTokenServices tokenServices() {
final DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
@Override
public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
final TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer(), accessTokenConverter()));
endpoints
.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
final JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
// final KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("mytest.jks"), "mypass".toCharArray());
// converter.setKeyPair(keyStoreKeyFactory.getKeyPair("mytest"));
return converter;
}
@Bean
public TokenEnhancer tokenEnhancer() {
return new CustomTokenEnhancer();
}
}
@Configuration
@Order(SecurityProperties.DEFAULT_FILTER_ORDER-1)
//@ComponentScan("com.example.springdemo.security")
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void configure(final AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**");
}
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login/**").permitAll()
.antMatchers("/api/**").permitAll()
.antMatchers("/admin").hasRole("ADMIN")
.and().formLogin()
.and().csrf().and().httpBasic().disable();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
final DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(encoder());
return authProvider;
}
@Bean
public PasswordEncoder encoder() {
return new BCryptPasswordEncoder(11);
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
In this file we want to configure Spring Security so that WebSecurityConfigurerAdapter does not interfere with AuthorizationServerConfigurerAdapter. Here we have added also a PasswordEncoder which is used when a new user wants to create a new account. Here you can find the link to this project.
Finally here is an image that shows you how to use postman to obtain a token and test if everything is working. I will update this repository as I find out new things.