CS 466 - Authentication

Due: 2019-12-03

Goals

  • Learn about the Authentication module in Firebase
  • Learn the basics of securing your database
  • Learn how to add authentication to your app

Prerequisites

  1. Accept the assignment on our Github Classroom.
  2. Download the git repository to your local computer.
  3. Update the package.json file with your name and email address.
  4. Install the package dependencies with npm install
  5. Copy over your config.json from your last practical so you can continue to use the same Firebase project.

Assignment

In this practical, you will be adding authentication to the Ghost Hunter app so that users only see the ghosts that they have caught.

We will be using the Authentication module offered by Firebase, which is also supported by the react-redux-firebase module we started using last time.

Part 1: Lock down your database

Go the the Firebase console. On the Database page, click the 'Rules' tab. You will see the default access rules we accepted last time, which should look something like this:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // This rule allows anyone on the internet to view, edit, and delete
    // all data in your Firestore database. It is useful for getting
    // started, but it is configured to expire after 30 days because it
    // leaves your app open to attackers. At that time, all client
    // requests to your Firestore database will be denied.
    //
    // Make sure to write security rules for your app before that time, or else
    // your app will lose access to your Firestore database
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2019, 12, 17);
    }
  }
}

As the comments warn us, these access rules allow anyone to read and write any document in the database. While this was desirable when we started up and were worrying about getting access in the first place, this is not ideal in general.

We actually want two things:

  • the data should only be accessible to our users
  • we want our users to only see their own ghosts

So, we need to write some new rules. The current rule is for all documents in the database, but we can target particular collections and particular actions with the matching operator.

In order to make sure our database is only accessible to our users, we will be using the Authentication module. This, in and of itself, will not lock down our database, but it will mean that every request we make to the database will include authorization information we can use in our rules.

We will start by restricting access to our database to only those users have authenticated.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    
    match /{document=**} {
      allow read, write: if request.auth.uid != null;
    }
  }
}

This just checks if the request has attached authentication.

Publish your changes (if you haven't already) and fire up the app. If you try to add a ghost or view the current collection, you should get permission errors.

Part 2: Enable authentication

In the Firebase console, click on the Authentication page.

Click on the 'Sign-in method' tab. You will see that Firebase provides support for a number of different sign-in providers.

Note, if you intend to distribute your app, Apple requires that you support Apple sign-in if you use any third-party provider (such as Google or Facebook).

While we used Google in 312, this time we are going to fall back on the basic email+password combination (we are relying on Firebase to handle the passwords correctly...). If you mouse over the 'Email/Password' provider line, you will see a pencil icon appear on the right. Click this to enable the provider.

Part 3: Add authentication support to the app

In the last practical, we added Firebase support to our app. This week, we just need to add a few more pieces to our configuration to support authentication as well.

In store.js, to enable authentication support, add the following import:

import 'firebase/auth' ;

The react-redux-firebase library provides us with support for authentication, including some high level functions that wrap the Firebase JavaScript API. In order to use these, we need to add another reducer. Import firebaseReducer from react-redux-firebase. Update your rootReducer to look like this:

const rootReducer = combineReducers({
  firebase: firebaseReducer,
  firestore: firestoreReducer,
});

We also need to have access to the firebase instance within our app. So we are going to add another provider to App.

Import ReactReduxFirebaseProvider from react-redux-firebase (yes, one is ReactReduxFirebaseProvider and the other is ReduxFirestoreProvider -- I can't explain the discrepancy).

Previously, you wrapped your AppNavigator in both a Provider and a ReduxFirestoreProvider. You will now add in a ReactReduxFirebaseProvider. Add it inside of the Provider and outside of the ReduxFirestoreProvider. It will take the same props as the ReduxFirestoreProvider.

Part 4: Add a log in screen to the app

I'll be honest, I hate apps that require me to log in in order to use them. More than once I have deleted apps that opened to a log-in screen. Unless it is absolutely clear that the app would be pointless without logging in (e.g., a banking app), I want to be able to use the app before committing to anything. I say this as a preface, because we are about to make Ghost Hunter one of these apps...

Initially, I wrote a version of this practical that used anonymous sign-in that could be escalated to a full account later. This is a typical model if you think about shopping online -- you build up a shopping cart just browsing around, and then you create an account on checkout, or you are reminded to log in and your activity is switched to your user account. Unfortunately, there seems to be a bug in react-redux-firebase that made this approach too complicated, so we will fall back on the simpler model of a gateway log-in page.

Create a new component

In order to separate out our logic a bit, we are going to add a container component between App and the AppNavigator.

Create a new component called Root and save it in a components/Root.js. Initially, we will just move what App is doing.

export default function Root() {
  
  return (<View style={styles.container}>
      <AppNavigator />
    </View>
    );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff'
  }
});

Replace the View and AppNavigator with the new component. Visually, this should change nothing when you run your app.

Integrating auth

We want this component to be aware of the current authentication state (and to re-render when the authentication state changes).

Add this line:

const auth = useSelector(state => state.firebase.auth);

Note that we are using the useSelector hook from react-redux, and extracting the auth data from the store.

Our auth object has two important state variables, isLoaded and isEmpty. If the auth component isn't loaded, then it isn't yet valid, and we need to wait. An auth object is considered "empty" if the user is not authenticated. We can test these values directly from the auth object, or we can import the functions isLoaded and isEmpty from react-redux-firebase and use them to query the object (i.e., isLoaded(auth) and isEmpty(auth)).

Taken together, we now have three different states that authentication could be in: not ready, not logged in, and logged in.

Write a conditional to cover these three states.

If the authentication is not loaded, just show a Text element that says "Loading".

If the user is logged in, show the AppNavigator.

