DEV Community

Cover image for Step-up authentication in Keycloak + Spring Boot
Ulrich VACHON
Ulrich VACHON

Posted on

Step-up authentication in Keycloak + Spring Boot

Today I will show you how to set up a stepped-up authentication based on Keycloak and Spring Boot.

Basically in a classical application, the security is often based on the login/password, API key, token, roles, URI pattern, etc... Sometimes one or all of these features are coupled together to implement a security policy worthy of the name.

When you need to enhance the security for a particular action it could be difficult to decide which security feature employing to accomplish that. The OAuth2 specification proposes a simple solution named Step-up Authentication Challenge Protocol, link of the RFC is here.

In this paper I will show you how I implemented this feature in a Spring Boot app coupled at Keycloak.

💡A Docker Compose stack is ready to run on my github.com/ulrich space available in the repository : stepup-authentication-with-keycloak-and-spring-boot.

Wear your seat belt and follow me.

Create the backend

Add a simple Spring boot app with the convenience dependencies.

By the way we need to enroll the following dependencies:

  • Spring Web
  • Spring Security
  • OAuth2 Client
  • OAuth2 Server
  • Lombok
  • Testcontainers

These followings for this introduction will use Java 17 and Maven (of course).

The minimum implementation

The reader should accept that this app doesn't fill the best practices expected for the Production environment.

Plug the backend to Keycloak

Obviously, we will use Docker and Compose to lead this tutorial.

Create the Docker Compose stack

  • Add the Dockerfile for the backend app.

Nothing to new for this Dockerfile, we just need to separate the build stage from the runtime stage.

# Build Container
FROM maven:3.8.5-openjdk-17-slim as build

WORKDIR /app/

RUN apt-get update && \
      apt-get install -y --no-install-recommends

COPY pom.xml .
COPY /src src

RUN mvn clean package -DskipTests

# Run Container
FROM amazoncorretto:17

