This post, I will show you how to use OAuth 2 with Spring Security. There are only few changes between JWT token or not, so, I show them together.

Why use OAuth

There are multiple scenarios to make us to find a way to integrate with other services.

For example,

Some of companies provides data storing services, you find a useful usage and they did not provided. So, you want to create app for users. But, there is a problem, the data is stored in services of those companies, you need get the permissions to access them.

The another scene is, you are working for a big company. Your company has multiple services, but don’t want user login again and again when they using different services. You should make sure user only need login once and can using all services with their permissions.

To make these scenarios to true, you can use OAuth.

If you need fetch resources from 3rd party, you need use it as authentication and authorization service.

If you just need use it to make single point login, you just need use it as authentication service, and ignore authorities which are got from authentication service and it should be handled by yourself.

What provided by OAuth

OAuth provides a safety, open and simple way to authenticate and authorize 3rd party clients to access user’s resources without account and password of user. You can use it get permissions to access user’s resources on 3rd party services, or provide unique authentication and authorization for your all services.

There are 4 authentication modes

  • Authorization code
  • Implicit
  • Resource owner password credentials
  • client credentials

In this post, we only talk how to use ‘Authorization code’ mode. It is the strictest mode in these 4 modes.

Work flow of Authorization Code Mode

With above chart, when user send request to client and still not sign in, the client will redirect to auth server which is provided by authorization provider to authenticate.

After user signed in auth server and permitted the permissions which are requested, auth server will send the auth code to client. This code only can be used once to get the OAuth token from auth server. When get the token, client may request user’s principal from auth server or not.

Latest, client will request resources which are user wanted with OAuth token from resources server, and send response to user.

You can see, as client, it never touch the user account and password. Sign in process is done on auth server and client only get the token. And when client request resources from resources server, the token also need be validated by auth server. It will make user account will not be known and embezzled by others.

Implementation

Now, let’s start to implement them.

Auth Service

First, we need provide an auth server with Spring Security and OAuth 2. There are 3 packages which we need use

  • spring-boot-starter-web
  • spring-boot-starter-security
  • spring-cloud-starter-oauth2

build.gradle

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
plugins {
id 'org.springframework.boot' version '2.2.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}

group = 'com.simplejourney'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
mavenCentral()
}

ext {
set('springCloudVersion', "Hoxton.SR4")
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-oauth2'

compileOnly 'org.projectlombok:lombok:1.18.12'
annotationProcessor 'org.projectlombok:lombok:1.18.12'

testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
}

dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}

test {
useJUnitPlatform()
}

After project is created, we need add config code which inherited from WebSecurityConfigurerAdapter which looks like normal Spring Security configurations. It is used to manage login actions with username and password.

WebSecurityConfig.java

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
61
62
63
64
65
66
67
68
69
70
71
72
package com.simplejourney.securityoauth2auth.config;

import com.simplejourney.securityoauth2auth.services.impl.DemoUserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private DemoUserDetailsServiceImpl userDetailsService;

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Autowired
public void configureGlobal(AuthenticationManagerBuilder builder) throws Exception {
builder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.requestMatchers()
.antMatchers("/login", "/oauth/authorize")
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().permitAll()
.and().sessionManagement().maximumSessions(1)
.expiredSessionStrategy(new SessionInformationExpiredStrategy() {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException {
Map<String, Object> results = new HashMap<String, Object>() {{
put("code", HttpServletResponse.SC_UNAUTHORIZED);
put("message", "Session Expired");
}};

HttpServletResponse response = sessionInformationExpiredEvent.getResponse();
response.setContentType("json/application;chartset=utf-8");
response.getWriter().write("session expired");
}
});
}
}

Then, we need add configurations for OAuth 2 which is inherited from AuthorizationServerConfigurerAdaper.

AuthServerConfig.java

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
package com.simplejourney.securityoauth2auth.config;

import com.simplejourney.securityoauth2auth.services.impl.DemoClientDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import java.security.KeyPair;
import java.util.Arrays;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Value("${use_jwt_token}")
private boolean useJwtToken;

@Autowired
private KeyPair keyPair;

@Autowired
private DemoClientDetailsServiceImpl clientDetailsService;

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private TokenStore tokenStore;

