[BAEL-5259] Spring Security - Map Authorities from JWT (#11990)

* [BAEL-4849] Article code

* [BAEL-4968] Article code

* [BAEL-4968] Article code

* [BAEL-4968] Article code

* [BAEL-4968] Remove extra comments

* [BAEL-5259] simple test case

* [BAEL-5259] DSL-based rewrite

* [BAEL-5259] Code formatting

* [BAEL-5259] Test case naming

* WIP: Article code

* [BAEL-5259] Tests

* [BAEL-5259] Tests
This commit is contained in:
psevestre 2022-03-31 02:04:02 -03:00 committed by GitHub
parent 2cda833322
commit 81e22da07c
14 changed files with 548 additions and 1 deletions

View File

@ -24,6 +24,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,23 @@
/**
*
*/
package com.baeldung.openid.oidc.jwtauthorities;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import com.baeldung.openid.oidc.discovery.SpringOidcDiscoveryApplication;
import com.baeldung.openid.oidc.utils.YamlLoaderInitializer;
@SpringBootApplication
public class SpringOidcJwtAuthoritiesApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(SpringOidcJwtAuthoritiesApplication.class);
ApplicationContextInitializer<ConfigurableApplicationContext> yamlInitializer = new YamlLoaderInitializer("jwtauthorities-application.yml");
application.addInitializers(yamlInitializer);
application.run(args);
}
}

View File

@ -0,0 +1,23 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import com.baeldung.openid.oidc.jwtauthorities.domain.Account;
public class AccountToken extends JwtAuthenticationToken {
private static final long serialVersionUID = 1L;
private final Account account;
public AccountToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities, String name, Account account) {
super(jwt, authorities, name);
this.account = account;
}
public Account getAccount() {
return account;
}
}

View File

@ -0,0 +1,36 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import java.util.Collection;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import com.baeldung.openid.oidc.jwtauthorities.domain.Account;
import com.baeldung.openid.oidc.jwtauthorities.service.AccountService;
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private final Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter;
private final String principalClaimName;
private AccountService accountService;
public CustomJwtAuthenticationConverter(AccountService accountService, Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter, String principalClaimName) {
this.accountService = accountService;
this.jwtGrantedAuthoritiesConverter = jwtGrantedAuthoritiesConverter;
this.principalClaimName = principalClaimName != null ? principalClaimName : JwtClaimNames.SUB;
}
@Override
public AbstractAuthenticationToken convert(Jwt source) {
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(source);
String principalClaimValue = source.getClaimAsString(this.principalClaimName);
Account acc = accountService.findAccountByPrincipal(principalClaimValue);
return new AccountToken(source, authorities, principalClaimValue, acc);
}
}

View File

@ -0,0 +1,49 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "baeldung.jwt.mapping")
public class JwtMappingProperties {
private String authoritiesPrefix;
private String authoritiesClaimName;
private String principalClaimName;
private Map<String,String> scopes;
public String getAuthoritiesClaimName() {
return authoritiesClaimName;
}
public void setAuthoritiesClaimName(String authoritiesClaimName) {
this.authoritiesClaimName = authoritiesClaimName;
}
public String getAuthoritiesPrefix() {
return authoritiesPrefix;
}
public void setAuthoritiesPrefix(String authoritiesPrefix) {
this.authoritiesPrefix = authoritiesPrefix;
}
public String getPrincipalClaimName() {
return principalClaimName;
}
public void setPrincipalClaimName(String principalClaimName) {
this.principalClaimName = principalClaimName;
}
public Map<String,String> getScopes() {
return scopes;
}
public void setScopes(Map<String,String> scopes) {
this.scopes = scopes;
}
}

View File

@ -0,0 +1,84 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
public class MappingJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
private static final Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES = Arrays.asList("scope", "scp");
private final Map<String,String> scopes;
private String authoritiesClaimName = null;
private String authorityPrefix = "SCOPE_";
MappingJwtGrantedAuthoritiesConverter(Map<String,String> scopes) {
this.scopes = scopes == null ? Collections.emptyMap(): scopes;
}
public void setAuthoritiesClaimName(String authoritiesClaimName) {
this.authoritiesClaimName = authoritiesClaimName;
}
public void setAuthorityPrefix(String authorityPrefix) {
this.authorityPrefix = authorityPrefix;
}
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<String> tokenScopes = parseScopesClaim(jwt);
if ( tokenScopes.isEmpty()) {
return Collections.emptyList();
}
return tokenScopes.stream()
.map(s -> scopes.getOrDefault(s, s))
.map(s -> this.authorityPrefix + s)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(HashSet::new));
}
protected Collection<String> parseScopesClaim(Jwt jwt) {
String scopeClaim;
if ( this.authoritiesClaimName == null ) {
scopeClaim = WELL_KNOWN_AUTHORITIES_CLAIM_NAMES.stream()
.filter( claim -> jwt.hasClaim(claim))
.findFirst()
.orElse(null);
if ( scopeClaim == null ) {
return Collections.emptyList();
}
}
else {
scopeClaim = this.authoritiesClaimName;
}
Object v = jwt.getClaim(scopeClaim);
if ( v == null ) {
return Collections.emptyList();
}
if ( v instanceof String) {
return Arrays.asList(v.toString().split(" "));
}
else if ( v instanceof Collection ) {
return ((Collection<?>)v).stream()
.map( s -> s.toString())
.collect(Collectors.toCollection(HashSet::new));
}
return Collections.emptyList();
}
}

