We’ve added authentication to our React application, but it’s not yet helping to secure our application. In this tutorial, we’ll associate the user to songs so that we can prevent unauthorized access and editing of information.
Goals
- Restrict access to certain routes depending on the authenticated status & content ownership.
- Allow registered users to create songs.
By the time we’re finished securing the application most of our routes will require authenticated status. We haven’t talked about what we want to do for data discovery yet, but that will probably be a consideration that we will want to deal with in the future. Adding route authorization is going to happen for us in two passes:
- Require an authenticated user.
- If the user is not logged in, then redirect to
/login
. - Require that the user has permissions to interact with the view’s content.
- If the user is not permitted, then redirect and show a message.
Creating Authorized Routes
Up to this point, we’ve been using the simple Route
component from the React Router library. This tool has worked amazingly for us, and we’re going to continue to use it, but we’re going to create components to wrap it. The beauty of components is that they can be classes like we’ve been using or they can be functions. We’re going to create an AuthenticatedRoute
component that does the checking of our current user’s authenticated state and redirects accordingly. This component is going to be small, so, for now, we’re going to put it directly into src/App.js
.
src/App.js
// Import `Redirect` from react-router-dom
import { BrowserRouter, Route, Redirect } from 'react-router-dom';
// other imports omitted
// AuthenticatedRoute added above App component
function AuthenticatedRoute({component: Component, authenticated, ...rest}) {
return (
<Route
{...rest}
render={(props) => authenticated === true
? <Component {...props} {...rest} />
: <Redirect to='/login' />} />
)
}
The function declaration is a little different than we’ve seen before because we want to take the component
attribute set on a Route
and store it as the Component
variable so that we can dynamically create the
component in JSX. You’ll notice that we’re expecting an authenticated
attribute to be passed in, but then all of
the other attributes are grouped into the rest
variable. Grouping these attributes also allows us to set
arbitrary attributes on our route and have them then set as props on our final Component
. Setting props like this has required us to write a custom render
function for our routes up to this point. Let’s use our component to restrict access to the /songs
route.
src/App.js
// Only showing routes section of render function
<Route exact path="/login" component={Login} />
<Route exact path="/logout" component={Logout} />
<AuthenticatedRoute
exact
path="/songs"
authenticated={this.state.authenticated}
component={SongList}
songs={this.state.songs} />
<Route path="/songs/:songId" render={(props) => {
const song = this.state.songs[props.match.params.songId];
return (
song
? <ChordEditor song={song} updateSong={this.updateSong} />
: <h1>Song not found</h1>
)
}} />
As you can see, there is no difference in usage between our AuthenticatedRoute
and the standard Route
component in this case (we’ll run into some differences later). Sign out of the application and then attempt to navigate to /songs
. You will be redirected to /login
and upon logging in, if you go to /songs
it will allow you to view the
content. It would have been nice if the Login
component had sent you back to where you were originally going (/songs
) instead of the root path. Let’s change our AuthenticatedRoute
and Login
component to be able to work together more to improve this experience.
src/App.js
function AuthenticatedRoute({component: Component, authenticated, ...rest}) {
return (
<Route
{...rest}
render={(props) => authenticated === true
? <Component {...props} {...rest} />
: <Redirect to={{pathname: '/login', state: {from: props.location}}} />} />
)
}
The to
prop on a Redirect
can handle an Object in addition to a simple string. This feature allows us to declare where the redirect should go, and also where it came from. We’re using the key of state
here because it will be part of the location
object and not something that will directly be accessible from props
within the Login
component. This name is not required. Let’s go and use this new information in Login
now:
src/components/Login.js
render() {
const { from } = this.props.location.state || { from: { pathname: '/' } }
if (this.state.redirect === true) {
return <Redirect to={from} />
}
// omitted majority of render implementation
}
If you log out, navigate to /songs
, and attempt to log in again it should redirect you to /songs
, but it won’t. You’ll see the login page again, but this time it has the logged in header. This is caused by the authentication state event from Firebase not being received before we redirect. We’re going to get around this by passing a function into the Login
component to set the currentUser
of our application. This will change the authenticated
state and set a new currentUser
value. After the redirect occurs, these values will be updated again by the onAuthStateChanged
event handler. Let’s create the setCurrentUser
function in App
now and pass it to the Login
component:
src/App.js
class App extends Component {
constructor() {
super();
this.addSong = this.addSong.bind(this);
this.updateSong = this.updateSong.bind(this);
this.setCurrentUser = this.setCurrentUser.bind(this);
this.state = {
authenticated: false,
currentUser: null,
loading: true,
songs: { }
};
}
// addSong and updateSong omitted
setCurrentUser(user) {
if (user) {
this.setState({
currentUser: user,
authenticated: true
})
} else {
this.setState({
currentUser: null,
authenticated: false
})
}
}
componentWillMount() {
this.removeAuthListener = app.auth().onAuthStateChanged((user) => {
if (user) {
this.setState({
authenticated: true,
currentUser: user,
loading: false
})
} else {
this.setState({
authenticated: false,
currentUser: null,
loading: false
})
}
})
this.songsRef = base.syncState('songs', {
context: this,
state: 'songs'
});
}
// componentWillUnmount omitted
render() {
// loading spinner code omitted
return (
<div style={{maxWidth: "1160px", margin: "0 auto"}}>
<BrowserRouter>
<div>
<Header authenticated={this.state.authenticated} />
<div className="main-content" style={{padding: "1em"}}>
<div className="workspace">
<Route exact path="/login" render={(props) => {
return <Login setCurrentUser={this.setCurrentUser} {...props} />
}} />
<!-- other routes omitted -->
</div>
</div>
</div>
</BrowserRouter>
<Footer />
</div>
);
}
}
Notice that we had to deconstruct the props
passed into the render
for out /login
route. We need to do this because location
is one of those props and we’re now using that within the Login
component. Now, let’s utilize
this function from within the Login
component.
src/components/Login.js
class Login extends Component {
// constructor omitted
authWithFacebook() {
app.auth().signInWithPopup(facebookProvider)
.then((user, error) => {
if (error) {
this.toaster.show({ intent: Intent.DANGER, message: "Unable to sign in with Facebook" })
} else {
this.props.setCurrentUser(user)
this.setState({ redirect: true })
}
})
}
authWithEmailPassword(event) {
event.preventDefault()
const email = this.emailInput.value
const password = this.passwordInput.value
app.auth().fetchProvidersForEmail(email)
.then((providers) => {
if (providers.length === 0) {
// create user
return app.auth().createUserWithEmailAndPassword(email, password)
} else if (providers.indexOf("password") === -1) {
// they used facebook
this.loginForm.reset()
this.toaster.show({ intent: Intent.WARNING, message: "Try alternative login." })
} else {
// sign user in
return app.auth().signInWithEmailAndPassword(email, password)
}
})
.then((user) => {
if (user && user.email) {
this.loginForm.reset()
this.props.setCurrentUser(user)
this.setState({redirect: true})
}
})
.catch((error) => {
this.toaster.show({ intent: Intent.DANGER, message: error.message })
})
}
// render omitted
}
With this code in place, let’s log out, and test the smart redirection again. It should work now.
Allowing New Song Creation
With authenticated routes, we’re now able to ensure that no one is creating a song without being logged in. This is the perfect opportunity for us to add some UI around the addSong
function that we’ve had in our application for some time now. Songs are the most important piece of data that our application works with, and we’re going to allow you to create a song from anywhere if you’re logged in. We’re going to do this by adding the interaction to the Header
element using a Popover
from blueprintjs. We’re going to need to pass addSong
into our Header
component from App.js
so let’s do that now.
src/App.js
// only showing the <Header /> portion of the render function
<Header addSong={this.addSong} authenticated={this.state.authenticated} />
Inside of Header
we’re going to add a Popover
and create a function that we can pass along to programmatically close it. Here’s our new header implementation including the not yet implemented NewSongForm
:
src/components/Header.js
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { Popover, PopoverInteractionKind, Position } from '@blueprintjs/core';
import NewSongForm from './NewSongForm';
class Header extends Component {
constructor(props) {
super(props)
this.closePopover = this.closePopover.bind(this)
this.state = {
popoverOpen: false,
}
}
closePopover() {
this.setState({ popoverOpen: false })
}
render() {
const { addSong } = this.props
return (
<nav className="pt-navbar">
<div className="pt-navbar-group pt-align-left">
<div className="pt-navbar-heading">Chord Creator</div>
{this.props.authenticated
? <input className="pt-input" placeholder="Search Songs..." type="text" />
: null
}
</div>
{
this.props.authenticated
? (
<div className="pt-navbar-group pt-align-right">
<Link className="pt-button pt-minimal pt-icon-music" to="/songs">Songs</Link>
<Popover
content={(<NewSongForm addSong={addSong} postSubmitHandler={this.closePopover}/>)}
interactionKind={PopoverInteractionKind.CLICK}
isOpen={this.state.popoverOpen}
onInteraction={(state) => this.setState({ popoverOpen: state })}
position={Position.BOTTOM}>
<button className="pt-button pt-minimal pt-icon-add" aria-label="add new song"></button>
</Popover>
<span className="pt-navbar-divider"></span>
<button className="pt-button pt-minimal pt-icon-user"></button>
<button className="pt-button pt-minimal pt-icon-cog"></button>
<Link className="pt-button pt-minimal pt-icon-log-out" to="/logout" aria-label="Log Out"></Link>
</div>
)
: (
<div className="pt-navbar-group pt-align-right">
<Link className="pt-button pt-intent-primary" to="/login">Register/Log In</Link>
</div>
)
}
</nav>
);
}
}
export default Header;
The JSX within the Popover
component is what will be rendered in the Header
and the target
is the component that will be rendered when the Popover
is triggered. We’re going to show our form when the button is clicked. Beyond that, the rest of the changes all revolve around managing the state of the Popover
using the popoverOpen
value. Let’s create the NewSongForm
now:
src/components/NewSongForm.js
import React, { Component } from 'react'
const newSongStyles = {
padding: '10px'
}
class NewSongForm extends Component {
constructor(props) {
super(props)
this.createSong = this.createSong.bind(this)
}
createSong(event) {
event.preventDefault()
const title = this.titleInput.value
this.props.addSong(title)
this.songForm.reset()
this.props.postSubmitHandler()
}
render() {
return (
<div style={newSongStyles}>
<form onSubmit={(event) => this.createSong(event)} ref={(form) => this.songForm = form}>
<label className="pt-label">
Song Title
<input style={{width: "100%"}} className="pt-input" name="title" type="text" ref={(input) => { this.titleInput = input }} placeholder="Don't Stop Believing"></input>
</label>
<input style={{width: "100%"}} type="submit" className="pt-button pt-intent-primary" value="Create Song"></input>
</form>
</div>
)
}
}
export default NewSongForm
This form is a lot like our Login
component in the way that we go about wiring up the events. Notice that when the form is submitted, we both reset the form state and also cause the Popover
to close in addition to passing the value on to addSong
. If you go to the /songs
route while signed in and you create a new song you should see it immediately added to the list.
Adding Data Ownership
Now that we can create songs we’re going to change how we read and write this data so that the user’s content is sandboxed for their eyes only (we could add a public showcase later). To get this to work, we’re going to have to delete our existing songs and start to structure our data so that it’s under songs/USER_ID/SONG_ID
. Thankfully, restructuring our data this way won’t be too painful. We need to where we’re syncing with re-base and also add some information to addSong
.
src/App.js
// omitting all but addSong and componentWillMount
addSong(title) {
const songs = {...this.state.songs};
const id = Date.now();
songs[id] = {
id: id,
title: title,
chordpro: "",
owner: this.state.currentUser.uid
};
this.setState({songs});
}
// updateSong & setCurrentUser omitted
componentWillMount() {
this.removeAuthListener = app.auth().onAuthStateChanged((user) => {
if (user) {
this.setState({
currentUser: user,
authenticated: true,
loading: false
})
this.songsRef = base.syncState(`songs/${this.state.currentUser.uid}`, {
context: this,
state: `songs`
});
} else {
this.setState({
currentUser: null,
authenticated: false,
loading: false
})
base.removeBinding(this.songsRef);
}
})
}
We’re now waiting to create our songsRef
until we have a signed in user. This will prevent the songs
data from being read into state unless we have a user, and it allows us to set what we’re syncing to the songs
key based on the currentUser
.
Restricting Database Access
Now that we’ve restructured our data so that users can own songs we are going to want to restrict access so that people can’t tamper with other people’s data. We’re going to do that by going into the “Database” portion of the Firebase console and then to the “Rules” sub-tab. This is the tab where we set both read and write to true
before. Now replace the contents of the rules with the following:
{
"rules": {
".write" : false,
".read" : true,
"songs" : {
"$uid" : {
".write" : "$uid === auth.uid",
".read" : "$uid === auth.uid"
}
}
}
}
Look here to see the rest of the code.
Recap
We’ve finally given the user a way to create songs, and now we’re ensuring the user’s data is secure. You now know how to create custom route types so that you can reuse common constraints like requiring authentication. We’re one step closer to the MVP of our application.