@Bean
public TokenStore tokenStore() {
if (this.useJwtToken) {
return new JwtTokenStore(jwtAccessTokenConverter());
} else {
return new InMemoryTokenStore();
}
}

@Autowired
private TokenEnhancer tokenEnhancer;

@Bean
public TokenEnhancer tokenEnhancer() {
return new DemoTokenEnhancer();
}

@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(this.keyPair);

/**
* there is a chance to modify the access token when convert it
*/
DefaultAccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter();
accessTokenConverter.setUserTokenConverter(new SubjectAttributeUserTokenConverter());
converter.setAccessTokenConverter(accessTokenConverter);

return converter;
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(authenticationManager)
.tokenStore(tokenStore);

if (this.useJwtToken) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(
Arrays.asList(tokenEnhancer(),
jwtAccessTokenConverter() // original token will be converted to jwt token
));

endpoints
.accessTokenConverter(jwtAccessTokenConverter) // used to decode jwt token when received it
.tokenEnhancer(tokenEnhancerChain); // used to generate jwt token
} else {
endpoints.tokenEnhancer(tokenEnhancer);
}
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService);
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients();
}
}

To use JWT, we need provide key pair for JWT

KeyConfig.java

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
package com.simplejourney.securityoauth2auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.spec.RSAPrivateKeySpec;
import java.security.spec.RSAPublicKeySpec;

@Configuration
public class KeyConfig {
@Bean
KeyPair keyPair() {
try {
String privateExponent = "3851612021791312596791631935569878540203393691253311342052463788814433805390794604753109719790052408607029530149004451377846406736413270923596916756321977922303381344613407820854322190592787335193581632323728135479679928871596911841005827348430783250026013354350760878678723915119966019947072651782000702927096735228356171563532131162414366310012554312756036441054404004920678199077822575051043273088621405687950081861819700809912238863867947415641838115425624808671834312114785499017269379478439158796130804789241476050832773822038351367878951389438751088021113551495469440016698505614123035099067172660197922333993";
String modulus = "18044398961479537755088511127417480155072543594514852056908450877656126120801808993616738273349107491806340290040410660515399239279742407357192875363433659810851147557504389760192273458065587503508596714389889971758652047927503525007076910925306186421971180013159326306810174367375596043267660331677530921991343349336096643043840224352451615452251387611820750171352353189973315443889352557807329336576421211370350554195530374360110583327093711721857129170040527236951522127488980970085401773781530555922385755722534685479501240842392531455355164896023070459024737908929308707435474197069199421373363801477026083786683";
String exponent = "65537";

RSAPublicKeySpec publicSpec = new RSAPublicKeySpec(new BigInteger(modulus), new BigInteger(exponent));
RSAPrivateKeySpec privateSpec = new RSAPrivateKeySpec(new BigInteger(modulus), new BigInteger(privateExponent));
KeyFactory factory = KeyFactory.getInstance("RSA");
return new KeyPair(factory.generatePublic(publicSpec), factory.generatePrivate(privateSpec));
} catch ( Exception e ) {
throw new IllegalArgumentException(e);
}
}
}

Following 2 just show some opportunities to customize token. And also, you can remove them and related configurations from AuthServerConfig.

DemoTokenEnhancer.java

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
package com.simplejourney.securityoauth2auth.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;

@Component
public class DemoTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
System.out.println(String.format("Token: %s", oAuth2AccessToken));
System.out.println(String.format("Authentication: %s", oAuth2Authentication));

/**
* If you want to add more additional information to token, you can do it here like following example
*/
// User user = (User) oAuth2Authentication.getPrincipal();
// final Map<String, Object> additionalInfo = new HashMap<>();
// additionalInfo.put("user_name", user.getUsername());
// ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(additionalInfo);

return oAuth2AccessToken;
}
}

SubjectAttributeUserTokenConverter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.simplejourney.securityoauth2auth.config;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.provider.token.DefaultUserAuthenticationConverter;

import java.util.Map;

