Skip to content

Latest commit

 

History

History
1412 lines (1211 loc) · 43.1 KB

File metadata and controls

1412 lines (1211 loc) · 43.1 KB

Use React and Spring Boot to Build a Simple CRUD App

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

  1. Navigate to start.spring.io and make the following selections:

    • Project: Maven Project

    • Group: com.okta.developer

    • Artifact: jugtours

    • Dependencies: JPA, H2, Web, Lombok

  2. Click Generate Project, expand jugtours.zip after downloading, and open the project in your favorite IDE.

Add a JPA domain model

  1. Create a src/main/java/com/okta/developer/jugtours/model directory and a Group.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;
    }
  2. 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;
    }
  3. 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;
    }
  4. 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);
    }
  5. To load some default data, create an Initializer.java class in the com.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);
        }
    }
    Tip
    If 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.
  6. Start your app with mvn spring-boot:run and you should see groups and events being created.

  7. Add a GroupController.java class (in src/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();
        }
    }
  8. 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>
  9. Restart the app and hit http://localhost:8080/api/groups with HTTPie and you should see the list of groups.

    http :8080/api/groups
  10. 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 React UI with Create React App

  1. Create a new project in the root directory with npx and Create React App.

    npx create-react-app@5 app
  2. 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
  3. Add Bootstrap’s CSS file as an import in app/src/index.js.

    import 'bootstrap/dist/css/bootstrap.min.css';

Call your Spring Boot API and display the results

  1. 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;
  2. To proxy from /api to http://localhost:8080/api, add a proxy setting to app/package.json.

    "scripts": {...},
    "proxy": "http://localhost:8080",
  3. Make sure Spring Boot is running, then run npm start in your app directory. You should see the list of default groups.

Build a React GroupList component

  1. React is all about components, and you don’t want to render everything in your main App, so create GroupList.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;
  2. 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;
  3. 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;
  4. 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;
  5. 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;
    }
  6. Your React app should update itself as you make changes at http://localhost:3000.

  7. Click on Manage JUG Tour and you should see a list of the default groups.

Add a React GroupEdit component

  1. Create GroupEdit.js and use useEffect() 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;
  2. Modify App.js to import GroupEdit 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 Authentication with Auth0

  1. 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>
    Note
    We hope to make the Okta Spring Boot starter work with Auth0 in the future.
  2. Install the Auth0 CLI and run auth0 login in a terminal.

  3. Run auth0 apps create, provide a memorable name, and select Regular Web Application. Specify http://localhost:8080/login/oauth2/code/auth0 for the Callback URLs and http://localhost:3000,http://localhost:8080 for the Allowed Logout URLs.

  4. Modify your src/main/resources/application.properties to include your Auth0 issuer, client ID, and client secret. You will have to run auth0 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 Authentication with Okta

  1. 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>
  2. Install the Okta CLI and run okta login. Then, run okta 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.

  3. After configuring Spring Security in the section below, update UserController.java to use okta in its constructor:

    public UserController(ClientRegistrationRepository registrations) {
        this.registration = registrations.findByRegistrationId("okta");
    }
  4. 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);
    }
  5. 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.

Configure Spring Security for React and user identity

  1. To make Spring Security React-friendly, create a SecurityConfiguration.java file in src/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));
    
                }
            };
        }
    }
    1. Define what URLs are allowed for anonymous users.

    2. CookieCsrfTokenRepository.withHttpOnlyFalse() means that the XSRF-TOKEN cookie won’t be marked HTTP-only, so React can read it and send it back when it tries to manipulate data.

    3. Spring Security 6 no longer sets a CSRF cookie for you. Add a filter to do it.

    4. The RequestCache bean overrides the default request cache.

    5. It saves the referrer header (misspelled referer in real life), so Spring Security can redirect back to it after authentication.

  2. 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);
        }
    }
  3. 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);
        }
    }
  4. 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 as GroupRepository.java.

    package com.okta.developer.jugtours.model;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface UserRepository extends JpaRepository<User, String> {
    }
  5. Add a new findAllByUserId(String id) method to GroupRepository.java.

    List<Group> findAllByUserId(String id);
  6. Then inject UserRepository into GroupController.java and use it to create (or grab an existing user) when adding a new group. While you’re there, modify the groups() 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);
    }

Modify React to handle CSRF and be identity-aware

You’ll need to make a few changes to your React components to make them identity-aware.

  1. Modify index.js to wrap everything in a CookieProvider. 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>
    );
  2. Modify Home.js to call /api/user to see if the user is logged in. If they’re not, show a Login 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;
    1. useCookies() is used for access to cookies. Then you can fetch a cookie with cookies['XSRF-TOKEN'].

    2. 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.

    3. 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 is X-XSRF-TOKEN.

  3. 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;
  4. 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!

Configure Maven to build and package React with Spring Boot

To build and package your React app with Maven, you can use the frontend-maven-plugin and Maven’s profiles to activate it.

  1. Add properties for versions and a <profiles> section to your pom.xml. [sbr-properties and sbr-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=@spring.profiles.active@
  2. After adding this, you should be able to run ./mvnw spring-boot:run -Pprod and see your app running on http://localhost:8080.

  3. 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 a SpaWebFilter 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);
        }
    }
  4. And add it to SecurityConfiguration.java:

    .addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class)
  5. Now, if you restart and reload the page, everything will work as expected. 🤗

Giddyup with React and Spring Boot!

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