View File

@ -0,0 +1,75 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import java.util.Collection;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.util.StringUtils;
import com.baeldung.openid.oidc.jwtauthorities.service.AccountService;
@Configuration
@EnableConfigurationProperties(JwtMappingProperties.class)
@EnableMethodSecurity
public class SecurityConfig {
private final JwtMappingProperties mappingProps;
private final AccountService accountService;
public SecurityConfig(JwtMappingProperties mappingProps, AccountService accountService) {
this.mappingProps = mappingProps;
this.accountService = accountService;
}
@Bean
public String jwtGrantedAuthoritiesPrefix() {
return mappingProps.getAuthoritiesPrefix();
}
@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(mappingProps.getScopes());
if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix()
.trim());
}
if (StringUtils.hasText(mappingProps.getAuthoritiesClaimName())) {
converter.setAuthoritiesClaimName(mappingProps.getAuthoritiesClaimName());
}
return converter;
}
@Bean
public Converter<Jwt,AbstractAuthenticationToken> customJwtAuthenticationConverter(AccountService accountService) {
return new CustomJwtAuthenticationConverter(
accountService,
jwtGrantedAuthoritiesConverter(),
mappingProps.getPrincipalClaimName());
}
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
// @formatter:off
return http.oauth2ResourceServer(oauth2 -> {
oauth2.jwt()
.jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService));
})
.build();
// @formatter:on
}
}

View File

@ -0,0 +1,26 @@
package com.baeldung.openid.oidc.jwtauthorities.domain;
public class Account {
private final Long id;
private final String branch;
private final String accountNumber;
public Account(Long id, String branch, String accountNumber) {
this.id = id;
this.branch = branch;
this.accountNumber = accountNumber;
}
public Long getId() {
return id;
}
public String getBranch() {
return branch;
}
public String getAccountNumber() {
return accountNumber;
}
}

View File

@ -0,0 +1,14 @@
package com.baeldung.openid.oidc.jwtauthorities.service;
import org.springframework.stereotype.Service;
import com.baeldung.openid.oidc.jwtauthorities.domain.Account;
@Service
public class AccountService {
public Account findAccountByPrincipal(String principal) {
// NOTE: real-world code would typically perform some sort of DB lookup
return new Account(1l, "0001", "101888444-0");
}
}

View File

@ -0,0 +1,49 @@
package com.baeldung.openid.oidc.jwtauthorities.web.controllers;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.baeldung.openid.oidc.jwtauthorities.config.AccountToken;
import com.baeldung.openid.oidc.jwtauthorities.domain.Account;
@RestController
@RequestMapping("/user")
public class UserRestController {
@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo( JwtAuthenticationToken principal) {
Collection<String> authorities = principal.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
Map<String,Object> info = new HashMap<>();
info.put("name", principal.getName());
info.put("authorities", authorities);
info.put("tokenAttributes", principal.getTokenAttributes());
if ( principal instanceof AccountToken ) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}
@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
return authentication.getAccount();
}
}

View File

@ -4,4 +4,3 @@ server:
logging:
level:
org.springframework.web.client.RestTemplate: DEBUG

View File

@ -0,0 +1,15 @@
spring:
security:
oauth2:
resourceserver:
jwt:
# issuer-uri: https://sts.windows.net/2e9fde3a-38ec-44f9-8bcd-c184dc1e8033/
issuer-uri: http://localhost:8083/auth/realms/baeldung
baeldung:
jwt:
mapping:
authorities-prefix: "MY_SCOPE_"
principal-claim-name: preferred_username
scopes:
profile: profile.read
"profile_read": profile.read

View File