public class SubjectAttributeUserTokenConverter extends DefaultUserAuthenticationConverter {
@Override
public Map<String, ?> convertUserAuthentication(Authentication authentication) {
return super.convertUserAuthentication(authentication);

/**
* You can do some customization here as following example code
*/
// Map<String, Object> response = new LinkedHashMap<>();
// response.put("sub", authentication.getName());
// if (authentication.getAuthorities() != null && !authentication.getAuthorities().isEmpty()) {
// response.put(AUTHORITIES, AuthorityUtils.authorityListToSet(authentication.getAuthorities()));
// }
// return response;
}
}

We also need to provide UserDetailsService to authenticate and authorize user when they login. The related code have been shown in Spring Security: Authentication and authorization for separated backend,

More than it, we need provide ClientDetailsService to authenticate and authorize client when we use OAuth.

DemoClientDetails.java

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package com.simplejourney.securityoauth2auth.entities;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.provider.ClientDetails;

import java.util.Collection;
import java.util.Map;
import java.util.Set;

public class DemoClientDetails implements ClientDetails {
private String clientId;
private Set<String> resId;
private String clientSecret;
private Set<String> scope;
private Set<String> grantTypes;
private Set<String> redirectUri;
private Collection<GrantedAuthority> authorities;
private int accessTokenValiditySeconds;
private int refreshTokenValiditySeconds;
private Map<String, Object> additionalInfo;

public DemoClientDetails(String clientId,
Set<String> resId,
String clientSecret,
Set<String> scope,
Set<String> grantTypes,
Set<String> uri,
Collection<GrantedAuthority> authorities,
int accessTokenValiditySeconds,
int refreshTokenValiditySeconds,
Map<String, Object> additionalInfo) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.resId = resId;
this.scope = scope;
this.grantTypes = grantTypes;
this.redirectUri = uri;
this.authorities = authorities;
this.accessTokenValiditySeconds = accessTokenValiditySeconds;
this.refreshTokenValiditySeconds = refreshTokenValiditySeconds;
this.additionalInfo = additionalInfo;
}

@Override
public String getClientId() {
return clientId;
}

@Override
public Set<String> getResourceIds() {
return resId;
}

@Override
public boolean isSecretRequired() {
return false;
}

@Override
public String getClientSecret() {
return clientSecret;
}

@Override
public boolean isScoped() {
return false;
}

@Override
public Set<String> getScope() {
return scope;
}

@Override
public Set<String> getAuthorizedGrantTypes() {
return grantTypes;
}

@Override
public Set<String> getRegisteredRedirectUri() {
return redirectUri;
}

@Override
public Collection<GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public Integer getAccessTokenValiditySeconds() {
return accessTokenValiditySeconds;
}

@Override
public Integer getRefreshTokenValiditySeconds() {
return refreshTokenValiditySeconds;
}

@Override
public boolean isAutoApprove(String s) {
return false;
}

@Override
public Map<String, Object> getAdditionalInformation() {
return additionalInfo;
}
}

DemoClientDetailsServiceImpl.java

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
package com.simplejourney.securityoauth2auth.services.impl;

import com.simplejourney.securityoauth2auth.entities.DemoClientDetails;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.ClientRegistrationException;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Service
public class DemoClientDetailsServiceImpl implements ClientDetailsService {
private Map<String, DemoClientDetails> clientDetailsMap = new HashMap<String, DemoClientDetails>() {{
put("client",
new DemoClientDetails(
"client",
null,
new BCryptPasswordEncoder().encode("secret"),
new HashSet<String>() {{ add("APP"); }},
new HashSet<String>() {{
add("authorization_code");
add("refresh_token"); // MUST add this if you need refresh token
}},
new HashSet<String>() {{ add("http://oauthcli:8080/login/oauth2/code/oauthsvr"); }},
new HashSet<GrantedAuthority>() {{ add( new SimpleGrantedAuthority("READ")); }}, // MUST NOT be null
(int) TimeUnit.SECONDS.toSeconds(30),
(int) TimeUnit.DAYS.toSeconds(15),
null));
}};

@Override
public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
return clientDetailsMap.get(clientId);
}
}

If you use web browser to test the client, you need implement a controller to response the ‘user-info-uri’ endpoint request and provide user’s principal.

But if you use Postman, it is not needed.

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
package com.simplejourney.securityoauth2auth.controllers;

import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletRequest;
import java.security.Principal;

