I m working with spring boot 3 java 17 and keyclock 21.1.1. I need to secure my rest api with keyclock ressource autorisation. for that i added a key clock client
policy
and my permission
My RPT token
I'm using oauth2 dependancies
<dependency>
<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>
Using @PreAuthorize("hasPermission('my_ressource', 'add')") is given me 403 forbiden
My Security config is
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfiguration {
@Autowired
private CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeHttpRequests(registry -> registry.anyRequest().authenticated())
.oauth2ResourceServer(oauth2Configurer -> oauth2Configurer.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwt -> {
Map<String, Collection<String>> realmAccess = jwt.getClaim("realm_access");
Collection<String> roles = realmAccess.get("roles");
var grantedAuthorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return new JwtAuthenticationToken(jwt,grantedAuthorities , "");
})))
;
return httpSecurity.build();
}
}
i need to adapte my security config to use hasPermission.
Application log
2023-09-12T17:39:18.444+01:00 DEBUG 1948 --- [nio-9000-exec-2] horizationManagerBeforeMethodInterceptor : Authorizing method invocation ReflectiveMethodInvocation: public org.springframework.http.ResponseEntity com.mypartner.tm.authentification.controller.UserBranchController.addKeyClock(); target is of class [com.mypartner.tm.authentification.controller.UserBranchController] 2023-09-12T17:39:18.452+01:00 WARN 1948 --- [nio-9000-exec-2] o.s.s.a.e.DenyAllPermissionEvaluator : Denying user permission 'add' on object my_ressource 2023-09-12T17:39:18.459+01:00 DEBUG 1948
--- [nio-9000-exec-2] horizationManagerBeforeMethodInterceptor : Failed to authorize ReflectiveMethodInvocation: public org.springframework.http.ResponseEntity com.mypartner.tm.authentification.controller.UserBranchController.addKeyClock(); target is of class [com.mypartner.tm.authentification.controller.UserBranchController] with authorization manager org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@181c058b and decision ExpressionAuthorizationDecision [granted=false, expressionAttribute=hasPermission('my_ressource', 'add')] 2023-09-12T17:39:18.470+01:00 TRACE 1948 --- [nio-9000-exec-2] o.s.s.w.a.ExceptionTranslationFilter : Sending JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@7e539329, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_default-roles-test, ROLE_offline_access, ROLE_test_role, ROLE_uma_authorization]] to access denied handler since access is denied
org.springframework.security.access.AccessDeniedException: Access Denied at org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.attemptAuthorization(AuthorizationManagerBeforeMethodInterceptor.java:257) at org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.invoke(AuthorizationManagerBeforeMethodInterceptor.java:198) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) at net.bull.javamelody.MonitoringSpringInterceptor.invoke(MonitoringSpringInterceptor.java:76) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:702) at com.mypartner.tm.authentification.controller.UserBranchController$$SpringCGLIB$$0.addKeyClock(<generated>) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.base/java.lang.reflect.Method.invoke(Method.java:568) at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:207) at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:152) at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884) at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797) at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081) at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974) at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1011) at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:537) at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:631) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:205) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149) at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
PS :
@PreAuthorize("hasRole('test_role')")
is working for me.
I think that i shoud extract authorization ---> permissions : {ressource and scope } from jwt token
hasPermission
is evaluated by a PermissionEvaluator
. The default (and only) implementation provided by Spring Security is DenyAllPermissionEvaluator
which will ... deny all (all expressions with hasPermission
result in 403).
You should provide with your own PermissionEvaluator
bean reading from the $.authorization.permissions
claim in your access tokens. Considering your @PreAuthorize("hasPermission('my_ressource', 'add')")
expression, maybe something like:
@Component
public class KeycloakAuthorizationPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
if(!(targetDomainObject instanceof String) || !(permission instanceof String)) {
return false;
}
final var permissions = getAuthenticationPermissions(authentication);
return Optional.ofNullable(permissions.get(targetDomainObject))
.map(KeycloakAuthorizationPermission::scopes)
.orElse(List.of())
.contains(permission);
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
final var permissions = getAuthenticationPermissions(authentication);
// FIXME: implement permission evaluation there too if you use this method in your expressions
return false;
}
@SuppressWarnings("unchecked")
static Map<String, KeycloakAuthorizationPermission> getAuthenticationPermissions(Authentication authentication) {
if(authentication instanceof JwtAuthenticationToken jwtAuth) {
final var authorization = (Map<String, Object>) jwtAuth.getToken().getClaims().getOrDefault("authorization", Map.of());
final var permissions = (List<Map<String, Object>>) authorization.getOrDefault("permissions", Map.of());
return permissions.stream().map(p -> {
final var rsid = Optional.ofNullable(p.get("rsid")).orElse("").toString();
final var rsname = Optional.ofNullable(p.get("rsname")).orElse("").toString();
final var scopes = (List<String>) Optional.ofNullable(p.get("scopes")).orElse(List.of());
return new KeycloakAuthorizationPermission(rsid, rsname, scopes);
}).collect(Collectors.toMap(KeycloakAuthorizationPermission::rsname, p -> p));
}
return Map.of();
}
static final record KeycloakAuthorizationPermission(String rsid, String rsname, List<String> scopes) {}
}
Few notes:
Bearer
header and most servers set a limit of 8kB for headers. This means that the number of entries in $.authorization.permissions
is actually limited (your system could stop working some day when you defined too many "resources" in Keycloak).JwtAuthenticationToken
seems a pretty bad idea. Using jwt.getClaim("preferred_username")
or jwt.getClaim("sub")
would probably be much better.add
permission on @GetMapping
looks pretty suspicious: you shouldn't be using a GET
when changing the sytem state (create => POST
, update => PUT
or PATCH
, delete => DELETE
). It is not just a matter of elegance and making you API coder-friendly: some security mechanisms rely on this (CSRF protection to start with).Bearer
header, I write a resource server for managing and exposing permissions. I then add a Keycloak "mapper" to call this resource server when building tokens and add the response as a private claim.PermissionEvaluator
implementation would read from a dedicated database. Of course, to get decent performance, I'd have to use some caching for DB requests, and if I had more than one resource server, I'd probably isolate permissions in a dedicated one (with some additional caching on REST calls to this permissions
service).In short, you're probably over complicating things and should think again about a way to solve your problem with good old Role Based Access Control (with just Keycloak "roles", not the "authorization" features), optionally augmented with evaluations on the resource itself. Something like:
@PutMapping("/posts/{post-id}")
@PreAuthorize("hasAuthority('moderator') || #post.authorName == authentication.name")
public ResponseEntity<Void> updatePost(@PathVariable("post-id") Post post, @RequestBody @Valid PostUpdateDto dto) {
...
}