Secure a Web Application with React, Spring Boot 3, Keycloak
1. Introduction
In this tutorial, we’ll learn how to secure a web application using React, Spring Boot 3, and Keycloak. We will start by setting up the Keycloak server and creating a new realm. Then, we will create a Spring Boot application and configure it to use Spring Security and Keycloak. Finally, create a React application and integrate Keycloak for authentication.
1.1 What is Keycloak
Keycloak is an open-source identity and access management solution that provides user authentication and authorization services to web applications and services. It offers features such as user federation, strong authentication, user management, fine-grained authorization, and more. Keycloak also provides single sign-on (SSO) functionality, which means that users only have to log in once to access multiple applications that use Keycloak.
Keycloak supports several SSO protocols such as OpenID Connect, SAML 2.0, and OAuth2.0.
1.1.1 OAuth 2.0
OAuth 2.0 is primarily an authorization protocol that allows applications to access resources on behalf of users with their consent. It defines access tokens, which are used to authorize requests to protected resources, such as APIs. It allows clients to specify the level of access they require through scopes.
1.1.2 OpenID Connect
OpenID Connect (OIDC) is primarily an authentication protocol. It builds on top of OAuth 2.0, adding an identity layer to it. OIDC allows users to log in using their preferred identity providers, such as social media accounts, without the need to create separate accounts for each application. While OAuth 2.0 focuses on authorization and access tokens, OpenID Connect extends it to include user authentication and identity information.
1.1.3 SAML 2.0 (Security Assertion Markup Language)
SAML 2.0 is an XML-based standard for exchanging authentication and authorization data between parties, especially in a web single sign-on (SSO) scenario. It enables identity federation, allowing users to access multiple applications and services with a single set of credentials.SAML defines a trust relationship between identity providers and service providers. It also supports single logout, ensuring users are logged out from all connected services when they log out from one.
1.2 OAuth 2.0 Authorization Flow
OAuth defines several authorization flows, each tailored to specific use cases and security requirements. These authorization flows allow clients (applications) to obtain access tokens, which are then used to access protected resources. Here are some of the common OAuth authorization flows.
1.2.1 Authorization Code Flow
This flow is ideal for web applications and services that contains client and server component. It involves the exchange of an authorization code for tokens.
How It Works:
- The user is redirected to the Keycloak login page.
- After a successful login, Keycloak provides an authorization code to the application.
- The application exchanges the code for access and ID tokens.
- Tokens are used to access protected resources.
1.2.2 Client Credentials Flow
This flow is suitable for server-to-server communication authentication.
How It Works:
- The application (client) provides its credentials (client id and client secret) to Keycloak.
- Keycloak issues an access token that can be used for authentication between the client and a resource server.
2. Setting Up Keycloak
To get started, first, we need to install Keycloak on our system. In this example, we are using Keycloak v22.
Begin by downloading the latest version of Keycloak from the official website. Once the download is complete, unzip the Keycloak archive. Next, navigate to the bin directory and run the following command:
./kc.sh start-dev
After starting the Keycloak instance, open your web browser and navigate to http://localhost:8080/
.
Under the Administration Console section, create an admin account with the username ‘admin’ and the password ‘admin’.
2.1 Creating a Realm
Realms in Keycloak define a space for users, roles, and applications.
- In the top left corner, click on the dropdown next to
Master
and then click onCreate Realm
. - For this tutorial, we named the realm as
FullStackApp
. - Click on the
Create
button.
2.2 Creating a Client
Once we have our realm, let’s start creating Keycloak clients.
In a typical full-stack application using Keycloak for authentication and authorization, it’s common to create separate clients for the front end (UI) and the back end (API). The frontend client, like a React Single Page Application (SPA), is often classified as a “public client” since it operates in a web browser and cannot securely store secrets. On the other hand, we usually configure the backend client as “bearer-only.” It doesn’t initiate authentication flows but expects every incoming request to present a valid bearer token (JWT). This token is then validated to ensure that the request is both authenticated and authorized.
- Navigate to the desired realm
FullStackApp
- Go to the
clients
section and click onCreate client
. - Choose
OpenID Connect
the client type. - Enter a unique
Client ID
, we named itfullstack-react-client
. This is for the front-end React applications. - Click on the
Next
button. - Turn
Off
the client authentication. When it is off, it is set to public access type, no client secret will be generated. - Select
Standard Flow
for the Authentication flow. - Uncheck the
Direct access grants
as this is not recommended in the latest OAuth security best practices. - Enter
http://localhost:3000/*
on the Valid redirect URIs. This is where after logging in successfully, keycloak will redirect to. - Click on the
Create
button. - Repeat steps 1 to 5 to create another client named
fullstack-springboot-client
. This is for the backend spring-boot application. - Turn On the client authentication. When it is on, it is set to confidential access type, client secret will be generated.
- Turn
On
theAuthorization
to enable fine-grained authorization support. It means the client can use OAuth 2.0 scopes to request specific permissions. - Select the
Service accounts roles
the Authentication flow. - Click on the
Create
button
2.3 Creating a Role
In Keycloak, roles are a way to define and manage permissions or access levels for users within the application or system. Roles help determine what actions or resources a user can access.
In this tutorial, we will be creating two roles:
- Navigate to
Clients
on the left. - Click
fullstack-springboot-client
on the client list. - Click on the
Roles
tab. - Click on the
Create role
button and name itdeveloper
. - Click on the
Save
button. - Repeat the steps 1 to 5 to create one more role name
admin
.
2.4 Creating a User
We’ll have to create two users, one with the developer role and the other with the admin role in Keycloak. These roles will define their permissions and access levels within the application with the client.
- Click on the “Users” section on the left sidebar.
- Click on
Add user
and fill in the user details. - Toggle the
Email verified
switch to enable email verification for the user. - Click on the
Create
button. - Go back to the
Users
section on the left sidebar and select the user you’ve just created. - Click on the
Credentials
tab for the selected user. - Set a password for the user. Mark it as temporary if the user should change it upon their next login.
- Click
Save
to save the user’s credentials. - Click on the
Role Mapping
tab for the selected user. - Click
Assign Role
and selectFilter by clients
to filter the list of roles by clients. - From the list of available roles on the left, select the
developer
role. - Click
Assign
to assign thedeveloper
role to the user. - Repeat the same set of steps to create another user. When assigning a role, select the
admin
role to assign administrative privileges to the user.
3. Integrating Keycloak to Spring Boot
This integration allows for secure access control and user authentication using Keycloak. We’ll be using the product-service
for illustration.
3.1 JWT Verification Process
When a request is made to the Spring Boot backend, the oauth2ResourceServer
configuration comes into play. This configuration is responsible for verifying the authenticity of JWT (JSON Web Token) tokens presented in the request’s Authorization header.
First and foremost, it checks if the incoming request contains a JWT token in the Authorization header. This token is crucial for verifying the user’s identity and access permissions. To handle JWT tokens, the oauth2ResourceServer
configuration uses the .jwt()
method. This method is responsible for handling JWT validation within Spring Security.
Spring Security takes the initial step of validating the digital signature of the JWT. Typically, a JWT is signed with a private key, and this crucial verification step ensures that the token remains unaltered during transmission. If the signature proves valid, it underscores the token’s integrity. Following this, Spring Security scrutinizes the expiration claim (exp) within the JWT. This claim defines the token’s validity period, and if it expires, the token becomes invalid.
The JWT contains an issuer claim iss
that specifies the entity that issued the token. The spring.security.oauth2.resourceserver.jwt.issuer-uri
configuration property we’ve set in the application.properties
defines the URI where the public key for token verification can be obtained.
This verification process ensures the trustworthiness of the token and allows the Spring Boot application to rely on it for user authentication and authorization.
3.2 Adding Necessary Dependencies
To get started, add the Keycloak Spring Boot Starter dependency to the project’s pom.xml
file. This dependency is essential for enabling OAuth 2.0 resource server functionality, which will help us secure our Spring Boot application using Keycloak.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
3.3 Configure Keycloak Properties
Next, we need to configure the application’s properties, specifically the issuer URI and the JSON Web Key (JWK) set URI. These properties are used for JWT token verification.
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/FullStackApp
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8080/realms/FullStackApp/protocol/openid-connect/certs
jwt.auth.converter.resource-id=fullstack-springboot-client
jwt.auth.converter.principal-attribute=preferred_username
3.4 Create a Custom JWT Authentication Converter
Keycloak typically nests roles inside the token.
To map these roles to Spring Security authorities, we might need a custom converter. The JwtAuthConverter
class is responsible for converting a JWT token into a Spring Security AbstractAuthenticationToken
. This conversion is essential for Spring Security to understand the roles and claims present in the JWT token and to make authorization decisions based on them.
@Component
public class JwtAuthConverter implements Converter<Jwt, AbstractAuthenticationToken>{
private static final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
@Value("${jwt.auth.converter.resource-id}")
private String resourceId;
@Value("${jwt.auth.converter.principal-attribute}")
private String principalAttribute;
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities =
Stream.concat(jwtGrantedAuthoritiesConverter.convert(jwt).stream(), extractResourceRoles(jwt).stream()).collect(Collectors.toSet());
String claimName = principalAttribute == null ? JwtClaimNames.SUB : principalAttribute;
return new JwtAuthenticationToken(jwt, authorities, jwt.getClaim(claimName));
}
private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
Map<String, Object> resource;
Collection<String> resourceRoles;
if (resourceAccess == null
|| (resource = (Map<String, Object>) resourceAccess.get(resourceId)) == null
|| (resourceRoles = (Collection<String>) resource.get("roles")) == null) {
return Set.of();
}
return resourceRoles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
}
}
The extractResourceRoles
() method is responsible for retrieving specific roles from the resource_access
claim within the JSON Web Token (JWT). These roles are associated with a particular client or resource named fullstack-springboot-client
.
Subsequently, it maps each of these roles to a Spring Security GrantedAuthority
. A GrantedAuthority
is an interface representing a role or permission in Spring Security.
Lastly, the method prefixes each role with ROLE_
. This is important because Spring Security typically expects role-based authorities to have this prefix.
3.5 Create a Security Configuration Class
Create a configuration class to configure security for the Spring Boot application. This class will ensure that only authenticated and authorized users can access specific endpoints.
@Configuration
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
@EnableWebSecurity
public class WebSecurityConfig {
private final JwtAuthConverter jwtAuthConverter;
public WebSecurityConfig(JwtAuthConverter jwtAuthConverter) {
this.jwtAuthConverter = jwtAuthConverter;
}
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/api/products/**")).hasAnyRole("developer", "admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter)))
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
corsConfiguration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
}
In the above code, we establish security rules on requests to the /api/products/**
path. We also specify the roles required for access.
3.6 Enable Method-Level Security
We can enable method-level security to secure individual methods using annotations. This provides fine-grained control over which roles or conditions are required to invoke a particular method. Here’s an example of how to use the @PreAuthorize
annotation to secure methods based on user roles.
@PreAuthorize
evaluates its expression before the method is invoked. If the expression evaluates to false
, the method will not be executed:
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('admin') or (hasRole('developer') and @productService.isProductOwner(#id, #jwt))")
public ResponseEntity<Map<String, Object>> deleteProduct(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
try {
productService.deleteProduct(id);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.NO_CONTENT.value());
response.put("message", PRODUCT_DELETED_SUCCESS);
return new ResponseEntity<>(response, HttpStatus.NO_CONTENT);
} catch (ResourceNotFoundException e) {
Map<String, Object> response = new HashMap<>();
response.put("status", "error");
response.put("statusCode", HttpStatus.NOT_FOUND.value());
response.put("message", PRODUCT_NOT_FOUND);
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
}
We check if the createdBy
field of the product matches the preferred_username
from the JWT. This way, the admin
role can delete any product and the developer
role can delete only the products created by himself.
@PostAuthorize
evaluates its expression after the method has returned. It can even check the returned value by accessing returnObject
in a security expression.
@PostAuthorize("hasRole('admin') or returnObject.createdBy == #createdBy")
@Override
public ProductDto getProductById(Long id, String createdBy) {
logger.info("Fetching product by ID {} only created by {}", id, createdBy);
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
return new ModelMapper().map(product, ProductDto.class);
}
4. Integrating Keycloak to React Web Apps
Start by creating a new React app using:
npx create-react-app product-ui
cd product-ui
4.1 Install Necessary Packages
Install the required packages for Keycloak integration, Axios for making API requests, and React Router for routing:
npm install --save keycloak-js axios react-router-dom
4.2 Initialize Keycloak
Create a configuration file named keycloak-config.js
in the src/config
directory. This file initializes Keycloak with the necessary parameters, including the Keycloak server URL, realm, and client ID:
import Keycloak from 'keycloak-js';
const keycloakConfig = {
url: 'http://localhost:8080/auth',
realm: 'FullStackApp',
clientId: 'fullstack-react-client',
};
const keycloak = new Keycloak(keycloakConfig);
export default keycloak;
4.3 Create KeycloakContext
Establish a KeycloakContext
by creating a file named KeycloakContext.js
in the src/context
directory. This context will allow components wrapped within its provider to access the Keycloak instance:
import { createContext } from "react";
const KeycloakContext = createContext();
export default KeycloakContext;
4.4 Create PrivateRoute Component
Create a new file named PrivateRoute.js
located in the src/route
directory, define a PrivateRoute
component that checks if a user is authenticated with Keycloak before rendering its children:
import React, { useContext } from "react";
import KeycloakContext from "../context/KeycloakContext";
function PrivateRoute({ children }) {
const keycloak = useContext(KeycloakContext);
const Login = () => {
keycloak.login();
};
return keycloak.authenticated ? children : <Login />;
}
export default PrivateRoute;
4.5 Modify the index.js
First, open the index.js
file located in the src
directory of your React project. Modify it to initialize Keycloak and wrap the entire app with the KeycloakContext.Provider
.
The onLoad: "check-sso"
option checks if the user is logged in when the page loads, but it does not force the user to log in. If the web app is not open for public usage, we can change the check-sso
to login-required
.
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import KeycloakContext from "./context/KeycloakContext";
import keycloak from "./config/keycloak-config";
keycloak.init({ onLoad: "check-sso", checkLoginIframe: false }).then((authenticated) => {
ReactDOM.render(
<React.StrictMode>
<KeycloakContext.Provider value={keycloak}>
<App />
</KeycloakContext.Provider>
</React.StrictMode>,
document.getElementById("root")
);
});
4.6 Modify the App.js
Next, we open the App.js
file located in the src
directory. Set up routing for the application, defining routes and including the PrivateRoute
component for securing specific routes:
const App = () => {
return (
<>
{/* Include navigation and other components here */}
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/products"
element={
<PrivateRoute>
<ProductList />
</PrivateRoute>
}
/>
</Routes>
</Router>
</>
);
};
export default App;
4.7 Create an API Interceptor
We can create a file named api.js
in the src/helpers
directory. This file sets up an Axios instance for making API requests. Moreover, it includes a function to set the JWT token as an authorization header:
import axios from "axios";
import { config } from "../config/config";
const api = axios.create({
baseURL: `${config.url.API_BASE_URL}`,
});
export const setAuthToken = (token) => {
api.defaults.headers["Authorization"] = `Bearer ${token}`;
};
export default api;
4.8 Create a Product Component
Finally, create a file named Product.js
in the src/components
directory. The ProductList
component fetches and displays a list of products. It utilizes the Keycloak token for authentication when making API requests:
const ProductList = () => {
const keycloak = useContext(KeycloakContext);
const [products, setProducts] = useState([]);
const navigate = useNavigate();
useEffect(() => {
if (keycloak && keycloak?.token) {
setAuthToken(keycloak?.token);
getProducts();
}
}, [keycloak]);
const getProducts = async () => {
try {
const response = await api.get("/api/products");
setProducts(response.data.data);
} catch (error) {
console.error("Error fetching products:", error);
}
};
const handleEdit = (productId) => {
// Handle the edit logic here
console.log("Editing product with ID:", productId);
};
const handleDelete = (productId) => {
// Handle the delete logic here
console.log("Deleting product with ID:", productId);
};
const handleAdd = () => {
navigate("/products/add");
console.log("Adding a new product");
};
return (
<div>
<h2>Products</h2>
<Button variant="contained" color="primary" onClick={handleAdd}>
Add New Product
</Button>
<Paper elevation={3} style={{ marginTop: "20px" }}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Price</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>{product.id}</TableCell>
<TableCell>{product.title}</TableCell>
<TableCell>${product.price}</TableCell>
<TableCell>
<Button
variant="outlined"
color="primary"
onClick={() => handleEdit(product.id)}
>
Edit
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => handleDelete(product.id)}
style={{ marginLeft: "10px" }}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</div>
);
};
export default ProductList;
5. Testing
To thoroughly test the Keycloak, Spring Boot, and React application integration, follow these steps:
Start the Keycloak, Spring Boot, and React applications in separate terminals or command prompts. Ensure they are all up and running. Open a web browser and navigate to http://localhost:3000
. We will initially see the Home page without requiring authentication.
Next, access the product page in your browser. If we’re not authenticated, the system automatically redirects us to the Keycloak login page.
Subsequently, we use the created user credentials with admin
role to log in. Once logged in, we should create a few products for testing purposes. Ensure we create at least one product with the created by
attribute set to developer
. This will be used to test method-level security.
Test Developer’s Credentials
Log out from the admin account, and then log in using the developer
credentials.
Let’s attempt to delete the product named apple
, which was created by the admin
user. We shouldn’t be able to delete it since it was not created by the developer
.
Next, try to delete the product named pear
. This should be successful because the developer
created it.
Let’s log in as admin
and retrieve the access token by opening the browser’s inspector tool. Navigate to the network tab, look for the token API request, and copy the access token from the response.
Next, use a tool like Postman, to test the API endpoint with the @PostAuthorize guard by sending a GET request to localhost:8081/api/products/get/1
. As an admin, we should be able to access details of any product.
Now, let’s log out and then log in as developer
. Update the token in the Authorization field in Postman. Send a GET request to the endpoint localhost:8081/api/products/get/1
.
Since we’re logged in as developer
and we’re trying to access a product created by the developer, we should receive the product details without any issues.
Finally, attempt to access the endpoint localhost:8081/api/products/get/2
. We should receive an Access Denied!
response because the developer
is trying to access a product not created by him.
6. Conclusion
In this tutorial, we’ve demonstrated the entire process of setting up a Keycloak server, integrating it with a Spring Boot backend, and securing APIs, along with a frontend that authenticates users based on their roles. Full source code is available on GitHub.
Share this content:
Leave a Comment