If the user is not logged in, we need to show a log-in screen. To save you some time, I wrote one for you. You will find it in components/LoginComponent.js. Import it, and place the component on the screen. You will probably want to enclose it in a View that centers it.

Try this out. It won't actually log you in, but you should see the log-in screen when you start the app.

Adding the logic for log-in

The LoginComponent only provides the visual side of the log-in process. You still need to implement the actual logic. The component takes two props: login and createAccount.

The login prop expects a handler function that accepts two arguments: email and password.

The createAccount prop expects a handler function that accepts three arguments: email, password, and username. The user name is included to show the creation of a "user profile".

Create these two functions inside of the Root component and pass them as props to LoginComponent.

In order to provide the logic for these, we are going to use the wrapper functions provided by react-redux-firebase. To do so, we need a firebase instance. Just as we used the useFirestore hook in the last practical, we will use the useFirebase hook to get an instance of the firebase object (this will also be imported from react-redux-firebase).

Use firebase.login() to log in existing users. The login function takes a single credentials object as an argument. In our case, that will be an object containing the email and password. Our library will take care of calling the appropriate function from the firebase API for us (authWithPassword).

Use [firebase.createUser()](http://react-redux-firebase.com/docs/auth.html#createusercredentials-profile) to create new users. This function takes in a credential object like login as well as a profile object that will be used to setup the user profile. Pass the email and password in an object as the credentials, then create a second object containing the email address and the username to build the profile, but call the username displayName.

Both of these functions return Promises. We don't need to worry too much about handling the results. Our library will handle the result internally, updating auth for us. However, you should add a .catch((error)=>{}) on the end to handle errors. The error argument has a message attribute which can be used to make reasonable user facing error messages. Add an Alert to each that displays the error message and has a single 'OK' button to acknowledge the message.

Try it out

You should now be able to try it out. Create a new user. You should see a record of the user appear in the Authentication page of the Firebase console, and you should see a new collection called 'users' appear in the database where your profile data is held (we picked the name of this collection earlier in the rrfProps object).

Part 5: Add log out

Now that we have a user, we will turn to the profile screen. We aren't going to get to fancy here. We will just print out the user's username and provide a logout button.

One of the other things that has been attached to our store is an object called profile. We can get it with useSelector:

const profile = useSelector(state => state.firebase.profile);

The profile object will include the values we put into the initial profile when the user was created. Change the Text component to display profile.displayName.

Now add a Button to the page with the title "Log out". Logging out is pretty simple. react-redux-firebase provides us with a logout function that takes no arguments. Add an onPress function to the Button and call logout. Like login and createUser, you will need a firebase instance to call the function on.

When you log out, this will cause a change in the auth part of state, and Root will re-render. Try it out.

Part 7: Individualizing the ghosts

At this point, the app should work. You should be able to add ghosts without getting warnings, as well as see the collection. However, if you create a second user, you will see both users can see the whole collection. One of our goals was for users to only be able to see their own ghosts.

Personalize the ghosts

Currently, there is nothing that marks a particular ghost as belonging to one user or another. So, we need to add another field to our ghosts.

We will add the extra field when we create the ghost object (in RadarScreen). Use useSelector to get an instance of auth so we have the user's uid available. Add the line ghost.uid = auth.uid; between the line creating the ghost and the line adding it to the collection.

New ghosts will now have this new property, which you should be able to see in the Firebase Console. Once you have seen this, use the menu at the top of the ghosts collection (the three dots on the right) to delete the collection. We want to start fresh so that all of our ghosts have the new uid field.

Lock down the database

Now it is time to revisit the rules. The current rules only require that we have logged in users. To make sure users can only access their own ghosts, we will reset the rules to this:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    
   match /users/{userId} {
      allow read, update, delete: if request.auth.uid == userId;
      allow create: if request.auth.uid != null;
    }

    
    match /ghosts/{ghost} {
      allow read, delete:  if request.auth.uid == resource.data.uid
      allow write:  if request.auth.uid == request.resource.data.uid
    }
  }
}

For the ghosts, this says that a user can only read or delete ghost documents provided their uid, as included in the request, matches the uid stored in the resource being requested. The user also can only write ghosts if the object they are trying to write to has a matching uid.

I also added a new rule for our users collection. This says that a user has to have a uid in order to create a new record, and if they want to read, update or delete the record, it has to match their uid. Note how we are using userId, which matches the name of the document (which also matches the uid of the user associated with it).

Once you have done this, you should be able to create new ghosts, but your app will crash if you try to see the list of captured ones.

For a more comprehensive treatment of what the rules can do, visit the Firestore documentation and the Security rules documentation.

Update the query

The problem is that the rules control access, but they don't act as a filter. You won't be given just the documents that are allowed past the rules, your query will just fail. So, we need to update the query to only ask for the ghosts we are allowed to see.

Go to GhostsScreen.

At the top of the component, we used useFirestoreConnect([{collection: 'ghosts'}]) to query for the ghost collection. You may not have recognized that as a query, but it was. That query object says "get everything from the 'ghosts' collection". Change the query to be

useFirestoreConnect([
    { collection: 'ghosts',
      where:[
        ['uid', '==', auth.uid]
      ] } 
  ]);

In other words, "Get everything from the 'ghosts' collection that has a uid matching mine".

You will again need to access the auth object, which you can get with useSelector.

At this point you should be able to add ghosts and then only see the ones associated with the logged in account.

Finishing up

Commit your changes to git and push them back up to GitHub. I will find them there.

Grading

Points Requirement
✓/✗ Login logic works
✓/✗ Errors are handled
✓/✗ Ghosts can be added
✓/✗ Ghosts can be viewed
✓/✗ Ghosts can be removed