-
-
Save jamilxt/d33b4a66a003ce6af1443bffd6e6959a to your computer and use it in GitHub Desktop.
Simple Spring Boot App protected by Keycloak with initial roles from Keycloak and additional hierarchical app Internal roles. Supports fine grained permission checks, where the permissions are derived from roles.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package demo; | |
import java.io.Serializable; | |
import java.util.ArrayList; | |
import java.util.Collection; | |
import java.util.Collections; | |
import java.util.HashMap; | |
import java.util.HashSet; | |
import java.util.LinkedHashMap; | |
import java.util.List; | |
import java.util.Map; | |
import java.util.Set; | |
import java.util.UUID; | |
import java.util.stream.Collectors; | |
import java.util.stream.Stream; | |
import javax.servlet.http.HttpSession; | |
import org.keycloak.adapters.KeycloakConfigResolver; | |
import org.keycloak.adapters.springboot.KeycloakBaseSpringBootConfiguration; | |
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver; | |
import org.keycloak.adapters.springboot.KeycloakSpringBootProperties; | |
import org.keycloak.adapters.springsecurity.KeycloakConfiguration; | |
import org.keycloak.adapters.springsecurity.account.KeycloakRole; | |
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; | |
import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory; | |
import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate; | |
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; | |
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; | |
import org.keycloak.representations.AccessToken; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.beans.factory.config.ConfigurableBeanFactory; | |
import org.springframework.boot.SpringApplication; | |
import org.springframework.boot.autoconfigure.SpringBootApplication; | |
import org.springframework.boot.context.properties.ConfigurationProperties; | |
import org.springframework.boot.context.properties.EnableConfigurationProperties; | |
import org.springframework.context.ApplicationContext; | |
import org.springframework.context.annotation.Bean; | |
import org.springframework.context.annotation.Configuration; | |
import org.springframework.context.annotation.Scope; | |
import org.springframework.http.HttpStatus; | |
import org.springframework.security.access.PermissionEvaluator; | |
import org.springframework.security.access.annotation.Secured; | |
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; | |
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; | |
import org.springframework.security.access.hierarchicalroles.RoleHierarchy; | |
import org.springframework.security.access.hierarchicalroles.RoleHierarchyAuthoritiesMapper; | |
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; | |
import org.springframework.security.access.hierarchicalroles.RoleHierarchyUtils; | |
import org.springframework.security.access.prepost.PreAuthorize; | |
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; | |
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; | |
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration; | |
import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |
import org.springframework.security.core.Authentication; | |
import org.springframework.security.core.AuthenticationException; | |
import org.springframework.security.core.GrantedAuthority; | |
import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
import org.springframework.security.core.authority.SimpleGrantedAuthority; | |
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; | |
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; | |
import org.springframework.security.core.session.SessionRegistryImpl; | |
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; | |
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; | |
import org.springframework.stereotype.Component; | |
import org.springframework.util.CollectionUtils; | |
import org.springframework.web.bind.annotation.DeleteMapping; | |
import org.springframework.web.bind.annotation.GetMapping; | |
import org.springframework.web.bind.annotation.PathVariable; | |
import org.springframework.web.bind.annotation.PostMapping; | |
import org.springframework.web.bind.annotation.RequestBody; | |
import org.springframework.web.bind.annotation.RequestMapping; | |
import org.springframework.web.bind.annotation.ResponseStatus; | |
import org.springframework.web.bind.annotation.RestController; | |
import lombok.Data; | |
import lombok.Getter; | |
import lombok.RequiredArgsConstructor; | |
import lombok.Setter; | |
import lombok.Value; | |
import lombok.extern.slf4j.Slf4j; | |
/** | |
* <pre> | |
* {@code | |
* curl -v -u demo:test http://localhost:28080/api/users/current | |
* | |
* Output: | |
* {"permissions":["create-order","cancel-order"],"roles":["ROLE_UNAUTHENTICATED","ROLE_AUTHENTICATED","ROLE_USER","ROLE_API_CONSUMER","ROLE_ORDER_DISPATCHER"],"username":"demo"} | |
* | |
* curl -v -u admin:test http://localhost:28080/api/users/current | |
* | |
* Output: | |
* {"permissions":["create-order","cancel-order","delete-order"],"roles":["ROLE_UNAUTHENTICATED","ROLE_AUTHENTICATED","ROLE_USER_ADMIN","ROLE_USER","ROLE_ADMIN","ROLE_API_CONSUMER","ROLE_ORDER_ADMIN","ROLE_ORDER_DISPATCHER"],"username":"admin"} | |
* | |
* curl -v -u demo:test -H "Content-type: application/json" -d '{"amount":10.0}' http://localhost:28080/api/orders | |
* | |
* Output: | |
* {"orderId":"7c3d3168-ec8f-4abd-a078-2c34b00e1829"} | |
* | |
* curl -v -u demo:test -X DELETE http://localhost:28080/api/orders/7c3d3168-ec8f-4abd-a078-2c34b00e1829 | |
* | |
* Output: | |
* {"timestamp":"2018-11-06T19:02:25.903+0000","status":403,"error":"Forbidden","message":"Forbidden","path":"/api/orders/7c3d3168-ec8f-4abd-a078-2c34b00e1829"} | |
* | |
* curl -v -u admin:test -X DELETE http://localhost:28080/api/orders/7c3d3168-ec8f-4abd-a078-2c34b00e1829 | |
* | |
* Output: | |
* HTTP Status 202 Accepted | |
* } | |
* </pre> | |
* | |
*/ | |
@SpringBootApplication | |
public class App { | |
public static void main(String[] args) { | |
SpringApplication.run(App.class, args); | |
} | |
} | |
@Configuration | |
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) | |
class SecurityConfig extends GlobalMethodSecurityConfiguration { | |
@Autowired | |
private DomainAwarePermissionEvaluator permissionEvaluator; | |
@Autowired | |
private ApplicationContext applicationContext; | |
@Override | |
protected MethodSecurityExpressionHandler createExpressionHandler() { | |
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); | |
expressionHandler.setPermissionEvaluator(permissionEvaluator); | |
expressionHandler.setApplicationContext(applicationContext); | |
return expressionHandler; | |
} | |
@Bean | |
public RoleHierarchy roleHierarchy(SecurityPropertiesExtension spe) { | |
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); | |
roleHierarchy.setHierarchy(RoleHierarchyUtils.roleHierarchyFromMap(spe.getRoleHierarchy())); | |
return roleHierarchy; | |
} | |
} | |
class RoleResolvingGrantedAuthoritiesMapper extends RoleHierarchyAuthoritiesMapper { | |
private final GrantedAuthoritiesMapper delegate; | |
public RoleResolvingGrantedAuthoritiesMapper(RoleHierarchy roleHierarchy, GrantedAuthoritiesMapper delegate) { | |
super(roleHierarchy); | |
this.delegate = delegate; | |
} | |
public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) { | |
// Transform roles if necessary | |
Collection<? extends GrantedAuthority> transformedAuthorities = delegate.mapAuthorities(authorities); | |
// Roles resolved via role hierarchy | |
Collection<? extends GrantedAuthority> expanededAuthorities = super.mapAuthorities(transformedAuthorities); | |
return expanededAuthorities; | |
} | |
} | |
@RequiredArgsConstructor | |
@KeycloakConfiguration | |
@EnableConfigurationProperties({ KeycloakSpringBootProperties.class, SecurityPropertiesExtension.class }) | |
class KeycloakConfig extends KeycloakWebSecurityConfigurerAdapter { | |
private final RoleHierarchy roleHierachy; | |
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
super.configure(http); | |
http // | |
// disable csrf for demo purposes | |
.csrf().disable() // | |
.authorizeRequests() // | |
.antMatchers("/api/*").authenticated() // | |
.anyRequest().permitAll() // | |
; | |
} | |
/** | |
* Use Keycloak configuration from properties / yaml | |
* | |
* @return | |
*/ | |
@Bean | |
public KeycloakConfigResolver keycloakConfigResolver() { | |
return new KeycloakSpringBootConfigResolver(); | |
} | |
@Override | |
protected KeycloakAuthenticationProvider keycloakAuthenticationProvider() { | |
SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper(); | |
grantedAuthorityMapper.setPrefix("ROLE_"); | |
grantedAuthorityMapper.setConvertToUpperCase(true); | |
RoleResolvingGrantedAuthoritiesMapper resolvingMapper = new RoleResolvingGrantedAuthoritiesMapper(roleHierachy, | |
grantedAuthorityMapper); | |
// RoleAppendingGrantedAuthoritiesMapper | |
return new CustomKeycloakAuthenticationProvider(resolvingMapper); | |
} | |
@Autowired | |
public void configureGlobal(AuthenticationManagerBuilder auth) { | |
auth.authenticationProvider(keycloakAuthenticationProvider()); | |
} | |
@Bean | |
@Override | |
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { | |
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); | |
} | |
/** | |
* {@link KeycloakRestTemplate} configured to use {@link AccessToken} of current | |
* user. | |
* | |
* @param requestFactory | |
* @return | |
*/ | |
@Bean | |
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) | |
public KeycloakRestTemplate keycloakRestTemplate(KeycloakClientRequestFactory requestFactory) { | |
return new KeycloakRestTemplate(requestFactory); | |
} | |
/** | |
* Ensures the correct registration of KeycloakSpringBootConfigResolver when | |
* Keycloaks AutoConfiguration is explicitly turned off in application.yml | |
* {@code keycloak.enabled: false}. | |
*/ | |
@Configuration | |
static class CustomKeycloakBaseSpringBootConfiguration extends KeycloakBaseSpringBootConfiguration { | |
@Override | |
public void setKeycloakConfigResolvers(KeycloakConfigResolver configResolver) { | |
// NOOP avoids recursive calls to setKeycloakConfigResolvers in Spring Boot | |
// 2.0.6 and Keycloak 4.5.0 | |
} | |
} | |
} | |
@RequiredArgsConstructor | |
class CustomKeycloakAuthenticationProvider extends KeycloakAuthenticationProvider { | |
private final GrantedAuthoritiesMapper grantedAuthoritiesMapper; | |
@Override | |
public Authentication authenticate(Authentication authentication) throws AuthenticationException { | |
KeycloakAuthenticationToken token = (KeycloakAuthenticationToken) authentication; | |
Collection<? extends GrantedAuthority> keycloakAuthorities = mapAuthorities(addKeycloakRoles(token)); | |
Collection<? extends GrantedAuthority> grantedAuthorities = addUserSpecificAuthorities(authentication, | |
keycloakAuthorities); | |
return new KeycloakAuthenticationToken(token.getAccount(), token.isInteractive(), grantedAuthorities); | |
} | |
protected Collection<? extends GrantedAuthority> addUserSpecificAuthorities( // | |
Authentication authentication, // | |
Collection<? extends GrantedAuthority> authorities // | |
) { | |
// potentially add user specific authentication, lookup from internal database | |
// etc... | |
List<GrantedAuthority> result = new ArrayList<>(); | |
result.addAll(authorities); | |
if ("demo".equals(authentication.getName())) { | |
result.add(new SimpleGrantedAuthority("ROLE_ORDER_DISPATCHER")); | |
} | |
return result; | |
} | |
protected Collection<? extends GrantedAuthority> addKeycloakRoles(KeycloakAuthenticationToken token) { | |
Collection<GrantedAuthority> keycloakRoles = new ArrayList<>(); | |
for (String role : token.getAccount().getRoles()) { | |
keycloakRoles.add(new KeycloakRole(role)); | |
} | |
return keycloakRoles; | |
} | |
private Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) { | |
return grantedAuthoritiesMapper != null ? grantedAuthoritiesMapper.mapAuthorities(authorities) : authorities; | |
} | |
@Override | |
public boolean supports(Class<?> aClass) { | |
return KeycloakAuthenticationToken.class.isAssignableFrom(aClass); | |
} | |
} | |
interface PermissionResolver { | |
Set<String> resolve(Authentication authentication); | |
} | |
@Component | |
@RequiredArgsConstructor | |
class DefaultPermissionResolver implements PermissionResolver { | |
private final SecurityPropertiesExtension securityPropertiesExtension; | |
@Override | |
public Set<String> resolve(Authentication authentication) { | |
return authentication.getAuthorities().stream() // | |
.flatMap(this::permissionsForRole) // | |
.collect(Collectors.toSet()); | |
} | |
private Stream<String> permissionsForRole(GrantedAuthority authority) { | |
return new HashSet<>(securityPropertiesExtension.getPermissions().getOrDefault(authority.getAuthority(), | |
Collections.emptyList())).stream(); | |
} | |
} | |
@Slf4j | |
@Component | |
@RequiredArgsConstructor | |
class DomainAwarePermissionEvaluator implements PermissionEvaluator { | |
private final PermissionResolver permissionResolver; | |
@Override | |
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { | |
log.info("check permission '{}' for user '{}' for target '{}'", permission, authentication.getName(), | |
targetDomainObject); | |
Set<String> givenPermissions = permissionResolver.resolve(authentication); | |
Set<String> requiredPermissions = toPermissions(permission); | |
boolean permissionsMatch = givenPermissions.containsAll(requiredPermissions); | |
if (!permissionsMatch) { | |
log.debug("Insufficient permissions:\nRequired: {}\nGiven: {}", requiredPermissions, givenPermissions); | |
return false; | |
} | |
// Delegate to bounded context specific permission evaluation... | |
if ("place-order".equals(permission)) { | |
Order order = (Order) targetDomainObject; | |
if (order.getAmount() > 500) { | |
return hasRole("ROLE_ADMIN", authentication); | |
} | |
} | |
return true; | |
} | |
private Set<String> toPermissions(Object permission) { | |
if (permission instanceof String) { | |
return Collections.singleton((String) permission); | |
} | |
// TODO deal with other forms of required permissions... | |
return Collections.emptySet(); | |
} | |
@Override | |
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, | |
Object permission) { | |
return hasPermission(authentication, new DomainObjectReference(targetId, targetType), permission); | |
} | |
private boolean hasRole(String role, Authentication auth) { | |
if (auth == null || auth.getPrincipal() == null) { | |
return false; | |
} | |
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities(); | |
if (CollectionUtils.isEmpty(authorities)) { | |
return false; | |
} | |
return authorities.stream().filter(ga -> role.equals(ga.getAuthority())).findAny().isPresent(); | |
} | |
@Value | |
static class DomainObjectReference { | |
private final Serializable targetId; | |
private final String targetType; | |
} | |
} | |
@RequestMapping("/api/auth") | |
@RestController | |
class AuthEndpoint { | |
@GetMapping | |
Map<String, Object> getToken(HttpSession session) { | |
return Collections.singletonMap("session", session.getId()); | |
} | |
} | |
@Getter | |
@Setter | |
@ConfigurationProperties("security.authz") | |
class SecurityPropertiesExtension { | |
Map<String, List<String>> roleHierarchy = new LinkedHashMap<>(); | |
Map<String, List<String>> permissions = new LinkedHashMap<>(); | |
} | |
@Data | |
class Order { | |
double amount; | |
} | |
@Slf4j | |
@Secured("ROLE_USER") | |
@RequestMapping("/api/orders") | |
@RestController | |
class OrderEndpoint { | |
/** | |
* Example for using fine grained application specific permissions | |
* | |
* @param order | |
* @return | |
*/ | |
@PostMapping | |
@PreAuthorize("hasPermission(#order, 'create-order')") | |
Map<String, Object> createOrder(@RequestBody Order order) { | |
Map<String, Object> map = new HashMap<>(); | |
map.put("orderId", UUID.randomUUID()); | |
return map; | |
} | |
/** | |
* Example for using fine grained application specific permissions | |
* | |
* @param order | |
* @return | |
*/ | |
@DeleteMapping("/{id}") | |
@PreAuthorize("hasPermission(#order, 'delete-order')") | |
@ResponseStatus(HttpStatus.ACCEPTED) | |
void deleteOrder(@PathVariable String id) { | |
log.info("Delete order {}", id); | |
} | |
} | |
@Secured("ROLE_USER") | |
@RequestMapping("/api/users") | |
@RestController | |
@RequiredArgsConstructor | |
class UserEndpoint { | |
private final PermissionResolver permissionResolver; | |
/** | |
* Dummy endpoint to return the resolved user information... | |
* | |
* @param token | |
* @return | |
*/ | |
@GetMapping("/current") | |
Object getUserInfo(@AuthenticationPrincipal KeycloakAuthenticationToken token) { | |
Map<Object, Object> userInfo = new HashMap<>(); | |
userInfo.put("username", token.getName()); | |
userInfo.put("roles", token.getAuthorities().stream() // | |
.map(GrantedAuthority::getAuthority) // | |
.collect(Collectors.toList()) // | |
); | |
userInfo.put("permissions", permissionResolver.resolve(token)); | |
return userInfo; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
server: | |
port: 28080 | |
keycloak: | |
# turn off Spring-Boots Keycloak AutoConfiguration: | |
# We only want to use Spring-Security without servlet container specific infrastructure. | |
# This allows us to pull the Keycloak configuration from here instead of keycloak.json | |
enabled: false | |
realm: acme | |
auth-server-url: http://localhost:8080/auth | |
# The client-id | |
resource: app-webshop | |
enable-basic-auth: true | |
credentials: | |
secret: b7e1ba50-dc8e-4cb0-af20-97a83d38c363 | |
ssl-required: external | |
principal-attribute: preferred_username | |
use-resource-role-mappings: true | |
token-minimum-time-to-live: 30 | |
logging: | |
level: | |
demo: debug | |
security: | |
authz: | |
role-hierarchy: | |
# ROLE_ADMIN is provided by Keycloak | |
ROLE_ADMIN: ROLE_USER_ADMIN, ROLE_ORDER_ADMIN | |
ROLE_ORDER_ADMIN: ROLE_ORDER_DISPATCHER | |
# ROLE_ORDER_DISPATCHER is internally assigned to user with name "demo" | |
ROLE_ORDER_DISPATCHER: ROLE_USER | |
ROLE_USER_ADMIN: ROLE_USER | |
# ROLE_USER is provided by Keycloak | |
ROLE_USER: ROLE_API_CONSUMER | |
ROLE_API_CONSUMER: ROLE_AUTHENTICATED | |
ROLE_AUTHENTICATED: ROLE_UNAUTHENTICATED | |
# maps roles to internal permissions | |
permissions: | |
ROLE_ORDER_DISPATCHER: ['create-order', 'cancel-order'] | |
ROLE_ORDER_ADMIN: ['delete-order'] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<groupId>com.github.thomasdarimont.training</groupId> | |
<artifactId>spring-keycloak-custom-authz</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
<packaging>jar</packaging> | |
<name>spring-keycloak-custom-authz</name> | |
<description>Demo project for Spring Boot</description> | |
<parent> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<version>2.0.6.RELEASE</version> | |
<relativePath/> <!-- lookup parent from repository --> | |
</parent> | |
<properties> | |
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> | |
<java.version>1.8</java.version> | |
<keycloak.version>4.5.0.Final</keycloak.version> | |
</properties> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-security</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-web</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.keycloak</groupId> | |
<artifactId>keycloak-spring-boot-starter</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-devtools</artifactId> | |
<scope>runtime</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.security</groupId> | |
<artifactId>spring-security-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-configuration-processor</artifactId> | |
<optional>true</optional> | |
</dependency> | |
</dependencies> | |
<dependencyManagement> | |
<dependencies> | |
<dependency> | |
<groupId>org.keycloak.bom</groupId> | |
<artifactId>keycloak-adapter-bom</artifactId> | |
<version>${keycloak.version}</version> | |
<type>pom</type> | |
<scope>import</scope> | |
</dependency> | |
</dependencies> | |
</dependencyManagement> | |
<build> | |
<plugins> | |
<plugin> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-maven-plugin</artifactId> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment