jpganz18
jpganz18

Reputation: 5858

Enable role authentication with spring boot (security) and keycloak?

I am trying to do a simple thing.

Want to make a request to a single endpoint and send a bearer token (from a client), I want this token to be validated and depending on the role assigned on keycloak accept/deny request on my endpoint.

I followed many tutorials and even books but most of all them I simply dont understand.

Followed this to setup my keycloak info (realm, role, user) https://medium.com/@bcarunmail/securing-rest-api-using-keycloak-and-spring-oauth2-6ddf3a1efcc2

So,

I basically set up my keycloak with a client, a user with a specific role "user" and configured it like this:

@Configuration
@KeycloakConfiguration
//@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class SecurityConf extends KeycloakWebSecurityConfigurerAdapter
{
    /**
     * Registers the KeycloakAuthenticationProvider with the authentication manager.
     */
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(keycloakAuthenticationProvider());
    }

    /**
     * Defines the session authentication strategy.
     */
    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

    @Bean
    public KeycloakSpringBootConfigResolver KeycloakConfigResolver() {
        return new KeycloakSpringBootConfigResolver();
    }

    @Bean
    public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean(
            KeycloakAuthenticationProcessingFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean(
            KeycloakPreAuthActionsFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception
    {
        super.configure(http);
        http
                .authorizeRequests()
                .antMatchers("/user/*").hasRole("admin")
                .antMatchers("/admin*").hasRole("user")

    }
}

I dont understand why at many tutorials I see this(as the last rule):

.anyRequest().permitAll();

Basically when I set that I have no security, I can call the endpoints without a bearer token.

But when I add this as last rule

 .anyRequest().denyAll();

I always get a 403.

Debbugging I found this:

Request is to process authentication

f.KeycloakAuthenticationProcessingFilter : Attempting Keycloak authentication
o.k.a.BearerTokenRequestAuthenticator    : Found [1] values in authorization header, selecting the first value for Bearer.
o.k.a.BearerTokenRequestAuthenticator    : Verifying access_token
o.k.a.BearerTokenRequestAuthenticator    : successful authorized
a.s.a.SpringSecurityRequestAuthenticator : Completing bearer authentication. Bearer roles: [] 
o.k.adapters.RequestAuthenticator        : User 'testuser' invoking 'http://localhost:9090/api/user/123' on client 'users'
o.k.adapters.RequestAuthenticator        : Bearer AUTHENTICATED
f.KeycloakAuthenticationProcessingFilter : Auth outcome: AUTHENTICATED
o.s.s.authentication.ProviderManager     : Authentication attempt using org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider
o.s.s.core.session.SessionRegistryImpl   : Registering session 5B871A0E2AF55B70DC8E3B7436D79333, for principal testuser
f.KeycloakAuthenticationProcessingFilter : Authentication success using bearer token/basic authentication. Updating SecurityContextHolder to contain: org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken@355f68d6: Principal: testuser; Credentials: [PROTECTED]; Authenticated: true; Details: org.keycloak.adapters.springsecurity.account.SimpleKeycloakAccount@5d7a32a9; Not granted any authorities
[nio-9090-exec-3] o.s.security.web.FilterChainProxy        : /api/user/123 at position 8 of 15 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'
nio-9090-exec-3] o.s.s.w.s.DefaultSavedRequest            : pathInfo: both null (property equals)
[nio-9090-exec-3] o.s.s.w.s.DefaultSavedRequest            : queryString: both null (property equals)

Seems like I get no bearer roles ...

My dependencies:

        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-spring-boot-starter</artifactId>
            <version>6.0.1</version>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-spring-security-adapter</artifactId>
            <version>6.0.1</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

My problem?

I request an access token sending:

client_id -> my client from keycloak
username -> my user from keycloak
password -> my password from keycloak
grant_type -> password
client_secret -> from keycloak

I get a token and then I use to request to my app endoint. My requests are always valid no matter what endpoint I use (the one with role user or with role admin).

At my properties I have something like this:

keycloak:
  auth-server-url: http://localhost:8080/auth/
  resource: users-api
  credentials:
    secret : my-secret
  use-resource-role-mappings : true
  realm: my-realm
  realmKey:  my-key
  public-client: true
  principal-attribute: preferred_username
  bearer-only: true

Any idea how to actually enabling the roles in this case?

Do I have to configure a client to use JWT? any ideas?

I also added the annotations on my endpoint

@Secured("admin")
@PreAuthorize("hasAnyAuthority('admin')")

but seems they dont do anything...

