skip to Main Content

I try to reproduce a https://www.baeldung.com/spring-boot-keycloak tutorial with some simplifications. Also my Keycloak server is on another machine. Config is also slightly changed due to deprecation of the antMatchers:

@Configuration
@EnableWebSecurity
class SecurityConfig {

    private final KeycloakLogoutHandler keycloakLogoutHandler;

    SecurityConfig(KeycloakLogoutHandler keycloakLogoutHandler) {
        this.keycloakLogoutHandler = keycloakLogoutHandler;
    }

    @Bean
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests
            .requestMatchers("/customers*")
            .hasRole("user")
            .anyRequest()
            .permitAll()
        );
        http.oauth2Login()
            .and()
            .logout()
            .addLogoutHandler(keycloakLogoutHandler)
            .logoutSuccessUrl("/");
        http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);

        return http.build();
    }
}

Properties:

spring.security.oauth2.client.registration.keycloak.client-id=***
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://192.168.254.1:8184/realms/***
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/***

Unprotected resource works, login works, but after login redirect I get 403. I tried to disable CORS and/or CSRF, the same.
Log:

2023-03-10T17:33:57.727+03:00 DEBUG 54908 --- [o-8080-Acceptor] o.apache.tomcat.util.threads.LimitLatch  : Counting up[http-nio-8080-Acceptor] latch=1
2023-03-10T17:33:57.728+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.coyote.http11.Http11InputBuffer      : Before fill(): parsingHeader: [true], parsingRequestLine: [true], parsingRequestLinePhase: [0], parsingRequestLineStart: [0], byteBuffer.position(): [0], byteBuffer.limit(): [0], end: [515]
2023-03-10T17:33:57.728+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.tomcat.util.net.SocketWrapperBase    : Socket: [org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@7ab77668:org.apache.tomcat.util.net.NioChannel@136f346d:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:47080]], Read from buffer: [0]
2023-03-10T17:33:57.729+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.net.NioEndpoint   : Socket: [org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@7ab77668:org.apache.tomcat.util.net.NioChannel@136f346d:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:47080]], Read direct from socket: [567]
2023-03-10T17:33:57.729+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.coyote.http11.Http11InputBuffer      : Received [GET /customers?continue HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: Idea-296f3fac=79e0126b-1518-4c2a-b4a3-1f158c95489b; JSESSIONID=A71AB029616D5092A00BE2B11E978123
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1

]
2023-03-10T17:33:57.729+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.t.util.http.Rfc6265CookieProcessor   : Cookies: Parsing b[]: Idea-296f3fac=79e0126b-1518-4c2a-b4a3-1f158c95489b; JSESSIONID=A71AB029616D5092A00BE2B11E978123
2023-03-10T17:33:57.729+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.catalina.connector.CoyoteAdapter     :  Requested cookie session id is A71AB029616D5092A00BE2B11E978123
2023-03-10T17:33:57.730+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.c.authenticator.AuthenticatorBase    : Security checking request GET /customers
2023-03-10T17:33:57.730+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.catalina.realm.RealmBase      :   No applicable constraints defined
2023-03-10T17:33:57.730+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.c.authenticator.AuthenticatorBase    : Not subject to any constraint
2023-03-10T17:33:57.730+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.s.security.web.FilterChainProxy        : Securing GET /customers?continue
2023-03-10T17:33:57.731+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.http.Parameters   : Set encoding to UTF-8
2023-03-10T17:33:57.731+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.http.Parameters   : Decoding query null UTF-8
2023-03-10T17:33:57.731+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.http.Parameters   : Start processing with input [continue]
2023-03-10T17:33:57.732+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.http.Parameters   : Parameter starting at position [0] and ending at position [8] with a value of [continue] was not followed by an '=' character
2023-03-10T17:33:57.732+03:00 DEBUG 54908 --- [nio-8080-exec-6] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.dvperv.kkauth.KkauthController#customers(Principal, Model)
2023-03-10T17:33:57.733+03:00 DEBUG 54908 --- [nio-8080-exec-6] w.c.HttpSessionSecurityContextRepository : Retrieved SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [user1], Granted Authorities: [[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]], User Attributes: [{at_hash=cOAUGbpbGKcvhnfekDHyEA, sub=09581f25-c22d-4192-89e0-f90af6c8757c, email_verified=false, iss=http://192.168.254.1:8184/realms/***, typ=ID, preferred_username=user1, given_name=, nonce=M-_DevVz-pT0R6tT9SDg6Wd3S9DBb72mxDH-gBBk0Cc, sid=b75bcd4a-c656-4572-b095-450a038e137d, aud=[***], acr=0, azp=***, auth_time=2023-03-10T14:08:13Z, exp=2023-03-10T14:36:51Z, session_state=b75bcd4a-c656-4572-b095-450a038e137d, family_name=, iat=2023-03-10T14:31:51Z, jti=4607af9f-8e7a-4dfe-8c70-5b8b3e5982e3}], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=432BECAABE9CFCC7EC117409C3E982E9], Granted Authorities=[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]]]
2023-03-10T17:33:57.735+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.coyote.http11.Http11InputBuffer      : Before fill(): parsingHeader: [true], parsingRequestLine: [true], parsingRequestLinePhase: [0], parsingRequestLineStart: [0], byteBuffer.position(): [0], byteBuffer.limit(): [0], end: [567]
2023-03-10T17:33:57.735+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.tomcat.util.net.SocketWrapperBase    : Socket: [org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@7ab77668:org.apache.tomcat.util.net.NioChannel@136f346d:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:47080]], Read from buffer: [0]
2023-03-10T17:33:57.735+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.net.NioEndpoint   : Socket: [org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@7ab77668:org.apache.tomcat.util.net.NioChannel@136f346d:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:47080]], Read direct from socket: [0]
2023-03-10T17:33:57.735+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.a.coyote.http11.Http11InputBuffer      : Received []
2023-03-10T17:33:57.736+03:00 DEBUG 54908 --- [nio-8080-exec-6] o.apache.coyote.http11.Http11Processor   : Socket: [org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@7ab77668:org.apache.tomcat.util.net.NioChannel@136f346d:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:47080]], Status in: [OPEN_READ], State out: [OPEN]
2023-03-10T17:33:57.736+03:00 DEBUG 54908 --- [nio-8080-exec-6] org.apache.tomcat.util.net.NioEndpoint   : Registered read interest for [org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@7ab77668:org.apache.tomcat.util.net.NioChannel@136f346d:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:47080]]
2023-03-10T17:33:59.794+03:00 DEBUG 54908 --- [alina-utility-2] o.apache.catalina.session.ManagerBase    : Start expire sessions StandardManager at 1678458839794 sessioncount 1
2023-03-10T17:33:59.794+03:00 DEBUG 54908 --- [alina-utility-2] o.apache.catalina.session.ManagerBase    : End expire sessions StandardManager processingTime 0 expired sessions: 0

2

Answers


  1. Chosen as BEST ANSWER

    I looked throw @ch4mp tutorials, official docs and got a converter from another tutorial. Final schematic solution without SSL and flexibility, but more short, is below.

    Props:

    spring.security.oauth2.resourceserver.jwt.issuer-uri=http://192.168.254.1:8184/realms/{realm}
    

    Converter:

    public class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        final Map<String, Object> realmAccess = (Map<String, Object>) jwt.getClaims().get("realm_access");
        return ((List<String>)realmAccess.get("roles")).stream()
                .map(roleName -> "ROLE_" + roleName) // prefix to map to a Spring Security "role"
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
    

    Config:

    @Configuration
    @EnableWebSecurity
    public class OAuth2ResourceServerSecurityConfiguration {
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                .authorizeHttpRequests((authorize) -> authorize
                    .requestMatchers(HttpMethod.GET, "/message/**").hasRole("user")
                    .requestMatchers(HttpMethod.POST, "/message/**").hasRole("user")
                    .anyRequest().authenticated()
                )
                .oauth2ResourceServer(httpSecurityOAuth2ResourceServerConfigurer -> httpSecurityOAuth2ResourceServerConfigurer
                    .jwt(jwtConfigurer -> jwtConfigurer
                        .jwtAuthenticationConverter(jwtAuthenticationConverter())
                    )
                );
            return http.build();
        }
        private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
            JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
            jwtConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
            return jwtConverter;
        }
    }
    

    Please be careful - role name is a case sensitive! USER != user Full code is here


  2. According to your logs, the user is granted with OIDC_USER when your conf expects ROLE_user. Changing hasRole("user") to hasAuthority("OIDC_USER") should fix your current issue, but you’ll probably face other ones because, in my opinion, the tutorial you link is bad. I advise you refer to "my" tutorials instead: https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials

    What I find mostly problematic in the tutorial you link is the confusion between client and resource server. In OAuth2, the authorization server, the resource server and the client are different actors with different responsibilities and security requirements.
    Spring provides with different boot starters for OAuth2 clients and OAuth2 resource servers for a reason.

    Sessions

    Resource server can (should?) usually be configured stateless (without sessions) which makes it insensible to CSRF attacks and also easier to scale and more fault tolerant. "State" is maintained by the access token: the security context of a request is built from this token, not from a session.

    Client with OAuth2 login need sessions (it couldn’t even complete an authorization-code flow without it) and as a consequence, must be protected against CSRF attacks. Requests received by a client are usually secured with a session cookie, not an access token, and the request security context is built from the session.

    Authentication

    User login and logout are the responsibility of the client, not of the resource server. It makes very little sense to have oauth2Login (or logout) in a SecurityFilterChain with oauth2ResourceServer.

    The resource server does not care about login, logout nor OAuth2 flows. All that matters to it is if a request is authorized (has an Authorization header) with a valid Bearer access token and if it should grant access to the requested resource based on the claims contained in that token (or introspected from it).

    The client is responsible for authorizing the requests it sends to resource servers. For that, it uses one of the OAuth2 flow to retrieve tokens from the authorization server. When a client needs to send a request on behalf of a user, it uses authorization-code flow (which requires user to login only if he does not have an opened session on the authorization server already), but can also use other flows depending on the context (refresh-token to renew an access token or client-credentials to issue a request without the context of a user).

    Security In Bealdung Tutorial

    As opposed to what is stated in the article you linked, the requests send with a browser to get Thymeleaf pages are secured with sessions, not JWTs. Check with your browser development tools, you’ll find session cookies but no Authorization header with a Bearer JWT.

    Also, the type of Authentication in the security context is specific to OAuth2 clients: look at your logs, it’s an OAuth2AuthenticationToken instance. For a resource server with a JWT decoder, you should have a JwtAuthenticationToken instance (unless you explicitly configure something else like I do in some of my tutorials).

    Logout Handler

    Keycloak complies with RP-Initiated Logout and spring-security-oauth2-client provides with OidcClientInitiatedLogoutSuccessHandler (OidcClientInitiatedServerLogoutSuccessHandler for webflux apps). Just use that one.

    Actions You Should Take

    Depending on your use case:

    • need a REST API? configure it as OAuth2 resource server (without login nor logout). Query it with Postman or any other OAuth2 REST client which can fetch tokens and authorize requests.
    • need a UI rendered on a Spring server (Thymeleaf, JSF, etc.)? configure it as OAuth2 (confidential) client (with login and logout).
    • need both in the same app? configure two security filter-chain beans, using securityMatcher to separate it as explained in my answer to this question: Use Keycloak Spring Adapter with Spring Boot 3
    • have a "rich" front-end in a browser (Angular, Vue, React, …) and are ok to have it see the OAuth2 tokens? Configure it as an OAuth2 public client and use a lib to handle OAuth2 flows for you (angular-auth-oidc-client is a sample for Angular)
    • have a "rich" front-end in a browser but prefer to hide the OAuth2 tokens from it? Have a look at the BFF pattern. An implementation option is spring-cloud-gateway configured as OAuth2 confidential client (with login and logout) and with the TokenRelay filter (I have a tutorial on that subject in the collection already linked above)
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search