Day 4 - BYU-CS260-Winter-2018/redbird GitHub Wiki
For Day 4, we will create an authenticated back end using JSON web tokens. The back end will generate a unique token for each client when the user registers or logs in, and the client will then need to present that token in subsequent calls to the API when it wants to modify any state (e.g. create a new tweet or follow someone).
Setup
The first thing we need to do is install the jsonwebtoken library. This is what we will use to create and verify tokens.
npm install jsonwebtoken
To use this library, we need to supply a secret. It is important that you never reveal this secret to anyone or check it into a file in a public source code repository. There are a number of ways to handles this, but an easy way is to set an environment variable. To make this a little easier, we will store them in a file that we are careful NOT to check into a source code repository. In a production system, you would be sure to just enter these environment variables on the command line.
Create a file called .env
that has some random number in it. For example:
export jwtSecret="21689237583884"
This defines an environment variable, which you can set by calling:
source .env
Now when you start your server, you will have a secret established for the jsonwebtoken
library.
Back End
For the back end, we need to create tokens whenever a user registers an account or logs in. We then need to verify the
token whenever we take an action that writes a record to the database. Edit server.js
and make the following changes.
First, let's do some setup:
// jwt setup
const jwt = require('jsonwebtoken');
let jwtSecret = process.env.jwtSecret;
if (jwtSecret === undefined) {
console.log("You need to define a jwtSecret environment variable to continue.");
knex.destroy();
process.exit();
}
This will load your environment variable containing the secret for the jsonwebtoken
library.
Now modify the login endpoint to create a token and send it back to the user when they login.
- if (result)
- res.status(200).json({user:{username:user.username,name:user.name,id:user.id}});
- else
+ if (result) {
+ let token = jwt.sign({ id: user.id }, jwtSecret, {
+ expiresIn: 86400 // expires in 24 hours
+ });
+ res.status(200).json({user:{username:user.username,name:user.name,id:user.id},token:token});
+ } else {
res.status(403).send("Invalid credentials");
+ }
This token includes the user's ID, so we know who the user is when they present the token. The token is also set to expire after 24 hours, so they'll have to login again after that. Some sites set this to be longer.
Make a similar change in the registration endpoint.
- res.status(200).json({user:user});
+ let token = jwt.sign({ id: user.id }, jwtSecret, {
+ expiresIn: 86400 // expires in 24 hours
+ });
+ res.status(200).json({user:user,token:token});
Now, before any of the REST endpoints, add that a function to verify a token:
const verifyToken = (req, res, next) => {
const token = req.headers['authorization'];
if (!token)
return res.status(403).send({ error: 'No token provided.' });
jwt.verify(token, jwtSecret, function(err, decoded) {
if (err)
return res.status(500).send({ error: 'Failed to authenticate token.' });
// if everything good, save to request for use in other routes
req.userID = decoded.id;
next();
});
}
The application will send a token in an HTTP header called Authorization
. Since these headers are not case sensitive,
Node converts them to lower case. We will check if a token is supplied, and then use the jwt.verify
function to validate it.
If valid, the token will contain the user's ID.
Note that this method takes request and response parameters, along with a next
parameter, and then calls next()
. This allows the function
to serve as middleware. This means that any other Express function can call this method first, and then handle the HTTP request
if this function calls the next()
function.
In every other endpoint we want to secure, we can simply call verifyToken
before handling the request if we want to be sure the
user is authorized to call this method. Once we do this, we can also check req.userID
to be sure they have the right authorization.
For the endpoint to post a new tweet:
-app.post('/api/users/:id/tweets', (req, res) => {
+app.post('/api/users/:id/tweets', verifyToken, (req, res) => {
let id = parseInt(req.params.id);
+ if (id !== req.userID) {
+ res.status(403).send();
+ return;
+ }
For the endpoint to follow someone:
-app.post('/api/users/:id/follow', (req,res) => {
+app.post('/api/users/:id/follow', verifyToken, (req,res) => {
// id of the person who is following
let id = parseInt(req.params.id);
+ // check this id
+ if (id !== req.userID) {
+ res.status(403).send();
+ return;
+ }
For the endpoint to unfollow someone:
-app.delete('/api/users/:id/follow/:follower', (req,res) => {
+app.delete('/api/users/:id/follow/:follower', verifyToken, (req,res) => {
// id of the person who is following
let id = parseInt(req.params.id);
+ // check this id
+ if (id !== req.userID) {
+ res.status(403).send();
+ return;
+ }
Finally, add a new endpoint to get a user's account based on the token they supply (the token contains the user ID).
// Get my account
app.get('/api/me', verifyToken, (req,res) => {
knex('users').where('id',req.userID).first().select('username','name','id').then(user => {
res.status(200).json({user:user});
}).catch(error => {
res.status(500).json({ error });
});
});
Front End
On the front end, we are going to store the token in the browser's localStorage. This is probably not the right choice, but this is enough to show you how tokens work. We'll copy this token to the VueX store every time it changes.
Modifying the store
We first need to add a plain function to src/store.js
. This should be located at the top of the file. This function, called getAuthHeader
, will return the header we need to send to the server when we use a REST endpoint that requires us to be authorized. It will get the token we need to send to the server from localStorage.
Vue.use(Vuex);
+ const getAuthHeader = () => {
+ return { headers: {'Authorization': localStorage.getItem('token')}};
+ }
Then we need to modify the store. We're going to change the loggedIn
boolean to instead keep track of the token.
We store it here so that the VueX store will notice changes and update the app when it is modified.
export default new Vuex.Store({
state: {
user: {},
- loggedIn: false,
+ token: '',
loginError: '',
registerError: '',
feed: [],
This means we also need a getter for the token, and we are going to provide a new getter for checking whether the user is logged in. We'll always take the token from localStorage, rather than the store, because we need this to work when we refresh the page as well (refreshing the page causes us to lose the VueX store state).
getters: {
user: state => state.user,
- loggedIn: state => state.loggedIn,
+ getToken: state => state.token,
+ loggedIn: state => {
+ if (state.token === '')
+ return false;
+ return true;
+ },
We'll also provide a mutation to set the token, and we'll save the token to the browser's localStorage each time it is changed.
- setLogin (state, status) {
- state.loggedIn = status;
+ setToken (state, token) {
+ state.token = token;
+ if (token === '')
+ localStorage.removeItem('token');
+ else
+ localStorage.setItem('token', token)
},
Notice how we remove the token if it is set to an empty string.
Now we modify the registration
action to set the token on success and clear it on failure:
// Registration, Login //
register(context,user) {
return axios.post("/api/users",user).then(response => {
context.commit('setUser', response.data.user);
- context.commit('setLogin',true);
+ context.commit('setToken',response.data.token);
context.commit('setRegisterError',"");
context.commit('setLoginError',"");
context.dispatch('getFollowing');
context.dispatch('getFollowers');
}).catch(error => {
- context.commit('setLogin',false);
+ context.commit('setUser',{});
+ context.commit('setToken','');
context.commit('setLoginError',"");
We do the same thing for the login
action:
login(context,user) {
return axios.post("/api/login",user).then(response => {
context.commit('setUser', response.data.user);
- context.commit('setLogin',true);
+ context.commit('setToken',response.data.token);
context.commit('setRegisterError',"");
context.commit('setLoginError',"");
context.dispatch('getFollowing');
context.dispatch('getFollowers');
}).catch(error => {
- context.commit('setLogin',false);
+ context.commit('setUser',{});
+ context.commit('setToken','');
context.commit('setRegisterError',"");
And we clear the token when we log out:
logout(context,user) {
context.commit('setUser', {});
- context.commit('setLogin',false);
+ context.commit('setToken','');
Finally, when we access a method that requires authorization, we be sure to include the
authorization header. We do this for the addTweet
action:
addTweet(context,tweet) {
- axios.post("/api/users/" + context.state.user.id + "/tweets",tweet).then(response => {
+ axios.post("/api/users/" + context.state.user.id + "/tweets",tweet,getAuthHeader()).then(response => {
We also do it for the follow action
:
follow(context,user) {
- return axios.post("/api/users/" + context.state.user.id + "/follow",user).then(response => {
+ return axios.post("/api/users/" + context.state.user.id + "/follow",user,getAuthHeader()).then(response => {
context.dispatch('getFollowing');
and the unfollow
action:
// unfollow someone, must supply {id: id} of user you want to unfollow
unfollow(context,user) {
- return axios.delete("/api/users/" + context.state.user.id + "/follow/" + user.id).then(response => {
+ return axios.delete("/api/users/" + context.state.user.id + "/follow/" + user.id,getAuthHeader()).then(response => {
Refresh
To make this work when we refresh the page, we need to do one more thing. Modify src/store.js
to add an initalize
action:
+ // Initialize //
+ initialize(context) {
+ let token = localStorage.getItem('token');
+ if(token) {
+ // see if we can use the token to get my user account
+ axios.get("/api/me",getAuthHeader()).then(response => {
+ context.commit('setToken',token);
+ context.commit('setUser',response.data.user);
+ }).catch(err => {
+ // remove token and user from state
+ localStorage.removeItem('token');
+ context.commit('setUser',{});
+ context.commit('setToken','');
+ });
+ }
+ },
Notice that we get the token from localStorage, and if it set, we call our new api/me
endpoint to see if the token is still valid
and set state for the current user. Otherwise, we remove the token.
Now modify src/main.js
to call this function when the app is loaded:
- render: h => h(App)
+ render: h => h(App),
+ beforeCreate() {
+ this.$store.dispatch('initialize');
+ }
You should now have an application that uses an authenticated REST API!