Spring Security OAuth2 Login
ReLive27
Posted on March 9, 2023
Overview
OAuth 2.0 is not an authentication protocol.
What is identity authentication? Authentication is the solution to the "Who are you?". Authentication tells the app who the current user is and whether they are using the app.In practice, it may also tell you the user's name, email address, mobile phone number, etc.
If an extension to OAuth 2.0. Enables messages from authorization servers and protected resources to convey information about users and their authentication context. We can provide the client with all the information for the user to log in securely.
The main advantages of this identity authentication method based on the OAuth 2.0 authorization protocol:
- The user performs authentication on the authorization server, the end user's original credentials are not passed to the client application through the OAuth 2.0 protocol.
- Allows users to enforce consent decisions at runtime.
- The user can also authorize access to other protected APIs along with his identity information. Through a call, the application can know whether the user is logged in, how to call the user, the user's mobile phone number, email address, etc.
In this article, we will use the OAuth 2.0 authorization code mode to securely pass the authorization service user information and log in to the client application.
In this article you will learn:
- Build basic authorization service and client service.
- Customize the authorization server access token and add role information.
- Custom authorization server user info endpoint.
- The client service uses
GrantedAuthoritiesMapper
for authorization mapping. - Client service custom
OAuth2UserService
implements parsing multi-layer Json data.
OAuth2 Authorization Server 🚀
In this section we will use Spring Authorization Server to build an authorization server.In addition, we will also customize the access_token and custom user information endpoints.
Maven dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
Configuration
First configure the service port 8080 through application.yml
:
server:
port: 8080
Next we will create the OAuth2ServerConfig
configuration class to define the specific beans needed for the OAuth2 authorization service. First we register an OAuth2 client:
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("relive-client")
.clientSecret("{noop}relive-client")
.clientAuthenticationMethods(s -> {
s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
})
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(false)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)/
.accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
.refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
.reuseRefreshTokens(true)
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
The above stores the OAuth2 client in memory. If you need to use database persistence, please refer to the article Using JWT with Spring Security OAuth2. Specify the OAuth2 client information as follows:
- clientId: relive-client
- clientSecret: relive-client
- redirectUri: http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code
- scope: profile
Next let's configure other default configurations of the OAuth2 authorization service, and redirect unauthenticated authorization requests to the login page:
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http
.exceptionHandling(exceptions -> exceptions.
authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.build();
}
The authorization server token token format uses JWT RFC 7519, so we need a signing key for the token, let's generate an RSA key:
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
static class Jwks {
private Jwks() {
}
public static RSAKey generateRsa() {
KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
}
static class KeyGeneratorUtils {
private KeyGeneratorUtils() {
}
static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
Next we will customize the access_token access token and add role information to the token:
@Configuration(proxyBeanMethods = false)
public class AccessTokenCustomizerConfig {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return (context) -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims().claims(claim -> {
claim.put("role", context.getPrincipal().getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.toSet()));
});
}
};
}
}
You can see that Spring Security provides us with OAuth2TokenCustomizer
for extending token information. We get the current user information from OAuth2TokenContext
, and extract the Authorities information from it and add it to the JWT claim.
Below we will create a Spring Security configuration class to configure the basic authentication capabilities of the authorization service.
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/userInfo")
.access("hasAnyAuthority('SCOPE_profile')")
.mvcMatchers("/userInfo")
.access("hasAuthority('SCOPE_profile')")
.anyRequest().authenticated()
.and()
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public UserDetailsService users() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
}
In the above configuration class, we did the following things:
- Enable Form authentication method;
- Configure login username and password;
- Use
oauth2ResourceServer()
to configure JWT authentication, and declareJwtDecoder
; - Protecting the /userInfo endpoint requires profile permissions for access;
At this point, we also need to create a Controller class to provide the OAuth2 client service to obtain user information:
@RestController
public class UserInfoController {
@PostMapping("/userInfo")
public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
return Collections.singletonMap("data", jwt.getClaims());
}
}
We return user information in JSON format:
{
"data":{
"sub":"admin"
...
}
}
OAuth2 Client Service 🚀
This section will use Spring Security to configure OAuth2 client login. And we will use GrantedAuthoritiesMapper
to map authority information. And we will create DefaultJsonOAuth2UserService
for parsing multi-layer JSON user information data.
Maven dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>2.6.7</version>
</dependency>
Configuration
First, we specify the client service port number 8070, and configure the OAuth2 client related information. The client information needs to be consistent with the authorization server registration information.
server:
port: 8070
servlet:
session:
cookie:
name: CLIENT-SESSION
spring:
security:
oauth2:
client:
registration:
messaging-client-authorization-code:
provider: client-provider
client-id: relive-client
client-secret: relive-client
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: profile
client-name: messaging-client-authorization-code
provider:
client-provider:
authorization-uri: http://127.0.0.1:8080/oauth2/authorize
token-uri: http://127.0.0.1:8080/oauth2/token
user-info-uri: http://127.0.0.1:8080/userInfo
user-name-attribute: data.sub
user-info-authentication-method: form
Next, configure Spring Security related beans. We first enable Form form authentication and OAuth2 login capabilities. Here we specify to redirect to the /home path after successful authentication.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.anyRequest()
.authenticated()
.and()
.formLogin(from -> {
from.defaultSuccessUrl("/home");
})
.oauth2Login(Customizer.withDefaults())
.csrf().disable();
return http.build();
}
Below we use GrantedAuthoritiesMapper
to map user permissions:
@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
//Role mapping relationship, authorization server ADMIN role corresponds to client OPERATION role
Map<String, String> roleMapping = new HashMap<>();
roleMapping.put("ROLE_ADMIN", "ROLE_OPERATION");
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(authority -> {
if (OAuth2UserAuthority.class.isInstance(authority)) {
OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
List<String> role = (List) userAttributes.get("role");
role.stream().map(roleMapping::get)
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.forEach(mappedAuthorities::add);
}
});
return mappedAuthorities;
};
}
The above maps the OAuth2 authorization service ADMIN role to the client service OPERATION role. Of course, you can also expand to database operations, so you need to maintain the authorization service role and client service role mapping table, which will not be expanded here.
GrantedAuthoritiesMapper
is used as an authority mapper in OAuth2 login, CAS login, SAML and LDAP.
The source code of GrantedAuthoritiesMapper
in OAuth2LoginAuthenticationProvider
is as follows:
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
//...
/* map authorities */
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
.mapAuthorities(oauth2User.getAuthorities());
/* map authorities */
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
authenticationResult.setDetails(loginAuthenticationToken.getDetails());
return authenticationResult;
}
So when we custom implement GrantedAuthoritiesMapper
. After successful OAuth2 login, store the mapped permission information in the authentication information OAuth2LoginAuthenticationToken
.
Next will implement the OAuth2UserService
custom DefaultJsonOAuth2UserService
class. Of course Spring Security provides DefaultOAuth2UserService
, so why not use it? The reason is simple. First, let us review the format of the authorization server returning user information:
{
"data":{
"sub":"admin"
...
}
}
The user information is nested in the data
field, and the DefaultOAuth2UserService
does not process this format when processing the user information response. The following is a snippet of the DefaultOAuth2UserService
source code:
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error("missing_user_info_uri", "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
} else {
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error("missing_user_name_attribute", "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
} else {
RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
/* Get user information */
ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
//Get the response body information directly here. By default, this userAttributes contains relevant user information, and does not parse multi-layer JSON
Map<String, Object> userAttributes = (Map)response.getBody();
/* Get user information */
Set<GrantedAuthority> authorities = new LinkedHashSet();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
Iterator var8 = token.getScopes().iterator();
while(var8.hasNext()) {
String authority = (String)var8.next();
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
}
}
When the DefaultOAuth2User
is finally created, you will get the following error message:
Missing attribute 'sub' in attributes
Below we use userNameAttributeName with "." as the separator. Extract user information to achieve. The following is the key code:
public class DefaultJsonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
//...
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//...
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<JsonNode> response = getResponse(userRequest, request);
JsonNode responseBody = response.getBody();
//Multi-layer JSON extracts user information attributes
Map<String, Object> userAttributes = new HashMap<>();
if (userNameAttributeName.contains(".")) {
String firstNodePath = userNameAttributeName.substring(0, userNameAttributeName.lastIndexOf("."));
userAttributes = this.extractUserAttribute(responseBody, firstNodePath);
userNameAttributeName = userNameAttributeName.substring(firstNodePath.length() + 1);
} else {
userAttributes = JsonHelper.parseMap(responseBody.toString());
}
//...
}
}
Finally, we create the Controller class and use the thymeleaf template engine to build the home page information. Different permission information sees different results in the home page list.
@Controller
public class HomeController {
private static Map<String, List<String>> articles = new HashMap<>();
static {
articles.put("ROLE_OPERATION", Arrays.asList("Java"));
articles.put("ROLE_SYSTEM", Arrays.asList("Java", "Python", "C++"));
}
@GetMapping("/home")
public String home(Authentication authentication, Model model) {
String authority = authentication.getAuthorities().iterator().next().getAuthority();
model.addAttribute("articles", articles.get(authority));
return "home";
}
}
Test
After we start the service, visit http://127.0.0.1:8070/login. After successful login with username, you will see:
We log out, and log in with OAuth2, you will see different information:
Conclusion
It is feasible for us to use the OAuth2.0 authorization protocol to build identity authentication proofs. But we cannot ignore the pitfalls in between.
- The token itself does not convey information about the authentication event. Tokens may be issued directly to clients, using the OAuth 2.0 Client Credentials model without user interaction.
- No client can get information about the user and his login status from the access_token. OAuth 2.0 access_token are intended for resource servers.(In this article, we use the JWT access_token to enable the client service to obtain information such as user permissions by customizing the access_token information. However, the OAuth2.0 protocol does not define the access_token format. We only use the characteristics of JWT to make it happen.)
- The client can present the access_token to the resource service to obtain user information. So it's easy to think that just having a valid access_token proves that the user is logged in, and this line of thinking is only true in some cases. For example, when a user completes identity authentication on an authorization server and immediately generates an access_token.(Because the access_token validity period may be much longer than the authentication session validity period.)
- The biggest problem with the user information API in the OAuth2.0 protocol is that different identity providers may have different user information APIs. A user's unique identifier may be "user_id" or "sub".
So we need a unified OAuth2.0 standard identity authentication protocol. OpenID Connect is an open standard that defines an interoperable way to perform user authentication using OAuth 2.0. This will be covered in a follow-up article.
As always, the source code used in this article is available on GitHub.
Thanks for reading! 😘
Posted on March 9, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.