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
- Accept the assignment on our Github Classroom.
- Download the git repository to your local computer.
- Update the
package.json
file with your name and email address. - Install the package dependencies with
npm install
- 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 |