Skip to content

Instantly share code, notes, and snippets.

@jamilxt
Forked from thomasdarimont/App.java
Created September 28, 2022 21:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jamilxt/d33b4a66a003ce6af1443bffd6e6959a to your computer and use it in GitHub Desktop.
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.
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;
}
}
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']
<?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