@Controller
@RequestMapping("/user")
public class UserController {
@Value("${use_jwt_token}")
private boolean useJwtToken;

@Autowired
private TokenStore tokenStore;

@PostMapping("/info")
public ResponseEntity<Principal> info(String access_token) {
OAuth2Authentication authentication = tokenStore.readAuthentication(access_token);
if (null == authentication) {
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
}

String username;
if (this.useJwtToken) {
username = authentication.getUserAuthentication().getPrincipal().toString();
} else {
User user = (User) authentication.getUserAuthentication().getPrincipal();
username = user.getUsername();
}

return ResponseEntity.ok(new UserPrincipal(username));
}

@Data
@AllArgsConstructor
public class UserPrincipal implements Principal {
private String name;

@Override
public String getName() {
return name;
}
}
}

At last, we need add some settings in ‘application.yml’

application.yml

1
2
3
4
5
server:
address: oauthsvr
port: 8081

use_jwt_token: true

If you don’t want to use JWT token, you can change value of ‘use_jwt_token’ to ‘false’.

Resources Service

Now, let’s create Resources Service to provide data with Opaque token.

In this service, we only provided 1 api and return an empty list of books.

To use features of OAuth 2 Resources Server, we need add following 3 dependenes

  • spring-boot-starter-web
  • spring-boot-starter-security
  • spring-boot-starter-oauth2-resource-server

NOTICE

There is an additional dependence MUST be added, otherwise, the NimbusOpaqueTokenIntrospector will not be found by class loader.

  • oauth2-oidc-sdk

Please refer to Dependency issues in resource server #8391

build.gradle

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
plugins {
id 'org.springframework.boot' version '2.2.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}

group = 'com.simplejourney'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
mavenCentral()
}

dependencies {
// this must be added for 2.2.7.RELEASE for both of JWT and opaque token, otherwise,
// ----------------------------------
// java.lang.IllegalStateException: Failed to introspect Class [org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector] from ClassLoader
// ----------------------------------
// exception will be thrown during launching application
// see [Dependency issues in resource server #8391](https://github.com/spring-projects/spring-security/issues/8391)
implementation 'com.nimbusds:oauth2-oidc-sdk:8.4'

implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'

testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
}

test {
useJUnitPlatform()
}

We add our api and assign the authority for it.

NOTICE

We use scope value of client here. And need add ‘SCOPE_’ prefix for it because when NimbusOpaqueTokenIntrospector parsing the response of auth server, it will append the ‘SCOPE_‘ prefix for all scope names and ‘ROLE_‘ prefix for all authorities.

But, is you use ‘.hasRole(role_name)’, you need not add ‘ROLE_‘ prefix, otherwise, an error will be occurred on it to tell you removing the prefix.

ResourceServerConfig.java

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
package com.simplejourney.securityoauth2res.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends WebSecurityConfigurerAdapter {
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}")
private String introspectionUri;

@Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-id}")
private String clientId;

@Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-secret}")
private String clientSecret;

@Bean
OpaqueTokenIntrospector opaqueTokenIntrospector() {
return new DemoOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize ->
authorize
.antMatchers(HttpMethod.GET, "/book/list").hasAuthority("SCOPE_APP")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2ResourceServer -> {
oauth2ResourceServer.opaqueToken().introspector(opaqueTokenIntrospector());
});
}
}

Because the type of scopes of ClientDetails is Set and it will be serialized to a JSONARRAY, but getScope() method in TokenIntrospectionSuccessResponse only can parse JSONString for ‘scope’ field. So, we need add this introspector to override and get scope and authorities from response.

DemoOpaqueTokenIntrospector.java

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
package com.simplejourney.securityoauth2res.config;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal;
import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

/**
* We need replace introsepctor with customized because getScope() method of TokenIntrospectionSuccessResponse cannot deserialize scope of type Set<String>
* see [TokenIntrospectionSuccessResponse doesn't support parsing scopes presented as JSONArray #7563](https://github.com/spring-projects/spring-security/issues/7563)
*/

public class DemoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
private OpaqueTokenIntrospector delegate;

public DemoOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) {
this.delegate = new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
}

@Override
public OAuth2AuthenticatedPrincipal introspect(String token) {
OAuth2AuthenticatedPrincipal principal = this.delegate.introspect(token);
return oAuth2AuthenticatedPrincipal(principal);
}

