Creating a React app with Spring Security
Learn how to add Spring Security to your React app
Authors: Ben Rhine, Zachary Klein
Grails Version: 3.3.2
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Getting Started
In this guide you will learn how to secure your React/Grails application with Spring Security REST (using the default JWT Token authentication).
In many React apps, libraries like React Router are used to handle client-side routing, including login/logout redirects. In this guide we will not use any dedicated routing solution, in order to focus the learning experience on the authentication functionality.
2.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
2.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/react-spring-security.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/react-spring-security/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/react-spring-security/complete
|
3 Writing the Application
For this guide, you should use the initial
project included in the guide’s repo to get started. This project contains a working Grails/React app which provides basic CRUD functionality and a simple (unsecured) RESTful API. Feel free to start up the app and play around with it, and look at the existing code.
4 Running the Application
The app in this guide uses the react
profile, which provides a multiproject client/server build. This means you must start both the server (Grails) and client (React) apps independently.
~ cd initial/
To launch the server application, run the following command.
~ ./gradlew server:bootRun
This will start up the Grails application, which will be running on http://localhost:8080
To start the client app, open a second terminal session (in the same directory), and run the following command:
~ ./gradlew client:start
The React app will be available at http://localhost:3000
. Browse to that URL and you should see the home page of the app.
5 Building the Server
The initial project is based on the completed project from the Building a React App Guide. Please refer to that guide for details on the provided code.
|
Our first step in adding security to this project is to install the Spring Security plugin/s in our Grails app, and secure our API endpoints. The Spring Security REST plugin supports a stateless, token-based authentication model that is ideal for securing APIs and Single Page Applications. See the diagram below for an overview of the security model.
5.1 Installing Spring Security
To secure our application, we will use the Spring Security Core plugin as well as the Spring Security REST plugin. Install these plugins in the project by adding these lines to server/build.gradle
(under the dependencies
section):
compile "org.grails.plugins:spring-security-core:3.2.0"
compile "org.grails.plugins:spring-security-rest:2.0.0.M2"
Please see the documentation for more information on the Spring Security Core plugin and Spring Security REST plugin. |
5.2 Configuring Spring Security
Now that Spring Security has been added to our application, we can generate the default Spring Security
configuration. The plugin provides a s2-quickstart
command that will generate a set of domain classes and configuration to get us started.
cd initial/server
Then execute the following
grails s2-quickstart demo User Role
This should generate the following domain classes:
/server/grails-app/domain/demo/User.groovy
/server/grails-app/domain/demo/Role.groovy
/server/grails-app/domain/demo/UserRole.groovy
In addition, the s2-quickstart
has added an extra configuration file at server/grails-app/conf/application.groovy
. Grails projects support both YML and Groovy configuration, but YML is preferred. Let’s remove the application.groovy
file and add the following snippet at the end of application.yml
---
grails:
plugin:
springsecurity:
userLookup:
userDomainClassName: demo.User
authorityJoinClassName: demo.UserRole
authority:
className: demo.Role
filterChain:
chainMap:
-
pattern: /assets/**
filters: none
-
pattern: /**/js/**
filters: none
-
pattern: /**/css/**
filters: none
-
pattern: /**/images/**
filters: none
- # Stateless chain
pattern: /api/**
filters: JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter
- # Traditional Chain
pattern: /**
filters: JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter
controllerAnnotations:
staticRules:
-
pattern: /
access:
- permitAll
-
pattern: /error
access:
- permitAll
-
pattern: /index
access:
- permitAll
-
pattern: /index.gsp
access:
- permitAll
-
pattern: /shutdown
access:
- permitAll
-
pattern: /assets/**
access:
- permitAll
-
pattern: /**/js/**
access:
- permitAll
-
pattern: /**/css/**
access:
- permitAll
-
pattern: /**/images/**
access:
- permitAll
-
pattern: /**/favicon.ico/**
access:
- permitAll
Please refer to the Spring Security REST documentation and Spring Security Core Documentation for more details.
5.3 Securing our API
Our first step in securing our API is to update our Driver
Domain Class. In our app, Driver
would be a "user", however Spring Security has generated a User
class for authentication purposes. We could modify the configuration to use Driver
instead, however we’ll take another approach and make Driver
a subclass of User
.
Edit server/grails-app/domain/demo/Driver.groovy
:
package demo
import grails.compiler.GrailsCompileStatic
import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
@GrailsCompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/driver')
class Driver extends User {
String name
static hasMany = [ vehicles: Vehicle ]
static constraints = {
vehicles nullable: true
}
}
In addition to extending the User
class, we have also restricted access to this domain resource to users with the ROLE_DRIVER
role, using the @Secured
annotation. We haven’t created this role yet, but we’ll fix that shortly.
If you were now to run the server application, you would get a 401 response to any attempt to access /api/driver
.
Let’s secure the remaining domain resources, as shown below:
package demo
import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
import groovy.transform.CompileStatic
@CompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/make')
class Make {
String name
static constraints = {
}
}
package demo
import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
import groovy.transform.CompileStatic
@CompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/model')
class Model {
String name
static constraints = {
}
}
package demo
import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
import groovy.transform.CompileStatic
@CompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/vehicle')
class Vehicle {
String name
Make make
Model model
static belongsTo = [driver: Driver]
static constraints = {
}
}
5.4 Updating our Initial Data
Believe it or not, we’re done with the server-side security portion of our application! The last thing we need to do is to create the ROLE_DRIVER
role that we mentioned earlier. We also should prepopulate our database with some user/passwords that we can use to login.
Edit server/grails-app/init/demo/BootStrap.groovy
:
@Slf4j
class BootStrap {
def init = { servletContext ->
log.info "Loading database..."
def driver1 = new Driver(name: "Susan", username: "susan", password: "password1").save() (1)
def driver2 = new Driver(name: "Pedro", username: "pedro", password: "password2").save()
Role role = new Role(authority: "ROLE_DRIVER").save() (2)
UserRole.create(driver1, role, true) (3)
UserRole.create(driver2, role, true)
def nissan = new Make(name: "Nissan").save()
def ford = new Make(name: "Ford").save()
def titan = new Model(name: "Titan").save()
def leaf = new Model(name: "Leaf").save()
def windstar = new Model(name: "Windstar").save()
new Vehicle(name: "Pickup", driver: driver1, make: nissan, model: titan).save()
1 | Since we’ve extended the User class, we can now set a username and password property on our Driver domain objects. The password will be encrypted prior to persistence. |
2 | Here we’re creating our ROLE_DRIVER role - we can create as many roles as we need, and even create role hierarchies to support complex access controls. |
3 | UserRole represents the join table between User and Role . The class includes a create method which we can use to quickly associate our User and Role objects. |
5.5 Test-driving our Secured API
Start up the server app (if it’s not already running):
~ ./gradlew server:bootRun
In another terminal session, if you make a curl
request to one of our resources, you’ll get a 401 response.
~ curl -i 0:8080/api/vehicle
HTTP/1.1 401
WWW-Authenticate: Bearer
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Thu, 08 Jun 2017 05:42:05 GMT
{"timestamp":1496900525475,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/vehicle"}
In order to make a secure request, we need to first authenticate with the server using valid user credentials. By default, we can use the /api/login
endpoint for this purpose.
Make a request to /api/login
using the credentials for one of our Driver
objects in BootStrap.groovy
:
curl -i -H "Content-Type: application/json" --data '{"username":"susan","password":"password1"}' 0:8080/api/login
HTTP/1.1 200
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json;charset=UTF-8
Content-Length: 2157
Date: Thu, 08 Jun 2017 05:45:44 GMT
{"username":"susan","roles":["ROLE_DRIVER"],"token_type":"Bearer","access_token":"eyJhbGciOiJIUzI1NiJ9...","expires_in":3600,"refresh_token":"eyJhbGciOiJIUzI1NiJ9..."}
Notice that in the response, in addition to user details/granted roles, we received an access_token
- this is what we need to provide to our server in order to authenticate our request. We do this by setting the Authorization
header with our token. Let’s try our /api/vehicle
request again, using this access_token
:
curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." 0:8080/api/vehicle
HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Thu, 08 Jun 2017 05:49:01 GMT
[{"id":1,"name":"Pickup","make":{"name":"Nissan","id":1},"model":{"name":"Titan","id":1},"driver":{"name":"Susan","id":1}},{"id":2,"name":"Economy","make":{"name":"Nissan","id":1},"model":{"name":"Leaf","id":2},"driver":{"name":"Susan","id":1}},{"id":3,"name":"Minivan","make":{"name":"Ford","id":2},"model":{"name":"Windstar","id":3},"driver":{"name":"Pedro","id":2}}]
Congratulations, your API is now secured! Now we can move on to supporting authentication in our React client app.
Notice there’s actually another token in the /api/login response: refresh_token . When the access_token expires, you can use the refresh_token to obtain a new access_token . We’ll see how that works later on in this guide.
|
6 Building the Client
Now that the API is secured, we need to provide the user a means to authenticate with the server from the React app. Once the user logs in, we need to use their credentials to make requests to the API, as well as give the user the ability to logout and invalidate their token.
6.1 Stateless Login Component
Let’s start by creating the login component. We will pass 4 props to this component: userDetails
and error
variables (to represent the form input and possible error message), and changeHandler
and onSubmit
functions.
If you’re familiar with React, you may recognize this is a "stateless functional component". This style of React component is literally a simple function, with no internal state. This component gets its state and dynamic functionality via props only, which are passed in from the parent component, in this case, App .
|
import React from 'react';
import {Jumbotron, Row, Col, Form, FormGroup, ControlLabel, FormControl, Button} from 'react-bootstrap';
const Login = ({userDetails, error, inputChangeHandler, onSubmit}) => {
return (
<Row>
<Jumbotron>
<h1>Welcome to the Garage</h1>
</Jumbotron>
<Row>
<Col sm={4} smOffset={4}>
{error ? <p className="alert alert-danger">{error} </p> : null} (1)
<Form onSubmit={onSubmit}> (2)
<FormGroup>
<ControlLabel>Login</ControlLabel >
<FormControl type='text' name='username' placeholder='Username'
value={userDetails.username} (3)
onChange={inputChangeHandler}/> (4)
<FormControl type='password' name='password' placeholder='Password'
value={userDetails.password} (3)
onChange={inputChangeHandler}/> (4)
</FormGroup>
<FormGroup>
<Button bsStyle="success" type="submit">Login</Button>
</FormGroup>
</Form>
</Col>
</Row>
</Row>
);
};
export default Login;
1 | If we have an error , render the error message - otherwise render nothing. |
2 | The onSubmit function will be called when the login form is submitted. |
3 | The userDetails prop contains the username and password that the user has typed so far. |
4 | The inputChangeHandler function will fire every time the user types character into the form fields. We’ll see what it does in the next section. |
This style of form is an example of a "controlled component". This means that the value of the inputs is set "upstream" (in this case, in the userDetails prop) and is updated only when that upstream value is changed. That change will take place when our inputChangeHandler function is called. This is also referred to as "one-way data-binding".
|
7 Adding Client security
7.1 Creating our Configuration
At this point we will begin adding the security configuration to the React app. Let’s begin by creating a security
directory underneath client/src
.
~ cd client/src
~ mkdir security
Create a file named auth.js
in your new security
directory
~ cd security
~ touch auth.js
7.2 Handling Authentication
Let’s continue by editing auth.js
. This file is a "plain" JavaScript file (not a React component), and will contain four core authentication-related functions (logIn
, logOut
, `refreshToken
, and isLoggedIn
). These functions will be exported as a module, so they can be imported and used in any React component (or JavaScript file). We will be making use of HTML5’s localStorage
object to store the user’s token after a successful login.
import {SERVER_URL} from './../config';
import {checkResponseStatus} from './../handlers/responseHandlers';
import headers from './../security/headers';
import 'whatwg-fetch';
import qs from 'qs';
(1)
export default {
logIn(auth) { (2)
localStorage.auth = JSON.stringify(auth);
},
logOut() { (3)
delete localStorage.auth;
},
refreshToken() { (4)
return fetch(
`${SERVER_URL}/oauth/access_token`,
{ method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: qs.stringify({ (5)
grant_type: 'refresh_token',
refresh_token: JSON.parse(localStorage.auth).refresh_token
})
})
.then(checkResponseStatus)
.then((a) => localStorage.auth = JSON.stringify(a))
.catch(() => { throw new Error("Unable to refresh!")})
},
loggedIn() { (6)
return localStorage.auth && fetch(
`${SERVER_URL}/api/vehicle`, (7)
{headers: headers()})
.then(checkResponseStatus)
.then(() => { return true })
.catch(this.refreshToken)
.catch(() => { return false });
}
};
1 | The export keyword indicates that this is a JavaScript module, which can be imported using the import keyword. A single JavaScript file can export multiple modules, so the default indicates which module is imported by default (without specifying a particular module). |
2 | logIn takes in an auth object, converts it into a JSON string, and stores it in our localStorage for later use.
use. |
3 | logOut is even simpler - we simply remove the object that was stored at localStorage.auth . |
4 | refreshToken is the most complex function. It makes a POST request against the /oauth/access_token endpoint (which is set up by default by Spring Security REST), including the refresh_token as a URL parameter. If successful, it will receive the same JWT token response that a normal login would have - in which case we store the new token in our localStorage as before (overwriting the earlier one). |
5 | Because the refresh endpoint expects form-urlencoded body, we’re using the stringify function from the qs package to convert our JavaScript object into the correct format. |
6 | isLoggedIn returns whether we have an auth object in localStorage , and in addition verifies that our token is still valid by making a fetch request against the secured API and checking the response (in a real-world app, you would probably have a special endpoint for this purpose). If the checkResponseStatus function throws an error, the first catch statement is called, which will call the refreshToken function described above. If that function throws another error, the final catch statement will fire and the authentication will fail. |
7 | We are using backticks instead of quotes to denote our strings. This is an ES6 syntax called "template strings", and they allow us to write multi-line strings as well as use expressions in our strings with the ${expression} syntax. |
The server’s api/login endpoint responds us with an access_token which will expire and a refresh token which never expires and which allows us to refresh the access_token via the oauth/access_token endpoint. Checkout the plugin documentation to learn more about access token expiration and refresh options.
|
Write the headers() function
Notice that in our fetch
call in isLoggedIn
, we are calling a headers()
function (imported from headers.js
). This function will return an object containing our request headers, including the token from localStorage
. Let’s create this function now.
Create a new JavaScript file under client/src/security
, called headers.js
.
export default () => { (1)
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.auth ? JSON.parse(localStorage.auth).access_token : null}`
}
}
1 | Again we’re exporting a module, but this time the module is itself a function (using the ES6 "arrow function" syntax, which is analogous to a Groovy closure). The function is anonymous, so the name will be whatever variable is used to import the module (e.g, import headers from './headers' ). |
The function from the headers.js
module returns an object with an "Authorization" header, and adds the access_token
(which is obtained by parsing the localStorage.auth
JSON object). We will use this function later on when it’s time to authenticate our API calls.
Install the qs package
Remember that we used the qs
package in our refreshToken()
function above. This is a useful utility package that performs many types of conversions. In refreshToken
we are using this package to convert our request body to form-urlencoded
format. Now we need to add that package to our package.json
:
{
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "16.1.1",
"react-bootstrap": "0.31.5",
"react-dom": "16.1.1",
"react-scripts": "1.0.17"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
1 | Add this line to add the qs dependency to our project. |
If you have yarn
or npm
installed locally, you may run the install
command to install the new package.
~ yarn install
However, the Gradle tasks for running the client
app will perform the install automatically, so you are not required to perform the above step.
7.3 Writing the Response Handlers
In order to implement our authentication we will write several "handler" functions that we can import and reuse throughout our app.
Let’s begin by creating a handlers
directory under client/src
.
~ cd client/src
~ mkdir handlers
Now we are ready to write our first handlers. Create the following two files:
~ touch responseHandlers.js
~ touch errorHandlers.js
7.4 Creating our Response Handlers
Our first handler will be checkResponseStatus
. This function will check the HTTP status of a response, and either return the JSON of the response or throw an error. We can chain this function with our REST calls using the fetch
API.
import Auth from '../security/auth';
(1)
export const checkResponseStatus = (response) => {
if(response.status >= 200 && response.status < 300) {
return response.json()
} else {
let error = new Error(response.statusText);
error.response = response;
throw error;
}
};
export const loginResponseHandler = (response, handler) => {
Auth.logIn(response);
if(handler) {
handler.call();
}
};
1 | The export keyword makes this function available to any JavaScript file that imports this file. |
The checkResponseStatus
function takes an HTTP response and checks that the status code is in the successful range, then returns the JSON body of the response. If the HTTP status code is outside that range then an error will be thrown.
loginResponseHandler
uses the Auth.login(response)
function that we wrote previously. If an additional function is passed in as the second argument, it will then execute that function.
7.5 Creating a Default Error Handler
Along the same lines as the checkStatusResponse
function, we’ll write a defaultErrorHandler
which will take a JavaScript error object along with an custom handler (to be called after the default error handling).
export const defaultErrorHandler = (error, handler) => {
console.error(error);
if(handler) {
handler.call();
}
};
The defaultErrorHandler
function is quite simple: it logs the error to the console. If an additional handler was passed in the second argument,
it will then call that function.
8 Putting it all together
Now we can finally wire these pieces together in order to get our client-side security functioning. Here’s the steps we need to take:
-
Update our
App
component’s state to include the user details for theLogin
form. -
Check whether we’re logged in, and display the
Login
component if the user is not authenticated. -
Submit the
Login
form details to the server and obtain (and store) the resulting token -
Provide a way to logout (delete the token from
localStorage
) -
Use the token in our REST calls to the API
For your reference the full App.js
file is shown below - in the remaining sections we will go piece by piece through this component (going from top to bottom) and show how to complete the above steps.
import React, {Component} from 'react';
import Garage from './Garage';
import Auth from './security/auth';
import Login from './Login';
import {Grid} from 'react-bootstrap';
import {SERVER_URL} from './config';
import {defaultErrorHandler} from './handlers/errorHandlers';
import {checkResponseStatus, loginResponseHandler} from './handlers/responseHandlers';
class App extends Component {
//tag::state[]
constructor() {
super();
this.state = {
userDetails: {
username: '',
password: ''
},
route: '',
error: null
}
}
reset = () => { (1)
this.setState({
userDetails: {
username: '',
password: ''
},
route: 'login',
error: null
});
};
//end::state[]
//tag::lifecycle[]
componentDidMount() {
console.log('app mounting...');
(async () => {
if (await Auth.loggedIn()) {
this.setState({route: 'garage'})
} else {
this.setState({route: 'login'});
}
})();
}
componentDidUpdate() {
if (this.state.route !== 'login' && !Auth.loggedIn()) {
this.setState({route: 'login'})
}
}
//end::lifecycle[]
//tag::inputChangeHandler[]
inputChangeHandler = (event) => {
let {userDetails} = this.state;
const target = event.target;
userDetails[target.name] = target.value; (1)
this.setState({userDetails});
};
//end::inputChangeHandler[]
//tag::login[]
login = (e) => {
console.log('login');
e.preventDefault(); (1)
fetch(`${SERVER_URL}/api/login`, { (2)
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(this.state.userDetails)
}).then(checkResponseStatus) (3)
.then(response => loginResponseHandler(response, this.customLoginHandler)) (4)
.catch(error => defaultErrorHandler(error, this.customErrorHandler)); (5)
};
//end::login[]
//tag::handler[]
customLoginHandler = () => { (1)
this.setState({route: 'garage'});
};
customErrorHandler = (error) => { (2)
this.reset();
this.setState({error: error.message});
};
//end::handler[]
//tag::logout[]
logoutHandler = () => {
Auth.logOut();
this.reset();
};
//end::logout[]
//tag::routing[]
contentForRoute() { (1)
const {error, userDetails, route} = this.state;
const loginContent = <Login error={error} (2)
userDetails={userDetails}
inputChangeHandler={this.inputChangeHandler}
onSubmit={this.login}/>;
const garageContent = <Garage logoutHandler={this.logoutHandler}/>;
switch (route) {
case 'login':
return loginContent;
case 'garage':
return garageContent;
default:
return <p>Loading...</p>;
}
};
render() { (3)
const content = this.contentForRoute();
return (
<Grid>
{content}
</Grid>
);
};
//end::routing[]
}
export default App;
8.1 Creating our state
First thing, let’s build our App
state object. This is done in the component constructor. You should recognize our user and error fields in our state as
we have used them already in some of our functions. In addition, we’ll add a route
variable for use later on.
constructor() {
super();
this.state = {
userDetails: {
username: '',
password: ''
},
route: '',
error: null
}
}
reset = () => { (1)
this.setState({
userDetails: {
username: '',
password: ''
},
route: 'login',
error: null
});
};
1 | We’ve added a reset function so that we can easily return
to our initial state when needed: |
8.2 React Lifecycle Methods
Next in our App
component, we need to implement two of React’s "lifecycle" methods, which will check whether we are logged in or not.
See the React documentation for more information on the component lifecycle. |
componentDidMount() {
console.log('app mounting...');
(async () => {
if (await Auth.loggedIn()) {
this.setState({route: 'garage'})
} else {
this.setState({route: 'login'});
}
})();
}
componentDidUpdate() {
if (this.state.route !== 'login' && !Auth.loggedIn()) {
this.setState({route: 'login'})
}
}
In componentDidMount
, we are using the Auth.isLoggedIn()
function to see whether we’re logged in. Because isLoggedIn
uses a fetch
call, which is asynchronous, we’re using the async
/await
keywords to prevent our check from returning before the fetch
call completes. Assuming that isLoggedIn
returns true, we set our route
state variable to 'garage' - otherwise, set it login
.
We perform a similar check in componentDidUpdate
, redirecting to the login
route if we are no longer logged in.
For more information on async /await , see the documentation at https://developer.mozilla.org
|
8.3 Login Form Change Handler
As we discussed while we were creating the Login
component (see [loginScreen]), we need to define a handler function to update our userDetails
state object when the username/password values are changed.
inputChangeHandler = (event) => {
let {userDetails} = this.state;
const target = event.target;
userDetails[target.name] = target.value; (1)
this.setState({userDetails});
};
1 | Note that we will use the same handler for both username and password, as the name attributes set on each form input can be used to assign the correct variable. |
8.4 Handling Login
login = (e) => {
console.log('login');
e.preventDefault(); (1)
fetch(`${SERVER_URL}/api/login`, { (2)
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(this.state.userDetails)
}).then(checkResponseStatus) (3)
.then(response => loginResponseHandler(response, this.customLoginHandler)) (4)
.catch(error => defaultErrorHandler(error, this.customErrorHandler)); (5)
};
1 | We call e.preventDefault(); to disable the Login form’s default submit event. |
2 | Using fetch , we make a POST request containing the credentials entered by the user via the Login form. |
3 | We chain the checkResponseStatus to our fetch call to validate that the request was successful. |
4 | Assuming success, we add loginResponseHandler to the chain to complete the login process. |
5 | Any errors are passed to the defaultErrorHandler function, along with our customErrorHandler . |
customLoginHandler = () => { (1)
this.setState({route: 'garage'});
};
customErrorHandler = (error) => { (2)
this.reset();
this.setState({error: error.message});
};
Note the use of the "custom" handler functions:
-
customLoginHandler
updatesthis.state.route
upon a successful login. -
customErrorHandler`clears the `userDetails
state variable, and sets an error message.
At this point you should be able to successfully login to the application but there are a few more things to do before we are done.
8.5 Handling Logout
The logout handler simply calls the Auth.logOut()
function we wrote earlier. We then call reset()
, which gets us back our initial state.
logoutHandler = () => {
Auth.logOut();
this.reset();
};
8.6 Routing
Routing, in Single Page Applications, gives the user the ability to navigate through your application as though it were made of multiple "pages". In many React apps, the React Router library is used to handle this requirement. However, we will take a simplistic approach for this guide by storing our current "route" in our state, and choosing which component to render based on that variable.
contentForRoute() { (1)
const {error, userDetails, route} = this.state;
const loginContent = <Login error={error} (2)
userDetails={userDetails}
inputChangeHandler={this.inputChangeHandler}
onSubmit={this.login}/>;
const garageContent = <Garage logoutHandler={this.logoutHandler}/>;
switch (route) {
case 'login':
return loginContent;
case 'garage':
return garageContent;
default:
return <p>Loading...</p>;
}
};
render() { (3)
const content = this.contentForRoute();
return (
<Grid>
{content}
</Grid>
);
};
1 | We’ve created a contentForRoute function, which will select the proper component to render based on this.state.route . If there is no route set yet, we display a "Loading…" message. |
2 | Note that this is where we are passing in our inputChangeHandler , login , and logoutHandler handlers into those components as props. |
3 | Finally, in our render function, we render out the content that we calculated earlier based on our route. |
Note how we pass our handler functions as references (onSubmit={this.login} ), not function calls (onSubmit={this.login()} ). This is because we want our child components to have access to these functions and call them later - we don’t want to call them when the component is rendered!
|
8.7 Authenticating our REST Calls
At this point our login and logout functionality is complete. The last step is to authenticate our REST calls back to our API. This takes place in the Garage
component, which is shown below.
import React from 'react';
import Vehicles from './Vehicles';
import AddVehicleForm from './AddVehicleForm';
import { Row, Jumbotron, Button } from 'react-bootstrap';
import { SERVER_URL } from './config';
import headers from './security/headers';
import 'whatwg-fetch';
class Garage extends React.Component {
constructor() {
super();
this.state = {
vehicles: [],
makes: [],
models: [],
drivers: []
}
}
componentDidMount() {
fetch(`${SERVER_URL}/api/vehicle`, {
method: 'GET',
headers: headers(), (1)
})
.then(r => r.json())
.then(json => this.setState({vehicles: json}))
.catch(error => console.error('Error retrieving vehicles: ' + error));
fetch(`${SERVER_URL}/api/make`, {
method: 'GET',
headers: headers() (1)
})
.then(r => r.json())
.then(json => this.setState({makes: json}))
.catch(error => console.error('Error retrieving makes: ' + error));
fetch(`${SERVER_URL}/api/model`, {
method: 'GET',
headers: headers() (1)
})
.then(r => r.json())
.then(json => this.setState({models: json}))
.catch(error => console.error('Error retrieving models ' + error));
fetch(`${SERVER_URL}/api/driver`, {
method: 'GET',
headers: headers() (1)
})
.then(r => r.json())
.then(json => this.setState({drivers: json}))
.catch(error => console.error('Error retrieving drivers: ' + error));
}
submitNewVehicle = (vehicle) => {
fetch(`${SERVER_URL}/api/vehicle`, {
method: 'POST',
headers: headers(), (1)
body: JSON.stringify(vehicle)
}).then(r => r.json())
.then(json => {
let vehicles = this.state.vehicles;
vehicles.push({id: json.id, name: json.name, make: json.make, model: json.model, driver: json.driver});
this.setState({vehicles});
})
.catch(ex => console.error('Unable to save vehicle', ex));
};
render() {
const {vehicles, makes, models, drivers} = this.state;
(2)
const logoutButton = <Button bsStyle="warning" className="pull-right" onClick={this.props.logoutHandler} >Log Out</Button>;
return <Row>
<Jumbotron>
<h1>Welcome to the Garage</h1>
{logoutButton}
</Jumbotron>
<Row>
<AddVehicleForm onSubmit={this.submitNewVehicle} makes={makes} models={models} drivers={drivers}/>
</Row>
<Row>
<Vehicles vehicles={vehicles} />
</Row>
</Row>;
}
}
export default Garage;
1 | Note the use of the headers() function again to return our token-bearing request headers for all of our API calls. |
2 | The logout button will execute the logoutHandler function when clicked.
Start up the app and verify that you can login and authenticate successfully. Congratulations! You have secured your React app with Grails and Spring Security! |