Today, I’ll show you how to create a basic CRUD app with Spring Boot and React. In this demo, I’ll use the OAuth 2.0 Authorization Code flow and package the React app in the Spring Boot app for production. At the same time, I’ll show you how to keep React’s productive workflow for developing locally.
Prerequisites:
Tip
|
The brackets at the end of some steps indicate the IntelliJ Live Templates to use. You can find the template definitions at mraible/idea-live-templates. |
- Create an API app with Spring Boot
- Create a React UI with Create React App
- Call your Spring Boot API and display the results
- Build a React
GroupList
component - Add a React
GroupEdit
component - Add Authentication with Auth0
- Add Authentication with Okta
- Configure Spring Security for React and user identity
- Modify React to handle CSRF and be identity-aware
- Configure Maven to build and package React with Spring Boot
- Giddyup with React and Spring Boot!
-
Navigate to start.spring.io and make the following selections:
-
Project:
Maven Project
-
Group:
com.okta.developer
-
Artifact:
jugtours
-
Dependencies:
JPA
,H2
,Web
,Lombok
-
-
Click Generate Project, expand
jugtours.zip
after downloading, and open the project in your favorite IDE.
-
Create a
src/main/java/com/okta/developer/jugtours/model
directory and aGroup.java
class in it. [sbr-group
]Group.java
package com.okta.developer.jugtours.model; import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.RequiredArgsConstructor; import jakarta.persistence.*; import java.util.Set; @Data @NoArgsConstructor @RequiredArgsConstructor @Entity @Table(name = "user_group") public class Group { @Id @GeneratedValue private Long id; @NonNull private String name; private String address; private String city; private String stateOrProvince; private String country; private String postalCode; @ManyToOne(cascade=CascadeType.PERSIST) private User user; @OneToMany(fetch = FetchType.EAGER, cascade=CascadeType.ALL) private Set<Event> events; }
-
Create an
Event.java
class in the same package. [sbr-event
]Event.java
package com.okta.developer.jugtours.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.ManyToMany; import java.time.Instant; import java.util.Set; @Data @NoArgsConstructor @AllArgsConstructor @Builder @Entity public class Event { @Id @GeneratedValue private Long id; private Instant date; private String title; private String description; @ManyToMany private Set<User> attendees; }
-
And a
User.java
class. [sbr-user
]User.java
package com.okta.developer.jugtours.model; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; @Data @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "users") public class User { @Id private String id; private String name; private String email; }
-
Create a
GroupRepository.java
to manage the group entity. [sbr-group-repo
]GroupRepository.java
package com.okta.developer.jugtours.model; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface GroupRepository extends JpaRepository<Group, Long> { Group findByName(String name); }
-
To load some default data, create an
Initializer.java
class in thecom.okta.developer.jugtours
package. [sbr-init
]Initializer.java
package com.okta.developer.jugtours; import com.okta.developer.jugtours.model.Event; import com.okta.developer.jugtours.model.Group; import com.okta.developer.jugtours.model.GroupRepository; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import java.time.Instant; import java.util.Collections; import java.util.stream.Stream; @Component class Initializer implements CommandLineRunner { private final GroupRepository repository; public Initializer(GroupRepository repository) { this.repository = repository; } @Override public void run(String... strings) { Stream.of("Seattle JUG", "Denver JUG", "Dublin JUG", "London JUG").forEach(name -> repository.save(new Group(name)) ); Group djug = repository.findByName("Seattle JUG"); Event e = Event.builder().title("Micro Frontends for Java Developers") .description("JHipster now has microfrontend support!") .date(Instant.parse("2022-09-13T17:00:00.000Z")) .build(); djug.setEvents(Collections.singleton(e)); repository.save(djug); repository.findAll().forEach(System.out::println); } }
TipIf your IDE has issues with Event.builder()
, you need to turn on annotation processing and/or install the Lombok plugin. I had to uninstall/reinstall the Lombok plugin in IntelliJ IDEA to get things to work. -
Start your app with
mvn spring-boot:run
and you should see groups and events being created. -
Add a
GroupController.java
class (insrc/main/java/…/jugtours/web
) that allows you to CRUD groups. [sbr-group-controller
]GroupController.java
package com.okta.developer.jugtours.web; import com.okta.developer.jugtours.model.Group; import com.okta.developer.jugtours.model.GroupRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; import java.net.URI; import java.net.URISyntaxException; import java.util.Collection; import java.util.Optional; @RestController @RequestMapping("/api") class GroupController { private final Logger log = LoggerFactory.getLogger(GroupController.class); private GroupRepository groupRepository; public GroupController(GroupRepository groupRepository) { this.groupRepository = groupRepository; } @GetMapping("/groups") Collection<Group> groups() { return groupRepository.findAll(); } @GetMapping("/group/{id}") ResponseEntity<?> getGroup(@PathVariable Long id) { Optional<Group> group = groupRepository.findById(id); return group.map(response -> ResponseEntity.ok().body(response)) .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); } @PostMapping("/group") ResponseEntity<Group> createGroup(@Valid @RequestBody Group group) throws URISyntaxException { log.info("Request to create group: {}", group); Group result = groupRepository.save(group); return ResponseEntity.created(new URI("/api/group/" + result.getId())) .body(result); } @PutMapping("/group/{id}") ResponseEntity<Group> updateGroup(@Valid @RequestBody Group group) { log.info("Request to update group: {}", group); Group result = groupRepository.save(group); return ResponseEntity.ok().body(result); } @DeleteMapping("/group/{id}") public ResponseEntity<?> deleteGroup(@PathVariable Long id) { log.info("Request to delete group: {}", id); groupRepository.deleteById(id); return ResponseEntity.ok().build(); } }
-
Add the following dependency to your
pom.xml
to fix compilation errors:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
-
Restart the app and hit
http://localhost:8080/api/groups
with HTTPie and you should see the list of groups.http :8080/api/groups
-
You can create, read, update, and delete groups with the following commands.
http POST :8080/api/group name='Utah JUG' city='Salt Lake City' country=USA http :8080/api/group/5 http PUT :8080/api/group/5 id=6 name='Utah JUG' address='On the slopes' http DELETE :8080/api/group/5
-
Create a new project in the root directory with
npx
and Create React App.npx create-react-app@5 app
-
After the app creation process completes, navigate into the
app
directory and install Bootstrap, cookie support for React, React Router, and Reactstrap.cd app npm i bootstrap@5 react-cookie@4 react-router-dom@6 reactstrap@9
-
Add Bootstrap’s CSS file as an import in
app/src/index.js
.import 'bootstrap/dist/css/bootstrap.min.css';
-
Modify
App.js
to use the following code that calls/api/groups
and displays the list in the UI. [sbr-app
]app/src/App.js
import React, { useEffect, useState } from 'react'; import logo from './logo.svg'; import './App.css'; const App = () => { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); fetch('api/groups') .then(response => response.json()) .then(data => { setGroups(data); setLoading(false); }) }, []); if (loading) { return <p>Loading...</p>; } return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <div className="App-intro"> <h2>JUG List</h2> {groups.map(group => <div key={group.id}> {group.name} </div> )} </div> </header> </div> ); } export default App;
-
To proxy from
/api
tohttp://localhost:8080/api
, add a proxy setting toapp/package.json
."scripts": {...}, "proxy": "http://localhost:8080",
-
Make sure Spring Boot is running, then run
npm start
in yourapp
directory. You should see the list of default groups.
-
React is all about components, and you don’t want to render everything in your main
App
, so createGroupList.js
and populate it with the following JavaScript. [sbr-group-list
]src/app/GroupList.js
import React, { useEffect, useState } from 'react'; import { Button, ButtonGroup, Container, Table } from 'reactstrap'; import AppNavbar from './AppNavbar'; import { Link } from 'react-router-dom'; const GroupList = () => { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); fetch('api/groups') .then(response => response.json()) .then(data => { setGroups(data); setLoading(false); }) }, []); const remove = async (id) => { await fetch(`/api/group/${id}`, { method: 'DELETE', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }).then(() => { let updatedGroups = [...groups].filter(i => i.id !== id); setGroups(updatedGroups); }); } if (loading) { return <p>Loading...</p>; } const groupList = groups.map(group => { const address = `${group.address || ''} ${group.city || ''} ${group.stateOrProvince || ''}`; return <tr key={group.id}> <td style={{whiteSpace: 'nowrap'}}>{group.name}</td> <td>{address}</td> <td>{group.events.map(event => { return <div key={event.id}>{new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: '2-digit' }).format(new Date(event.date))}: {event.title}</div> })}</td> <td> <ButtonGroup> <Button size="sm" color="primary" tag={Link} to={"/groups/" + group.id}>Edit</Button> <Button size="sm" color="danger" onClick={() => remove(group.id)}>Delete</Button> </ButtonGroup> </td> </tr> }); return ( <div> <AppNavbar/> <Container fluid> <div className="float-end"> <Button color="success" tag={Link} to="/groups/new">Add Group</Button> </div> <h3>My JUG Tour</h3> <Table className="mt-4"> <thead> <tr> <th width="20%">Name</th> <th width="20%">Location</th> <th>Events</th> <th width="10%">Actions</th> </tr> </thead> <tbody> {groupList} </tbody> </Table> </Container> </div> ); }; export default GroupList;
-
Create
AppNavbar.js
in the same directory to establish a common UI feature between components. [sbr-navbar
]src/app/AppNavbar.js
import React, { useState } from 'react'; import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; import { Link } from 'react-router-dom'; const AppNavbar = () => { const [isOpen, setIsOpen] = useState(false); return ( <Navbar color="dark" dark expand="md"> <NavbarBrand tag={Link} to="/">Home</NavbarBrand> <NavbarToggler onClick={() => { setIsOpen(!isOpen) }}/> <Collapse isOpen={isOpen} navbar> <Nav className="justify-content-end" style={{width: "100%"}} navbar> <NavItem> <NavLink href="https://twitter.com/oktadev">@oktadev</NavLink> </NavItem> <NavItem> <NavLink href="https://github.com/oktadev/okta-spring-boot-react-crud-example">GitHub</NavLink> </NavItem> </Nav> </Collapse> </Navbar> ); }; export default AppNavbar;
-
Create
Home.js
to serve as the landing page for your app. [sbr-home
]src/app/Home.js
import React from 'react'; import './App.css'; import AppNavbar from './AppNavbar'; import { Link } from 'react-router-dom'; import { Button, Container } from 'reactstrap'; const Home = () => { return ( <div> <AppNavbar/> <Container fluid> <Button color="link"><Link to="/groups">Manage JUG Tour</Link></Button> </Container> </div> ); } export default Home;
-
Also, change
App.js
to use React Router to navigate between components. [sbr-app-router
]src/app/App.js
import React from 'react'; import './App.css'; import Home from './Home'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import GroupList from './GroupList'; const App = () => { return ( <Router> <Routes> <Route exact path="/" element={<Home/>}/> <Route path="/groups" exact={true} element={<GroupList/>}/> </Routes> </Router> ) } export default App;
-
To make your UI a bit more spacious, add a top margin to Bootstrap’s container classes in
App.css
.nav + .container, nav + .container-fluid { margin-top: 20px; }
-
Your React app should update itself as you make changes at
http://localhost:3000
. -
Click on Manage JUG Tour and you should see a list of the default groups.
-
Create
GroupEdit.js
and useuseEffect()
to fetch the group resource with the ID from the URL. [sbr-group-edit
]app/src/GroupEdit.js
import React, { useEffect, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Button, Container, Form, FormGroup, Input, Label } from 'reactstrap'; import AppNavbar from './AppNavbar'; const GroupEdit = () => { const initialFormState = { name: '', address: '', city: '', stateOrProvince: '', country: '', postalCode: '' }; const [group, setGroup] = useState(initialFormState); const navigate = useNavigate(); const { id } = useParams(); useEffect(() => { if (id !== 'new') { fetch(`/api/group/${id}`) .then(response => response.json()) .then(data => setGroup(data)); } }, [id, setGroup]); const handleChange = (event) => { const { name, value } = event.target setGroup({ ...group, [name]: value }) } const handleSubmit = async (event) => { event.preventDefault(); await fetch('/api/group' + (group.id ? '/' + group.id : ''), { method: (group.id) ? 'PUT' : 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(group) }); setGroup(initialFormState); navigate('/groups'); } const title = <h2>{group.id ? 'Edit Group' : 'Add Group'}</h2>; return (<div> <AppNavbar/> <Container> {title} <Form onSubmit={handleSubmit}> <FormGroup> <Label for="name">Name</Label> <Input type="text" name="name" id="name" value={group.name || ''} onChange={handleChange} autoComplete="name"/> </FormGroup> <FormGroup> <Label for="address">Address</Label> <Input type="text" name="address" id="address" value={group.address || ''} onChange={handleChange} autoComplete="address-level1"/> </FormGroup> <FormGroup> <Label for="city">City</Label> <Input type="text" name="city" id="city" value={group.city || ''} onChange={handleChange} autoComplete="address-level1"/> </FormGroup> <div className="row"> <FormGroup className="col-md-4 mb-3"> <Label for="stateOrProvince">State/Province</Label> <Input type="text" name="stateOrProvince" id="stateOrProvince" value={group.stateOrProvince || ''} onChange={handleChange} autoComplete="address-level1"/> </FormGroup> <FormGroup className="col-md-5 mb-3"> <Label for="country">Country</Label> <Input type="text" name="country" id="country" value={group.country || ''} onChange={handleChange} autoComplete="address-level1"/> </FormGroup> <FormGroup className="col-md-3 mb-3"> <Label for="country">Postal Code</Label> <Input type="text" name="postalCode" id="postalCode" value={group.postalCode || ''} onChange={handleChange} autoComplete="address-level1"/> </FormGroup> </div> <FormGroup> <Button color="primary" type="submit">Save</Button>{' '} <Button color="secondary" tag={Link} to="/groups">Cancel</Button> </FormGroup> </Form> </Container> </div> ) }; export default GroupEdit;
-
Modify
App.js
to importGroupEdit
and specify a path to it.import GroupEdit from './GroupEdit'; const App = () => { return ( <Router> <Routes> ... <Route path="/groups/:id" element={<GroupEdit/>}/> </Routes> </Router> ) }
Now you should be able to add and edit groups!
-
Add the necessary Spring Security dependencies to do OIDC authentication. [
sbr-spring-oauth
]<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency>
NoteWe hope to make the Okta Spring Boot starter work with Auth0 in the future. -
Install the Auth0 CLI and run
auth0 login
in a terminal. -
Run
auth0 apps create
, provide a memorable name, and select Regular Web Application. Specifyhttp://localhost:8080/login/oauth2/code/auth0
for the Callback URLs andhttp://localhost:3000,http://localhost:8080
for the Allowed Logout URLs. -
Modify your
src/main/resources/application.properties
to include your Auth0 issuer, client ID, and client secret. You will have to runauth0 apps open
and select the app you created to copy your client secret. [sbr-auth0
]# make sure to include the trailing slash for the Auth0 issuer spring.security.oauth2.client.provider.auth0.issuer-uri=https://<your-auth0-domain>/ spring.security.oauth2.client.registration.auth0.client-id=<your-client-id> spring.security.oauth2.client.registration.auth0.client-secret=<your-client-secret> spring.security.oauth2.client.registration.auth0.scope=openid,profile,email
Of course, you can also use your Auth0 dashboard to configure your application. Just make sure to use the same URLs specified above.
-
Add the Okta Spring Boot starter to do OIDC authentication.
<dependency> <groupId>com.okta.spring</groupId> <artifactId>okta-spring-boot-starter</artifactId> <version>2.1.6</version> </dependency>
-
Install the Okta CLI and run
okta login
. Then, runokta apps create
. Select the default app name, or change it as you see fit. Choose Web and press Enter.Select Okta Spring Boot Starter. Accept the default Redirect URI and use
http://localhost:3000,http://localhost:8080
for the Logout Redirect URI. -
After configuring Spring Security in the section below, update
UserController.java
to useokta
in its constructor:public UserController(ClientRegistrationRepository registrations) { this.registration = registrations.findByRegistrationId("okta"); }
-
And update the
logout()
method to work with Okta:@PostMapping("/api/logout") public ResponseEntity<?> logout(HttpServletRequest request, @AuthenticationPrincipal(expression = "idToken") OidcIdToken idToken) { // send logout URL to client so they can initiate logout String logoutUrl = this.registration.getProviderDetails() .getConfigurationMetadata().get("end_session_endpoint").toString(); Map<String, String> logoutDetails = new HashMap<>(); logoutDetails.put("logoutUrl", logoutUrl); logoutDetails.put("idToken", idToken.getTokenValue()); request.getSession(false).invalidate(); return ResponseEntity.ok().body(logoutDetails); }
-
Update
Home.js
in the React project to use different parameters for the logout redirect:window.location.href = `${response.logoutUrl}?id_token_hint=${response.idToken}` + `&post_logout_redirect_uri=${window.location.origin}`;
Tip
|
You can see all the differences between Okta and Auth0 by comparing their branches on GitHub. |
-
To make Spring Security React-friendly, create a
SecurityConfiguration.java
file insrc/main/java/…/jugtours/config
. [sbr-security-config
]package com.okta.developer.jugtours.config; import com.okta.developer.jugtours.web.CookieCsrfFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.context.SecurityContextHolderFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.SimpleSavedRequest; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Enumeration; import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; @Configuration public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authz) -> authz .requestMatchers("/", "/index.html", "/static/**", "/*.ico", "/*.json", "/*.png", "/api/user").permitAll() // (1) .anyRequest().authenticated() ) .csrf((csrf) -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // (2) // https://stackoverflow.com/a/74521360/65681 .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) ) .addFilterAfter(new CookieCsrfFilter(), BasicAuthenticationFilter.class) // (3) .oauth2Login(); return http.build(); } @Bean public RequestCache refererRequestCache() { // (4) return new HttpSessionRequestCache() { @Override public void saveRequest(HttpServletRequest request, HttpServletResponse response) { String referrer = request.getHeader("referer"); // (5) if (referrer == null) { referrer = request.getRequestURL().toString(); } request.getSession().setAttribute("SPRING_SECURITY_SAVED_REQUEST", new SimpleSavedRequest(referrer)); } }; } }
-
Define what URLs are allowed for anonymous users.
-
CookieCsrfTokenRepository.withHttpOnlyFalse()
means that theXSRF-TOKEN
cookie won’t be marked HTTP-only, so React can read it and send it back when it tries to manipulate data. -
Spring Security 6 no longer sets a CSRF cookie for you. Add a filter to do it.
-
The
RequestCache
bean overrides the default request cache. -
It saves the referrer header (misspelled
referer
in real life), so Spring Security can redirect back to it after authentication.
-
-
Create
src/main/java/…/jugtours/web/CookieCsrfFilter.java
to set a CSRF cookie. [sbr-csrf
]CookieCsrfFilter.java
package com.okta.developer.jugtours.web; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; /** * Spring Security 6 doesn't set a XSRF-TOKEN cookie by default. * This solution is * <a href="https://github.com/spring-projects/spring-security/issues/12141#issuecomment-1321345077"> * recommended by Spring Security.</a> */ public class CookieCsrfFilter extends OncePerRequestFilter { /** * {@inheritDoc} */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken()); filterChain.doFilter(request, response); } }
-
Create
src/main/java/…/jugtours/web/UserController.java
and populate it with the following code. This API will be used by React to 1) find out if a user is authenticated, and 2) perform global logout. [sbr-user-controller
]UserController.java
package com.okta.developer.jugtours.web; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; @RestController public class UserController { private ClientRegistration registration; public UserController(ClientRegistrationRepository registrations) { this.registration = registrations.findByRegistrationId("auth0"); } @GetMapping("/api/user") public ResponseEntity<?> getUser(@AuthenticationPrincipal OAuth2User user) { if (user == null) { return new ResponseEntity<>("", HttpStatus.OK); } else { return ResponseEntity.ok().body(user.getAttributes()); } } @PostMapping("/api/logout") public ResponseEntity<?> logout(HttpServletRequest request) { // send logout URL to client so they can initiate logout StringBuilder logoutUrl = new StringBuilder(); String issuerUri = this.registration.getProviderDetails().getIssuerUri(); logoutUrl.append(issuerUri.endsWith("/") ? issuerUri + "v2/logout" : issuerUri + "/v2/logout"); logoutUrl.append("?client_id=").append(this.registration.getClientId()); Map<String, String> logoutDetails = new HashMap<>(); logoutDetails.put("logoutUrl", logoutUrl.toString()); request.getSession(false).invalidate(); return ResponseEntity.ok().body(logoutDetails); } }
-
You’ll also want to add user information when creating groups so that you can filter by your JUG tour. Add a
UserRepository.java
in the same directory asGroupRepository.java
.package com.okta.developer.jugtours.model; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository<User, String> { }
-
Add a new
findAllByUserId(String id)
method toGroupRepository.java
.List<Group> findAllByUserId(String id);
-
Then inject
UserRepository
intoGroupController.java
and use it to create (or grab an existing user) when adding a new group. While you’re there, modify thegroups()
method to filter by user.import org.springframework.security.core.annotation.AuthenticationPrincipal; ... @GetMapping("/groups") Collection<Group> groups(Principal principal) { return groupRepository.findAllByUserId(principal.getName()); } ... @PostMapping("/group") ResponseEntity<Group> createGroup(@Valid @RequestBody Group group, @AuthenticationPrincipal OAuth2User principal) throws URISyntaxException { log.info("Request to create group: {}", group); Map<String, Object> details = principal.getAttributes(); String userId = details.get("sub").toString(); // check to see if user already exists Optional<User> user = userRepository.findById(userId); group.setUser(user.orElse(new User(userId, details.get("name").toString(), details.get("email").toString()))); Group result = groupRepository.save(group); return ResponseEntity.created(new URI("/api/group/" + result.getId())) .body(result); }
You’ll need to make a few changes to your React components to make them identity-aware.
-
Modify
index.js
to wrap everything in aCookieProvider
. This component allows you to read the CSRF cookie and send it back as a header.import { CookiesProvider } from 'react-cookie'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <CookiesProvider> <App /> </CookiesProvider> </React.StrictMode> );
-
Modify
Home.js
to call/api/user
to see if the user is logged in. If they’re not, show aLogin
button. [sbr-home-auth
]import React, { useEffect, useState } from 'react'; import './App.css'; import AppNavbar from './AppNavbar'; import { Link } from 'react-router-dom'; import { Button, Container } from 'reactstrap'; import { useCookies } from 'react-cookie'; const Home = () => { const [authenticated, setAuthenticated] = useState(false); const [loading, setLoading] = useState(false); const [user, setUser] = useState(undefined); const [cookies] = useCookies(['XSRF-TOKEN']); // (1) useEffect(() => { setLoading(true); fetch('api/user', { credentials: 'include' }) // (2) .then(response => response.text()) .then(body => { if (body === '') { setAuthenticated(false); } else { setUser(JSON.parse(body)); setAuthenticated(true); } setLoading(false); }); }, [setAuthenticated, setLoading, setUser]) const login = () => { let port = (window.location.port ? ':' + window.location.port : ''); if (port === ':3000') { port = ':8080'; } // redirect to a protected URL to trigger authentication window.location.href = `//${window.location.hostname}${port}/api/private`; } const logout = () => { fetch('/api/logout', { method: 'POST', credentials: 'include', headers: { 'X-XSRF-TOKEN': cookies['XSRF-TOKEN'] } // (3) }) .then(res => res.json()) .then(response => { window.location.href = `${response.logoutUrl}&returnTo=${window.location.origin}`; }); } const message = user ? <h2>Welcome, {user.name}!</h2> : <p>Please log in to manage your JUG Tour.</p>; const button = authenticated ? <div> <Button color="link"><Link to="/groups">Manage JUG Tour</Link></Button> <br/> <Button color="link" onClick={logout}>Logout</Button> </div> : <Button color="primary" onClick={login}>Login</Button>; if (loading) { return <p>Loading...</p>; } return ( <div> <AppNavbar/> <Container fluid> {message} {button} </Container> </div> ); } export default Home;
-
useCookies()
is used for access to cookies. Then you can fetch a cookie withcookies['XSRF-TOKEN']
. -
When using
fetch()
, you need to include{credentials: 'include'}
to transfer cookies. You will get a 403 Forbidden if you do not include this option. -
The CSRF cookie from Spring Security has a different name than the header you need to send back. The cookie name is
XSRF-TOKEN
, while the header name isX-XSRF-TOKEN
.
-
-
Update
GroupList.js
to have similar changes.import { useCookies } from 'react-cookie'; const GroupList = () => { ... const [cookies] = useCookies(['XSRF-TOKEN']); ... const remove = async (id) => { await fetch(`/api/group/${id}`, { method: 'DELETE', headers: { 'X-XSRF-TOKEN': cookies['XSRF-TOKEN'], 'Accept': 'application/json', 'Content-Type': 'application/json' }, credentials: 'include' }).then(() => { let updatedGroups = [...groups].filter(i => i.id !== id); setGroups(updatedGroups); }); } ... return (...) } export default GroupList;
-
Update
GroupEdit.js
too.import { useCookies } from 'react-cookie'; const GroupEdit = () => { ... const [cookies] = useCookies(['XSRF-TOKEN']); ... const handleSubmit = async (event) => { event.preventDefault(); await fetch(`/api/group${group.id ? `/${group.id}` : ''}`, { method: group.id ? 'PUT' : 'POST', headers: { 'X-XSRF-TOKEN': cookies['XSRF-TOKEN'], 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(group), credentials: 'include' }); setGroup(initialFormState); navigate('/groups'); } ... return (...) } export default GroupEdit;
After all these changes, you should be able to restart both Spring Boot and React and witness the glory of planning your very own JUG Tour!
To build and package your React app with Maven, you can use the frontend-maven-plugin and Maven’s profiles to activate it.
-
Add properties for versions and a
<profiles>
section to yourpom.xml
. [sbr-properties
andsbr-profiles
]pom.xml
<properties> ... <frontend-maven-plugin.version>1.12.1</frontend-maven-plugin.version> <node.version>v16.18.1</node.version> <npm.version>v8.19.2</npm.version> </properties> <profiles> <profile> <id>dev</id> <activation> <activeByDefault>true</activeByDefault> </activation> <properties> <spring.profiles.active>dev</spring.profiles.active> </properties> </profile> <profile> <id>prod</id> <build> <plugins> <plugin> <artifactId>maven-resources-plugin</artifactId> <executions> <execution> <id>copy-resources</id> <phase>process-classes</phase> <goals> <goal>copy-resources</goal> </goals> <configuration> <outputDirectory>${basedir}/target/classes/static</outputDirectory> <resources> <resource> <directory>app/build</directory> </resource> </resources> </configuration> </execution> </executions> </plugin> <plugin> <groupId>com.github.eirslett</groupId> <artifactId>frontend-maven-plugin</artifactId> <version>${frontend-maven-plugin.version}</version> <configuration> <workingDirectory>app</workingDirectory> </configuration> <executions> <execution> <id>install node</id> <goals> <goal>install-node-and-npm</goal> </goals> <configuration> <nodeVersion>${node.version}</nodeVersion> <npmVersion>${npm.version}</npmVersion> </configuration> </execution> <execution> <id>npm install</id> <goals> <goal>npm</goal> </goals> <phase>generate-resources</phase> </execution> <execution> <id>npm test</id> <goals> <goal>test</goal> </goals> <phase>test</phase> <configuration> <arguments>test</arguments> <environmentVariables> <CI>true</CI> </environmentVariables> </configuration> </execution> <execution> <id>npm build</id> <goals> <goal>npm</goal> </goals> <phase>compile</phase> <configuration> <arguments>run build</arguments> </configuration> </execution> </executions> </plugin> </plugins> </build> <properties> <spring.profiles.active>prod</spring.profiles.active> </properties> </profile> </profiles>
Add the active profile setting to
src/main/resources/application.properties
:spring.profiles.active[email protected]@
-
After adding this, you should be able to run
./mvnw spring-boot:run -Pprod
and see your app running onhttp://localhost:8080
. -
Everything will work just fine if you start at the root, since React will handle routing. However, if you refresh the page when you’re at
http://localhost:8080/groups
, you’ll get a 404 error since Spring Boot doesn’t have a route for/groups
. To fix this, add aSpaWebFilter
that conditionally forwards to the React app. [sbr-spa
]SpaWebFilter.java
package com.okta.developer.jugtours.web; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.security.Principal; public class SpaWebFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String path = request.getRequestURI(); Authentication user = SecurityContextHolder.getContext().getAuthentication(); if (user != null && !path.startsWith("/api") && !path.contains(".") && path.matches("/(.*)")) { request.getRequestDispatcher("/").forward(request, response); return; } filterChain.doFilter(request, response); } }
-
And add it to
SecurityConfiguration.java
:.addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class)
-
Now, if you restart and reload the page, everything will work as expected. 🤗
I hope you enjoyed this screencast, and it helped you understand how to integrate React and Spring Boot securely.
⚛️ Find the code on GitHub: @oktadev/okta-spring-boot-react-crud-example
🍃 Read the blog post: Use React and Spring Boot to Build a Simple CRUD App