private DefaultOAuth2AuthenticatedPrincipal oAuth2AuthenticatedPrincipal(OAuth2AuthenticatedPrincipal principal) {
/**
* In ClientDetails interface of oauth2 server, there are 2 types of things to indicate the permissions of client
* 1. scopes
* 2. authorities
* You can get both of them from 'principal.attributes' which got from auth server
* Generally, you can use scope to grant permission for client, -> 'SCOPE_'
* And also, you can use 'authorities' to do it. -> 'ROLE_'
* Or, both of them
*
* But, notice, the scopes are permissions of client, and authorities are user's.
* See [Spring oauth2 scope vs authorities(roles)](https://stackoverflow.com/questions/32092749/spring-oauth2-scope-vs-authoritiesroles)
* And [The OAuth 2.0 Authorization Framework - 3.3. Access Token Scope](https://tools.ietf.org/html/rfc6749#section-3.3)
*/

List<String> scopes = principal.getAttribute(OAuth2IntrospectionClaimNames.SCOPE);
Collection<GrantedAuthority> grantedAuthorities = scopes.stream().map(scope -> "SCOPE_" + scope).map(SimpleGrantedAuthority::new).collect(Collectors.toList());

// List<String> authorities = principal.getAttribute("authorities");
// Collection<GrantedAuthority> grantedAuthorities = authorities.stream().map(authority -> "ROLE_" + authority).map(SimpleGrantedAuthority::new).collect(Collectors.toList());

return new DefaultOAuth2AuthenticatedPrincipal(principal.getName(), principal.getAttributes(), grantedAuthorities);
}
}

Add a simple api for return books’ list.

BookController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.simplejourney.securityoauth2res.controllers;

import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.ArrayList;
import java.util.List;

@Controller
@RequestMapping("/book")
public class BookController {
@GetMapping("/list")
public ResponseEntity<List<String>> list() {
return ResponseEntity.ok(new ArrayList<>());
}
}

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
spring:
security:
oauth2:
resourceserver:
opaque:
introspection-uri: "http://oauthsvr:8081/oauth/check_token"
introspection-client-id: client
introspection-client-secret: secret

server:
address: oauthres
port: 8082

Client

Finally, we need create client service which will call auth server to authenticate and authorize for user, and store access token in session.

To make OAuth 2 client side, we need add following dependences

  • spring-boot-starter-web
  • spring-boot-starter-security
  • spring-boot-starter-oauth2-client

build.gradle

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
plugins {
id 'org.springframework.boot' version '2.2.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}

group = 'com.simplejourney'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'

testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
}

test {
useJUnitPlatform()
}

WebSecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.simplejourney.securityoauth2client.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults());
}
}

HomeController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.simplejourney.securityoauth2client.controllers;

import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.HttpServletRequest;

@Controller
public class HomeController {
@GetMapping("/")
public ResponseEntity<String> home(HttpServletRequest request) {
return ResponseEntity.ok("Hello, world");
}
}

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
security:
oauth2:
client:
registration:
oauthsvr:
client-id: client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: APP
client-authentication-method: basic
provider:
oauthsvr:
authorization-uri: http://oauthsvr:8081/oauth/authorize
token-uri: http://oauthsvr:8081/oauth/token
user-info-uri: http://oauthsvr:8081/user/info
user-name-attribute: name
user-info-authentication-method: form

server:
port: 8080
address: oauthcli

Test

OK, all done. let’s test the results.

Before start services, we need add some domain name to ‘/etc/hosts’ file to separate each services,

1
127.0.0.1	localhost oauthsvr oauthcli oauthres

otherwise, you will see following error in browser because there is cookie conflict for ‘localhost’

Open your Postman, and add a new request. as following

Then, click the ‘Get New Access Token’ button, and fill as following

Start all services, then click ‘Request Token’ button

You will see a popup dialog which ask you enter the username and password, fill them

And click ‘Authorize’ button in next popup dialog

After these, Postman will received tokens and show them in dialog

click ‘Use Token’ button to use the tokens for your request, then click ‘Send’ button behind the request uri, you can get the empty list for books

Conclusion

OK, the services work well now.

If you have any questions or suggestions, please feel free to submit your comments to issue.

The demo projects you can download from Github with following links

References