-- EDIT --

After fixed the url to match the resource I still get 403.

"realm_access": {
    "roles": [
      "offline_access",
      "admin",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },

Is it somehow related the resource_access with my problem?

Upvotes: 23

Views: 30242

Answers (6)

ch4mp
ch4mp

Reputation: 12629

2022 update

Keycloak adapters for Spring are deprecated. Don't use it. Use spring-boot-starter-oauth2-resource-server instead.

Easy solution

With this Boot starter I wrote to complement spring-boot-starter-oauth2-resource-server, configuration can be as simple as:

@EnableMethodSecurity
@Configuration
public static class SecurityConfig {
    // You can even remove this bean if using @PreAuthorize on @RestController methods
    @Bean
    ExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() {
        return (ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) -> registry
            .antMatchers("/api/user/**").hasAuthority("USER")
            .antMatchers("/api/admin/**").hasAuthority("ADMIN")
            .anyRequest().authenticated();
        }
    }
}
com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${oauth2-issuer}
          authorities:
          - path: $.realm_access.roles
          - path: $.resource_access.employee-service.roles
          - path: $.resource_access.other-client.roles
        cors:
        - path: /api/**
        resourceserver:
          permit-all:
          - "/api/public/**"
          - "/actuator/health/readiness"
          - "/actuator/health/liveness"

Spring only solution

To do the same with spring-boot-starter-oauth2-resource-server only, there is quite some Java conf to write:

@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {

    public interface Jwt2AuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
    }

    @SuppressWarnings("unchecked")
    @Bean
    public Jwt2AuthoritiesConverter authoritiesConverter() {
        // This is a converter for roles as embedded in the JWT by a Keycloak server
        // Roles are taken from both realm_access.roles & resource_access.{client}.roles
        return jwt -> {
            final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
            final var realmRoles = (Collection<String>) realmAccess.getOrDefault("roles", List.of());

            final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
            // We assume here you have "employee-service" (as in the tutorial referenced in the question) and "other-client" clients configured with "client roles" mapper in Keycloak
            final var confidentialClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("employee-service", Map.of());
            final var confidentialClientRoles = (Collection<String>) confidentialClientAccess.getOrDefault("roles", List.of());
            final var publicClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("other-client", Map.of());
            final var publicClientRoles = (Collection<String>) publicClientAccess.getOrDefault("roles", List.of());

            return Stream.concat(realmRoles.stream(), Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
                    .map(SimpleGrantedAuthority::new).toList();
        };
    }

    public interface Jwt2AuthenticationConverter extends Converter<Jwt, AbstractAuthenticationToken> {
    }

    @Bean
    public Jwt2AuthenticationConverter authenticationConverter(Jwt2AuthoritiesConverter authoritiesConverter) {
        return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, Jwt2AuthenticationConverter authenticationConverter, ServerProperties serverProperties)
            throws Exception {

        // Enable OAuth2 with custom authorities mapping
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);

        // Enable anonymous
        http.anonymous();

        // Enable and configure CORS
        http.cors().configurationSource(corsConfigurationSource());

        // State-less session (state in access-token only)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Enable CSRF with cookie repo because of state-less session-management
        http.csrf().disable();

        // Return 401 (unauthorized) instead of 403 (redirect to login) when authorization is missing or invalid
        http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
            response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        });

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        } else {
            http.requiresChannel().anyRequest().requiresInsecure();
        }

        // Route security: authenticated to all routes but actuator and Swagger-UI
        // @formatter:off
        http.authorizeRequests()
            .antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
            .antMatchers("/api/user/**").hasAuthority("USER")
            .antMatchers("/api/admin/**").hasAuthority("ADMIN")
            .anyRequest().authenticated();
        // @formatter:on

        return http.build();
    }

    private CorsConfigurationSource corsConfigurationSource() {
        // Very permissive CORS config...
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));

        // Limited to API routes (neither actuator nor Swagger-UI)
        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);

        return source;
    }
}
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://localhost:8443/realms/master

Important notes

Both configuration above make no transformation to keycloak roles (case unchanged, no ROLE_ prefix), reason for using hasAuthority(...) instead of hasRole(...).

Also only roles defined at following levels are considered:

  • "realm"
  • "employee-service" client (has defined in the tutorial referenced in the question
  • "other-client" (just to demo that any other arbitrary client(s) can be used)

Upvotes: 12

emrekgn
emrekgn

Reputation: 682

I know this is an old post but I'm just writing this for future reference in case anyone else has the same problem.

If you look into the logs, Keycloak successfully authenticated the access token but there are not any granted authorities. That's why Spring doesn't authorize the request and you get HTTP 403 Forbidden:

f.KeycloakAuthenticationProcessingFilter : Authentication success using bearer token/basic authentication. Updating SecurityContextHolder to contain: org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken@355f68d6: Principal: testuser; Credentials: [PROTECTED]; Authenticated: true; Details: org.keycloak.adapters.springsecurity.account.SimpleKeycloakAccount@5d7a32a9; Not granted any authorities

That's because Keycloak adapter is configured to use resource (i.e. client-level) role mappings instead of realm-level role mappings:

use-resource-role-mappings: If set to true, the adapter will look inside the token for application-level role mappings for the user. If false, it will look at the realm level for user role mappings. This is OPTIONAL. The default value is false.

Here is the link about adapter configurations.

So, if you want to get authorized via realm roles, properties should be like this:

keycloak:
  auth-server-url: http://localhost:8080/auth/
  resource: users-api
  credentials:
    secret : my-secret
  use-resource-role-mappings : false
  realm: my-realm
  realmKey:  my-key
  public-client: true
  principal-attribute: preferred_username
  bearer-only: true

Note: If you want to use both realm-level and client-level role mappings, then you should override KeycloakAuthenticationProvider.authenticate() method to provide the necessary roles by combining them yourself.

Upvotes: 6

Snorky35
Snorky35

Reputation: 425

Late answer, but hope it will help other facing the same issue. I was facing the exact same problem as you, and for me, in the configuration class, i has to change the default keycloakAuthenticationProvider by setting a granted authority mapper (the @Override method is just for debugging):

@Bean
public KeycloakSpringBootConfigResolver KeycloakConfigResolver() {
  KeycloakAuthenticationProvider keycloakAuthenticationProvider = new KeycloakAuthenticationProvider() {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
      System.out.println("===========+>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> authenticate ");
      KeycloakAuthenticationToken token = (KeycloakAuthenticationToken) authentication;
      for (String role : token.getAccount().getRoles()) {
        System.out.println("===========+>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Role : " + role);
      }

      return super.authenticate(authentication);
    }
  };
  keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
  auth.authenticationProvider(keycloakAuthenticationProvider);
}

}

Upvotes: 1

bamandine
bamandine

Reputation: 139

  1. Do you try without the @Configuration ? I think you only need @KeycloakConfiguration annotation on your SecurityConf class.

  2. Do your antMatchers respect case sensitivity ?

 http
    .csrf().disable()
    .authorizeRequests()
    .antMatchers("/api/user/**").hasRole("user")
    .antMatchers("/api/admin/**").hasRole("admin")
    .anyRequest().authenticated();
  1. Please also try this configuration, to remove the ROLE_* conventions defined by Java :
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) {
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        // SimpleAuthorityMapper is used to remove the ROLE_* conventions defined by Java so
        // we can use only admin or user instead of ROLE_ADMIN and ROLE_USER
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
        auth.authenticationProvider(keycloakAuthenticationProvider);
    }
  1. If all your endpoints have the same logics, the security config should be enough, you don't need others annotations. But if you have another endpoint with the admin role, which is not in your "/api/admin" controller, you can try :
@PreAuthorize("hasRole('admin')")

Upvotes: 3

stacker
stacker

Reputation: 4475

in Debug stack: I see you are calling /api/user/123 and in your security configs you are securing /user/* which is not the same, change your security to:

.antMatchers("/api/user/*").hasRole("user")
                .antMatchers("/api/admin*").hasRole("admin")

P.S: you don't need to register KeycloakAuthenticationProcessingFilter and KeycloakPreAuthActionsFilter

Upvotes: 8

Romil Patel
Romil Patel

Reputation: 13727

permitAll:

Whenever you want to allow any request to access the particular resource/URL you can use permitAll. For example, the Login URL should be accessible to everyone.

denyAll:

Whenever you want to block the access of particular URL no matter from where the request comes or who is making request(ADMIN)

You also have miss-match with URL and Role (you are granting URL with admin to USER and vise-versa). (It's a good practice to use the role as ROLE_ADMIN or ADMIN or USER) Form your stack I can see Not granted any authorities so please recheck the code with authorities

 http
         .csrf().disable()
         .authorizeRequests()
         .antMatchers("/api/user/**").hasRole("ADMIN")
         .antMatchers("/api/admin/**").hasRole("USER")
         .anyRequest().authenticated();

Upvotes: 3

Related Questions