@ -0,0 +1,59 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import static org.junit.jupiter.api.Assertions.*;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import com.baeldung.openid.oidc.jwtauthorities.service.AccountService;
class CustomJwtAuthenticationConverterUnitTest {
@Test
void testGivenCustomJwtAuthenticationConverter_whenConvert_thenReturnAccountToken() {
AccountService accountService = new AccountService();
MappingJwtGrantedAuthoritiesConverter authoritiesConverter = new MappingJwtGrantedAuthoritiesConverter(new HashMap<>());
CustomJwtAuthenticationConverter converter = new CustomJwtAuthenticationConverter(
accountService, authoritiesConverter, null);
Jwt jwt = Jwt.withTokenValue("NOTUSED")
.header("typ", "JWT")
.subject("user")
.claim("scp", "openid email profile")
.build();
Object auth = converter.convert(jwt);
assertTrue(auth instanceof AccountToken, "Authentication must be instance of AccountToken");
AccountToken token = AccountToken.class.cast(auth);
assertEquals("user", token.getName());
}
@Test
void testGivenCustomPrincipalClaimName_whenConvert_thenReturnAccountToken() {
AccountService accountService = new AccountService();
MappingJwtGrantedAuthoritiesConverter authoritiesConverter = new MappingJwtGrantedAuthoritiesConverter(new HashMap<>());
CustomJwtAuthenticationConverter converter = new CustomJwtAuthenticationConverter(
accountService, authoritiesConverter, "preferred_username");
Jwt jwt = Jwt.withTokenValue("NOTUSED")
.header("typ", "JWT")
.claim("preferred_username", "user")
.claim("scp", "openid email profile")
.build();
Object auth = converter.convert(jwt);
assertTrue(auth instanceof AccountToken, "Authentication must be instance of AccountToken");
AccountToken token = AccountToken.class.cast(auth);
assertEquals("user", token.getName());
}
}

View File

@ -0,0 +1,91 @@
package com.baeldung.openid.oidc.jwtauthorities.config;
import static org.junit.Assert.assertTrue;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
class MappingJwtGrantedAuthoritiesConverterUnitTest {
@Test
void testGivenConverterWithScopeMap_whenConvert_thenResultHasMappedAuthorities() {
Jwt jwt = Jwt.withTokenValue("NOTUSED")
.header("typ", "JWT")
.subject("user")
.claim("scp", "openid email profile")
.build();
Map<String, String> scopeMap = new HashMap<>();
scopeMap.put("profile", "profile.read");
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(scopeMap);
Collection<GrantedAuthority> result = converter.convert(jwt);
assertTrue("Result must contain the authoriry 'SCOPE_profile.read'",
result.contains(new SimpleGrantedAuthority("SCOPE_profile.read")));
}
@Test
void testGivenConverterWithCustomScopeClaim_whenConvert_thenResultHasAuthorities() {
Jwt jwt = Jwt.withTokenValue("NOTUSED")
.header("typ", "JWT")
.subject("user")
.claim("myscope_claim", "openid email profile")
.build();
Map<String, String> scopeMap = new HashMap<>();
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(scopeMap);
converter.setAuthoritiesClaimName("myscope_claim");
Collection<GrantedAuthority> result = converter.convert(jwt);
assertTrue("Result must contain the authoriry 'SCOPE_profile'",
result.contains(new SimpleGrantedAuthority("SCOPE_profile")));
}
@Test
void testGivenTokenWithNonMappedScope_whenConvert_thenResultHasOriginalScope() {
Jwt jwt = Jwt.withTokenValue("NOTUSED")
.header("typ", "JWT")
.subject("user")
.claim("scp", "openid email profile custom")
.build();
Map<String, String> scopeMap = new HashMap<>();
scopeMap.put("profile", "profile.read");
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(scopeMap);
Collection<GrantedAuthority> result = converter.convert(jwt);
assertTrue("Result must contain the authority SCOPE_custom",
result.contains(new SimpleGrantedAuthority("SCOPE_custom")));
}
@Test
void testGivenConverterWithCustomPrefix_whenConvert_thenAllAuthoritiesMustHaveTheCustomPrefix() {
Jwt jwt = Jwt.withTokenValue("NOTUSED")
.header("typ", "JWT")
.subject("user")
.claim("scp", "openid email profile custom")
.build();
Map<String, String> scopeMap = new HashMap<>();
scopeMap.put("profile", "profile.read");
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(scopeMap);
converter.setAuthorityPrefix("MY_SCOPE");
Collection<GrantedAuthority> result = converter.convert(jwt);
long count = result.stream()
.map(GrantedAuthority::getAuthority)
.filter(s -> !s.startsWith("MY_SCOPE"))
.count();
assertTrue("All authorities names must start with custom prefix", count == 0 );
}
}