In chapter four you learned how to use the withAuthenticator
higher order component to create a pre-configured authentication form. You also learned how to use React Router and the Auth
class to create public and protected routes based on the user’s signed in state.
While this lays the foundation for what can be done with Amplify and the basics around authentication and routing, we want to go one step further and build a completely custom authentication flow so we know exactly what is going on under the hood and understand the logic and state needed to manage a custom authentication form.
This means that we need to update our app to have custom forms for signing up, signing in, and resetting our password instead of using the withAuthenticator
Higher Order Component.
We will also take the idea of protected routes one step further by creating a hook that we can reuse to wrap any component we are wanting protect with authentication (instead of rewriting the logic in each component).
The Auth
class, with over 30 different methods, is very powerful and allows you to handle all of the authentication logic that most applications demand. By the end of this chapter, you will understand how to use the Auth
class and React state to build and manage a custom authentication form.
The first thing we will do is to create the custom protectedRoute
hook that we will be using to protect any routes that require authentication.
This hook will check for the signed in user information and if the user is not signed in, will redirect them to the sign in page or any other specified route. If the user is signed in, the hook will return and render the component passed in as an argument.
By using this hook we can do away with any duplicate logic around authentication that we may need across multiple components.
In the src directory, create a new file called protectedRoute.js and add the following code:
import React, { useEffect } from 'react' import { Auth } from 'aws-amplify' const protectedRoute = (Comp, route = '/profile') => (props) => { async function checkAuthState() { try { await Auth.currentAuthenticatedUser() } catch (err) { props.history.push(route) } } useEffect(() => { checkAuthState() }, []) return <Comp {...props} /> } export default protectedRoute
This component uses the useEffect
hook when the component loads to check if the user is signed in. If the user is signed in, nothing happens and the component that is passed in as an argument gets rendered. If the user is not signed in, we do a redirect.
The redirect route can either be passed in as the second argument to the hook, or if no redirect route is passed in we set the default to be /profile
. Now, we can use the hook to protect any component like this:
// default redirect route protectedRoute(App) // custom redirect route protectedRoute(App, '/about-us')
Now that the protected route hook has been created, we can begin the refactor of our app. The next thing we may want to do is update the Protected component in our app to use this new protectedRoute
hook. To do so, open Protected.js and update the component with this code:
import React from 'react'; import Container from './Container' import protectedRoute from './protectedRoute' function Protected() { return ( <Container> <h1>Protected route</h1> </Container> ); } export default protectedRoute(Protected)
Now this component is protected and users will continue to be redirected when trying to access it if they are not authenticated.
The next thing we will want to do is create the main Form component. This component will hold all of the logic and UI for the following actions:
Signing up
Confirming sign up
Signing in
Resetting password
In the last chapter we used the withAuthenticator
component that encapsulated most of this logic for us, but we will new be rewriting our own version of this from scratch. It is important to understand how to create and handle custom forms because most of the time you will be working with custom designs and business logic that may not be able to work with abstractions like the withAuthenticator
component.
The first thing we will do is create the new component files that we will need. In the src directory, create the following files:
Button.js Form.js SignUp.js ConfirmSignUp.js SignIn.js ForgotPassword.js ForgotPasswordSubmit.js
Now that you have created these components, let’s continue by creating the reusable button we will be using as the submit button across all of the forms. In Button.js, add the following code:
import React from 'react' export default function Button({ onClick, title }) { return ( <button style={styles.button} onClick={onClick}> {title} </button> ) } const styles = { button: { backgroundColor: '#006bfc', color: 'white', width: 316, height: 45, fontWeight: '600', fontSize: 14, cursor: 'pointer', border:'none', outline: 'none', borderRadius: 3, marginTop: '25px', boxShadow: '0px 1px 3px rgba(0, 0, 0, .3)', }, }
This component is a pretty basic button component that accepts two props: title
and onClick
. The onClick
handler will call the function associated with the button and the title
component will render the text for the button.
Next, open Form.js and add the following code that will be the beginnings of the form:
import React, { useState } from 'react' import { Auth } from 'aws-amplify' import SignIn from './SignIn' import SignUp from './SignUp' import ConfirmSignUp from './ConfirmSignUp' import ForgotPassword from './ForgotPassword' import ForgotPasswordSubmit from './ForgotPasswordSubmit' const initialFormState = { username: '', password: '', email: '', confirmationCode: '' } function Form(props) { const [formType, updateFormType] = useState('signIn') const [formState, updateFormState] = useState(initialFormState) function renderForm() {} return ( <div> {renderForm()} </div> ) }
Here, we’ve imported the individual form components (that we will be writing shortly) and initialized some initial form state. The things that we will be keeping up with in the form state are the input fields (username
, password
, email
, and confirmationCode
) for the authentication flow.
There’s another piece of component state that keeps up with the type of form to be rendered, formType
. Because the form components will be displayed all in one route, we will need to check what the current form state is and then render either the Sign Up form, Sign In form, or Reset Password form. The updateFormType
will be the function that switches between different form types. Once a user successfully signs up, for example, we will call updateFormType('signIn')
to render the SignIn component so that they can then sign in.
The renderForm
method the is returned in the UI for the Form component will return the correct component based on the current formType
.
Next, add the following styles and default export to Form.js. The styles for some of the elements will be shared among the components so we will be exporting both the component as well as the styling:
const styles = { container: { display: 'flex', flexDirection: 'column', marginTop: 150, justifyContent: 'center', alignItems: 'center' }, input: { height: 45, marginTop: 8, width: 300, maxWidth: 300, padding: '0px 8px', fontSize: 16, outline: 'none', border: 'none', borderBottom: '2px solid rgba(0, 0, 0, .3)' }, toggleForm: { fontWeight: '600', padding: '0px 25px', marginTop: '15px', marginBottom: 0, textAlign: 'center', color: 'rgba(0, 0, 0, 0.6)' }, resetPassword: { marginTop: '5px', }, anchor: { color: '#006bfc', cursor: 'pointer' } } export { styles, Form as default }
Next, let’s go ahead and create the individual form components.
The SignIn component will render the sign in form. This component will accept two props, one for updating the form state (updateFormState
) and one for calling the signIn
function.
import React from 'react' import Button from './Button' import { styles } from './Form' function SignIn({ signIn, updateFormState }) { return ( <div style={styles.container}> <input name='username' onChange={e => {e.persist();updateFormState(e)}} style={styles.input} placeholder='username' /> <input type='password' name='password' onChange={e => {e.persist();updateFormState(e)}} style={styles.input} placeholder='password' /> <Button onClick={signIn} title="Sign In" /> </div> ) } export default SignIn
The SignUp component will render the sign up form. This component will accept two props, one for updating the form state (updateFormState
) and one for calling the signUp
function.
import React from 'react' import Button from './Button' import { styles } from './Form' function SignUp({ updateFormState, signUp }) { return ( <div style={styles.container}> <input name='username' onChange={e => {e.persist();updateFormState(e)}} style={styles.input} placeholder='username' /> <input type='password' name='password' onChange={e => {e.persist();updateFormState(e)}} style={styles.input} placeholder='password' /> <input name='email' onChange={e => {e.persist();updateFormState(e)}} style={styles.input} placeholder='email' /> <Button onClick={signUp} title="Sign Up" /> </div> ) } export default SignUp
Once a user has signed up they will receive a confirmation code for multi-factor authentication (MFA). The ConfirmSignUp component holds the form that will handle and submit this MFA code.
This component will accept two props, one for updating the form state (updateFormState
) and one for calling the confirmSignUp
function.
import React from 'react' import Button from './Button' import { styles } from './Form' function ConfirmSignUp(props) { return ( <div style={styles.container}> <input name='confirmationCode' placeholder='Confirmation Code' onChange={e => {e.persist();props.updateFormState(e)}} style={styles.input} /> <Button onClick={props.confirmSignUp} title="Confirm Sign Up" /> </div> ) } export default ConfirmSignUp
The next two forms will be for handling the resetting of a forgotten password. The first form (ForgotPassword) will take the user’s username as an input and send them a confirmation code. They can then use that confirmation code along with a new password to reset the password in the second form (ForgotPasswordSubmit).
The ForgotPassword component will accept two props, one for updating the form state (updateFormState
) and one for calling the forgotPassword
function.
import React from 'react' import Button from './Button' import { styles } from './Form' function ForgotPassword(props) { return ( <div style={styles.container}> <input name='username' placeholder='Username' onChange={e => {e.persist();props.updateFormState(e)}} style={styles.input} /> <Button onClick={props.forgotPassword} title="Reset password" /> </div> ) } export default ForgotPassword
The ForgotPasswordSubmit component will accept two props, one for updating the form state (updateFormState
) and one for calling the forgotPassword
function.
import React from 'react' import Button from './Button' import { styles } from './Form' function ForgotPasswordSubmit(props) { return ( <div style={styles.container}> <input name='confirmationCode' placeholder='Confirmation code' onChange={e => {e.persist();props.updateFormState(e)}} style={styles.input} /> <input name='password' placeholder='New password' type='password' onChange={e => {e.persist();props.updateFormState(e)}} style={styles.input} /> <Button onClick={props.forgotPasswordSubmit} title="Save new password" /> </div> ) } export default ForgotPasswordSubmit
Now that all of the individual form components have been created, we can update Form.js to use these new components.
The next thing we will do is open Form.js and create the functions that will interact with the authentication service. These functions, signIn
, signUp
, confirmSignUp
, forgotPassword
, and forgotPasswordSubmit
, will be passed as props to the individual form components.
Below the last import, add the following code:
async function signIn({ username, password }, setUser) { try { const user = await Auth.signIn(username, password) const userInfo = { username: user.username, ...user.attributes } setUser(userInfo) } catch (err) { console.log('error signing up..', err) } } async function signUp({ username, password, email }, updateFormType) { try { await Auth.signUp({ username, password, attributes: { email } }) console.log('sign up success!') updateFormType('confirmSignUp') } catch (err) { console.log('error signing up..', err) } } async function confirmSignUp({ username, confirmationCode }, updateFormType) { try { await Auth.confirmSignUp(username, confirmationCode) updateFormType('signIn') } catch (err) { console.log('error signing up..', err) } } async function forgotPassword({ username }, updateFormType) { try { await Auth.forgotPassword(username) updateFormType('forgotPasswordSubmit') } catch (err) { console.log('error submitting username to reset password...', err) } } async function forgotPasswordSubmit({ username, confirmationCode, password }, updateFormType) { try { await Auth.forgotPasswordSubmit(username, confirmationCode, password) updateFormType('signIn') } catch (err) { console.log('error updating password... :', err) } }
The signUp
, confirmSignUp
, forgotPassword
, and forgotPasswordSubmit
functions all take the same arguments, the form state and the updateFormType
function to update the type of form that is displayed.
The signIn
function is different than the other functions in that it takes in a setUser
function that will be passed down as a prop from the Profile component. This setUser
function will allow us to re-render the Profile component in order to show or hide the form once the user has successfully signed in.
In the previous chapter, the Profile.js component used the withAuthenticator
component to render the form so we did not need to worry about rendering the proper UI ourselves. Now that we are handling our own form state, we will need to decide whether to render the Profile component or the Form component based on whether the user is authenticated or not.
You’ll notice that in these functions we are using different methods on the Auth
class from AWS Amplify. These methods correspond with the naming of the functions we’ve created so that we know exactly what each of these functions are doing.
Next, let’s create a helper function for updating the form state. The initial form state variable that we created in Form.js looks like this:
const initialFormState = { username: '', password: '', email: '', confirmationCode: '' }
This state is an object with values for each form that we will be using.
We then used this initialFormState
variable to create the component state (as well a function to update the component state) using the useState
hook:
const [formState, updateFormState] = useState(initialFormState)
The problem that we have now is that updateFormState
is expecting a new object with all of these fields in order to update the form state, but a form handler only gives us the single form event that is being typed. How can we transform this input event into a new object for the state? We’ll do this by creating a helper function that we will use inside of the Form
function.
In Form.js below the useState
hooks inside of the Form
function, add the following code:
function updateForm(event) { const newFormState = { ...formState, [event.target.name]: event.target.value } updateFormState(newFormState) }
The updateForm
function will create a new state
object using the existing state as well as the new values coming in from the event and then call updateFormState
with this new form object. We can then reuse this function across all of our components.
Now that we have all of the form components created, the form state set up, and the authentication functions created, let’s update the renderForm
function to render the current form. In Form.js, update renderForm
with the following code:
function renderForm() { switch(formType) { case 'signUp': return ( <SignUp signUp={() => signUp(formState, updateFormType)} updateFormState={e => updateForm(e)} /> ) case 'confirmSignUp': return ( <ConfirmSignUp confirmSignUp={() => confirmSignUp(formState, updateFormType)} updateFormState={e => updateForm(e)} /> ) case 'signIn': return ( <SignIn signIn={() => signIn(formState, props.setUser)} updateFormState={e => updateForm(e)} /> ) case 'forgotPassword': return ( <ForgotPassword forgotPassword={() => forgotPassword(formState, updateFormType)} updateFormState={e => updateForm(e)} /> ) case 'forgotPasswordSubmit': return ( <ForgotPasswordSubmit forgotPasswordSubmit={() => forgotPasswordSubmit(formState, updateFormType)} updateFormState={e => updateForm(e)} /> ) default: return null } }
The renderForm
function will check the current formType
that is set in the state and render the proper form. As the formType
changes, renderForm
will be called and subsequently re-render the correct form based on the formType
.
The last thing we will need to do in this component is render the buttons that will allow us to manually toggle between different form states. The three main form states that we will want to toggle between are signIn
, signUp
, and forgotPassword
.
To do this, let’s update the return statement from the Form
function to also return some buttons that allow the user to toggle the form type:
return ( <div> {renderForm()} { formType === 'signUp' && ( <p style={styles.toggleForm}> Already have an account? <span style={styles.anchor} onClick={() => updateFormType('signIn')} >Sign In</span> </p> ) } { formType === 'signIn' && ( <> <p style={styles.toggleForm}> Need an account? <span style={styles.anchor} onClick={() => updateFormType('signUp')} >Sign Up</span> </p> <p style={{ ...styles.toggleForm, ...styles.resetPassword}}> Forget your password? <span style={styles.anchor} onClick={() => updateFormType('forgotPassword')} >Reset Password</span> </p> </> ) } </div> )
The Form component will now different buttons based on the current form type and allow the user to toggle between signing in, signing up, and resetting their password.
We now need to update the Profile component to use the new Form component. The main changes are that we will be that we will be rendering either the Form component or the user profile information based on whether there is a currently signed in user.
Amplify has a local eventing system called Hub
. Amplify uses Hub
for different categories to communicate with one another when specific events occur, such as authentication events like a user sign-in or notification of a file download.
In this component we will also be setting a Hub
listener to listen for the signOut
authentication event so that we can remove the user from the state and re-render the Profile component to show the authentication form.
Update Profile.js with the following code:
import React, { useState, useEffect } from 'react' import { Button } from 'antd' import { Auth, Hub } from 'aws-amplify' import Container from './Container' import Form from './Form' function Profile() { useEffect(() => { checkUser() Hub.listen('auth', (data) => { const { payload } = data if (payload.event === 'signOut') { setUser(null) } }) }, []) const [user, setUser] = useState(null) async function checkUser() { try { const data = await Auth.currentUserPoolUser() const userInfo = { username: data.username, ...data.attributes, } setUser(userInfo) } catch (err) { console.log('error: ', err) } } function signOut() { Auth.signOut() .catch(err => console.log('error signing out: ', err)) } if (user) { return ( <Container> <h1>Profile</h1> <h2>Username: {user.username}</h2> <h3>Email: {user.email}</h3> <h4>Phone: {user.phone_number}</h4> <Button onClick={signOut}>Sign Out</Button> </Container> ); } return <Form setUser={setUser} /> } export default Profile
In this component we check to see if there is a user, and if so we return the profile information of the user. If there is no user, then we return the authentication form (Form
). We pass in setUser
as a prop to the Form
component so that when a user signs in we can update the form state to re-render the component and show the profile information for that user.
To run the app, we can now run the start
command:
npm start
Congratulations, you’ve built out a completely custom authentication flow!
Here are a couple of things to keep in mind from this chapter:
Use the Auth
class for handling direct API calls to the Amazon Cognito authentication service.
As you can see, handling custom form state can become verbose. Try to understand the tradeoffs between rolling your own authentication flow vs using something like the withAuthenticator
higher order component.
Authentication is complex. By using a managed identity service like Amazon Cognito we’ve abstracted away all of the back end code and logic. The only thing we have to know or understand is how to interact with the authentication APIs and then manage the local state.
13.58.137.218