How to Secure Data and Routes With React

Tue Aug 15, 2017 - 2200 Words

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:

  1. Require an authenticated user.
  2. If the user is not logged in, then redirect to /login.
  3. Require that the user has permissions to interact with the view’s content.
  4. 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.