COPY --from=build /app/target/**.jar app.jar
Enter fullscreen mode Exit fullscreen mode
  • Add the Docker Compose services

Like the previous Docker configuration the Compose file is a-by-the-book example used to run the services:

services:
  keycloak:
    image: quay.io/keycloak/keycloak:21.0
    container_name: stepup-keycloak
    hostname: stepup-keycloak
    ports:
      - 9080:8080
    networks:
      - keycloak-net
    volumes:
      - ./keycloak/config:/opt/keycloak/data/import
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_HOSTNAME: localhost
    entrypoint: [ "/opt/keycloak/bin/kc.sh", "start-dev", "--import-realm" ]
  backend:
    depends_on:
      - keycloak
    build: ./backend
    container_name: stepup-backend
    hostname: stepup-backend
    ports:
      - 8080:8080
    networks:
      - keycloak-net
    entrypoint: [ "java", "-Xms512m", "-Xmx1g", "-jar", "app.jar", "--debug" ]

networks:
  keycloak-net: { }
Enter fullscreen mode Exit fullscreen mode
  • Resolve the Keycloak hostname in your local hosts file for Keycloak
❯ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' stepup-keycloak
172.22.0.2
❯ cat /etc/hosts
172.22.0.2  stepup-keycloak
Enter fullscreen mode Exit fullscreen mode
  • Resolve the Keycloak hostname in your local hosts file for the backend
❯ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' stepup-backend
172.22.0.3
❯ cat /etc/hosts
172.22.0.3  stepup-backend
Enter fullscreen mode Exit fullscreen mode

Create the Keycloak the stepped-up configuration realm

The detailed configuration can be found in this the stepup-realm.json
file, but we can highlight some points:

  • The client user-client is mandated for delivering an access token to the user,
  • The frontend-url property has necessary to be set to http://172.30.0.2:8080 for the issuer claim value.

Create the OAuth2 security layer

Backend side we can use a simple but an effective configuration where the settings are spread both in
the application.yml and SecurityConfiguration.java files. No more needed for the time.

The following shows the main content of the files:

  • In the application.yml file we need to declare to Spring Security where the OIDC configuration can be found.
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          client-id: user-client
          issuer-uri: http://stepup-keycloak:8080/realms/stepup
          jwk-set-uri: http://stepup-keycloak:8080/realms/stepup/protocol/openid-connect/certs
Enter fullscreen mode Exit fullscreen mode
  • In the SecurityConfiguration.java file we need to describe a basic configuration indicates how to handle the request.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfiguration {

    // ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors();
        http.csrf().disable();
        http.authorizeHttpRequests()
                .anyRequest().authenticated()
                .and()
                .oauth2ResourceServer().jwt();

        return http.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Test the security configuration

At this point we can run the Compose stack:

❯ docker compose up --build
Enter fullscreen mode Exit fullscreen mode

You can note that the client secret was generated during this documentation and located in the stepup-realm.json file.

Open a terminal and test the configuration with curl.

  • Get a valid access token from Keycloak
TOKEN=`curl -XPOST 'http://stepup-keycloak:8080/realms/stepup/protocol/openid-connect/token' \
        --header 'Content-Type: application/x-www-form-urlencoded' \
        --data-urlencode 'client_secret=6AurffbSrQ4yaGOl2TE7nvdveKwM2CB0' \
        --data-urlencode 'client_id=user-client' \
        --data-urlencode 'grant_type=password' \
        --data-urlencode 'username=ulrich' \
        --data-urlencode 'password=ulrich' | jq -r .access_token`
Enter fullscreen mode Exit fullscreen mode
  • Test an authenticated access
❯ curl -i "http://stepup-backend:8080/user?email=foo@gmail.com" \
        --header "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode
  • The expected result from the previous request should be:
HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 10 Apr 2023 16:35:48 GMT

{"email":"foo@gmail.com"}                                         
Enter fullscreen mode Exit fullscreen mode

If we omit the Authorization header we should have this kind of response:

HTTP/1.1 401 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: JSESSIONID=21C630C8B2B38333DC81029DEBC2818E; Path=/; HttpOnly
WWW-Authenticate: Bearer
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Mon, 10 Apr 2023 19:54:26 GMT
Enter fullscreen mode Exit fullscreen mode

The step-up implementation from app side

It's time to add more securities in our app by using a step-up mechanism involves the add of a request interceptor. The
request interceptor will should be triggered if any resource is marked by @StepupAuthentication annotation.

The step-up components

No caveat in the current implementation based on Spring, we just need to develop three components:

  • StepupAuthentication.java is the class that we will put on a stepped-up resource,
  • StepupAuthenticationInterceptor.java is the implementation of the step-up mechanism. It is responsible for the ACR validation by opening the jwt-token and check if the signed token contains the expected LOA value,
  • StepupAuthenticationInterceptorConfig.java is the underlying code responsible to declare own interceptor.

The resource security enhancement

All is ready to enhance the security and for the test we will put the @StepupAuthentication on
the UserResource.delete resource.

Just like that:


@RestController
@RequestMapping(
        value = "user",
        produces = {"application/json"})
public class UserResource {

    private final UserRepository userRepository;

    @Autowired
    public UserResource(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // ...

    @DeleteMapping
    @StepupAuthentication
    public ResponseEntity<Void> delete(@RequestParam String email) {
        userRepository.deleteUserByEmail(email);

        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}
Enter fullscreen mode Exit fullscreen mode

The step-up implementation from Keycloak side

The Keycloak instance is ready to test in this tutorial, but we will brief take a look in the authentication flow where all work.

The overridden Browser flow

The basic Browser flow doesn't allow to build a stepped-up flow, so I created the browser-stepup-flow cloned from the browser-flow. More details are available in the Keycloak configuration but just note the two flows, the first one for the default flow and the second for the step-up flow.

The key here is that the user pass through with success the Stepup authentication flow the ACR field will contain the
expected LOA value (2 for my example).

Remember, if the stepup authentication interceptor validates the ACR code, it will let the execution continue.

keycloak

Test the step-up configuration

Open a terminal and test the configuration.

  • Get a valid access token from Keycloak
TOKEN=`curl -XPOST 'http://stepup-keycloak:8080/realms/stepup/protocol/openid-connect/token' \
        --header 'Content-Type: application/x-www-form-urlencoded' \
        --data-urlencode 'client_secret=6AurffbSrQ4yaGOl2TE7nvdveKwM2CB0' \
        --data-urlencode 'client_id=user-client' \
        --data-urlencode 'grant_type=password' \
        --data-urlencode 'username=ulrich' \
        --data-urlencode 'password=ulrich' | jq -r .access_token`
Enter fullscreen mode Exit fullscreen mode

Step 1

  • Call the delete user resource
❯ curl -i -XDELETE "http://stepup-backend:8080/user?email=foo@gmail.com" \
        --header "Referer: http://localhost:8080" \
        --header "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode
  • The expected result from the previous request should be:
HTTP/1.1 412 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 253
Date: Fri, 14 Apr 2023 12:02:51 GMT

{"status":"PRECONDITION_FAILED","authenticationUrl":"http://stepup-keycloak:8080/realms/stepup/protocol/openid-connect/auth?client_id=user-client&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2F&response_type=code&response_mode=query&scope=openid&acr_values=2"}
Enter fullscreen mode Exit fullscreen mode

Explanations:

  • This response status available in the status field means that the LOA (Level Of Access) value stored in ACR claim field was not in the good level,
  • The step-up mechanism built a validation URL for the end user available in the authenticationUrl field.

Step 2

To continue the authentication flow, the user needs to follow the given URL and provides to good password.

stepup-login

If the password form is successfully passed, the Keycloak forwards the flow by using the given URI located in the redirect_uri URL parameter.

authorization-code

Step 3

At this moment, the app needs to exchange the authorization-code (located in the response header Location) for the access-token. This is the final step where the app will receive the access-token filled with the expected ACR claim value (2).

Proceed to get the new access-token.

TOKEN=`curl -XPOST 'http://stepup-keycloak:8080/realms/stepup/protocol/openid-connect/token' \
        --header 'Content-Type: application/x-www-form-urlencoded' \
        --data-urlencode 'grant_type=authorization_code' \
        --data-urlencode 'client_id=user-client' \
        --data-urlencode 'client_secret=6AurffbSrQ4yaGOl2TE7nvdveKwM2CB0' \
        --data-urlencode 'code=3125c7c3-b2a9-4c20-a2dd-e5bb14b16b6b.e4702561-58b5-44b2-b493-b30242eb1c71.d4b4ea5c-b02b-46d6-8859-5a0f0ed32287' \
        --data-urlencode 'redirect_uri=http://localhost:8080/' | jq -r .access_token`
Enter fullscreen mode Exit fullscreen mode
  • The expected result from the previous request should be a new access-token with the ACR claim field updated:

acr-updated

Step 4

It's now possible to call the protected resources with the good level of accreditation.

❯ curl -i -XDELETE "http://stepup-backend:8080/user?email=foo@gmail.com" \
        --header "Referer: http://localhost:8080" \
        --header "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode
  • The expected result from the previous request should be:
HTTP/1.1 204 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Date: Fri, 14 Apr 2023 12:34:41 GMT
Enter fullscreen mode Exit fullscreen mode

The status code of the delete user action is 204.

Conclusion

Finally the set up of a stepped-up authentication solution is relatively easy with some knowledge of how to deal with ACR/LOA notions and obviously if you use a powerful tool like Spring. But you can without troubles employ another mechanism like Servlet Filter or whatever technologies even outside of Java. Moreover you can use a more powerful checker like OTP or else.

Have a good day.

Image par jacqueline macou de Pixabay

Top comments (1)

Collapse
 
vdelitz profile image
vdelitz

Great article, learned a lot!