Login: JWT and Cookie
Introduction
Having added the packages to the project, let's now put them to work. By adding the login process to our project.
Video Overview
The video provides a general overview of the activity, but does not contain the detail needed to complete each process. Watch the video to obtain a general idea, but follow the written steps to complete the activity. This is the Transcript of the video.
cookie-parser
- Open the server.js file.
- At the top, in the "Require Statements" area require the cookie-parser:
const cookieParser = require("cookie-parser") - Scroll down to the "Middleware" section of the same file.
- Beneath the existing use statements, add one for the cookieParser:
app.use(cookieParser()) - The code in the Middleware section will allow the cookie parser to be implemented throughout the project.
- Ensure that no warnings or errors are reported by VSC.
- Save and close the file.
Login Process
Currently, we have the means of delivering the login and registration views and registering a client. What we don't have is the login process itself, to authenticate a site visitor. Let's add that now.
login View
- Find and open the account > login file in the views folder.
- Look carefully at the opening form tag. It should look similar to this:
<form id="loginForm" action="/account/login" method="post"> - All data within the form will be sent to the server using a post method.
- All data will be sent through the account/login route. Remember though that it is the combination of handler "post" and route that determines what happens on the server.
- Alter the method and action, if needed.
- Save and close the view.
accountRoute Router
- Find and open the routes > accountRoute file.
- Locate the login process route, not the route to deliver the login view.
- Remove the existing arrow function that was used during the validation testing.
- In its place add a controller-based function (that doesn't yet exist) that will process the login request. For example:
accountController.accountLogin - Be sure to wrap it in our error handler function.
- Be sure to add the login validation rules and check functions.
- When done, the route could look similar to this:
// Process the login request router.post( "/login", regValidate.loginRules(), regValidate.checkLoginData, utilities.handleErrors(accountController.accountLogin) ) - Ensure that no warnings or errors are reported by VSC.
- Save the file.
Account Controller File
- Open the accountController file from the "controllers" folder.
- At the top of the file, require the "jsonwebtoken" and "dotenv" packages:
const jwt = require("jsonwebtoken") require("dotenv").config() - Ensure that no warnings or errors are reported by VSC.
- Save the file.
The Login Process
As done during the registration process, the incoming data must be validated (already done in the route), collected in the controller, checked against the existing data in the database, processed, and the client informed. Collectively, this entire process is to "authenticate" a client.
- Scroll down to the bottom of the last function in the controller.
- Add a few empty lines.
- Leave at least one empty line between the existing function and the new function.
- Add the following function to the file:
/* **************************************** * Process login request * ************************************ */ async function accountLogin(req, res) { let nav = await utilities.getNav() const { account_email, account_password } = req.body const accountData = await accountModel.getAccountByEmail(account_email) if (!accountData) { req.flash("notice", "Please check your credentials and try again.") res.status(400).render("account/login", { title: "Login", nav, errors: null, account_email, }) return } try { if (await bcrypt.compare(account_password, accountData.account_password)) { delete accountData.account_password const accessToken = jwt.sign(accountData, process.env.ACCESS_TOKEN_SECRET, { expiresIn: 3600 * 1000 }) if(process.env.NODE_ENV === 'development') { res.cookie("jwt", accessToken, { httpOnly: true, maxAge: 3600 * 1000 }) } else { res.cookie("jwt", accessToken, { httpOnly: true, secure: true, maxAge: 3600 * 1000 }) } return res.redirect("/account/") } else { req.flash("message notice", "Please check your credentials and try again.") res.status(400).render("account/login", { title: "Login", nav, errors: null, account_email, }) } } catch (error) { throw new Error('Access Forbidden') } } - When done, add the new function to the module.exports object at the bottom of the file.
- Check for any warnings or errors.
- Save the file.
An explanation
- Lines 1-3 - a multi-line comment for the function.
- Line 4 - opens the async function, and accepts the request and response objects as parameters.
- Line 5 - builds the navigation bar for use in views.
- Line 6 - collects the incoming data from the request body.
- Line 7 - makes a call to a model-based function to locate data associated with an existing email (this function does not yet exist). Returned data, if any, is stored into the "accountData" variable.
- Line 8 - an "if" to test if nothing was returned.
- Line 9 - If the variable is empty, a message is set.
- Line 10 - the response object is used to return the login view to the browser.
- Lines 11-14 - data to be returned to the view. Note that the password is NOT part of the clientData object..
- Line 15 - closes the data object and render function.
- Line 16 - return control back to the project process.
- Line 17 - ends the "if" test.
- Line 18 - opens a try-catch block.
- Line 19 - uses the bcrypt.compare() function which takes the incoming, plain text password and the hashed password from the database and compares them to see if they match. (The plain text password is hashed using the same algorithm and secret used with the original password. The two hashes are compared to see if they match.) The resulting "TRUE" or "FALSE" is evaluated by the "if".
- Line 20 - If the passwords match, then the JavaScript delete function is used to remove the hashed password from the accountData array.
- Line 21 - the JWT token is created. The accountData is inserted as the payload. The secret is read from the .env file. When the token is ready, it is stored into an "accessToken" variable. Note! The token expiration must be set in seconds. Thus, 60 seconds x 60 minutes = 3600 seconds. In short, the token will be given a life of 1 hour, measured in seconds.
- Line 22 - checks to see if the development environment is "development" (meaning local for testing). If TRUE, a new cookie is created, named "jwt", the JWT token is stored in the cookie, and the options of "httpOnly: true" and "maxAge: 3600 * 1000" are set. This means that the cookie can only be passed through the HTTP protocol and cannot be accessed by client-side JavaScript. It will also expire in 1 hour.
- Line 24 - an "else", (meaning the the environment is not "development"), then the cookie is created with the same name and token, but with the added option of "secure: true". This means that the cookie can only be passed through HTTPS and not HTTP. This is a security measure to ensure that the cookie is not passed through an unsecured connection.
- Line 26 - ends the if - else structure to create the correct form of the cookie.
- Line 27 - the application then redirects to the default account route. This should deliver an account management view (you'll create this later in this activity).
- Line 28 - ends the if block begun on line 19.
- Line 29 - ends the try block and begins the catch block and stores any errors in the "error" variable.
- Line 30 - an error will occur if the passwords do not match and the token and cookie cannot be created. A new error is created and should be picked up by the error handler.
- Line 31 - ends the catch block.
- Line 32 - ends the function.
- Examine the code and ensure that no warnings or errors are present.
- Save the file.
.env File
In the code we indicated that the JWT secret would be stored in the .env file for use in the JWT creation process. Let's now create that "secret" value.
- Open the .env file from the root of the project.
- Beneath the existing name - value pairs add the following:
ACCESS_TOKEN_SECRET= - With the name in place, let's create the actual value for the ACCESS_TOKEN_SECRET. It is critical that the secret value be random, so that it cannot be guessed. We will use the same process that was used to create the SESSION_SECRET value in Unit 4:
- Open a new VSC terminal.
- Type "node", press "Enter".
- Type "
require('crypto').randomBytes(64).toString('hex')", press "Enter". - Copy the generated string, including the single quotes on either end, and paste it as the value for the ACCESS_TOKEN_SECRET.
- Type "Control + C" in the terminal window to exit Node. Reduce or close the terminal.
- Ensure that no warnings or errors are indicated in the .env file.
- Save the file.
Recall that the .env file is ONLY for testing locally. Because it is our intent to deploy this to a live server, the values that were just created need to also be present in our production environment. Let's do that now.
Render.com Environment Variables
- In a browser, login to render.com using your GitHub sign-in.
- In the dashboard, click on the web server.
- In the dashboard, click on Environment on the left-side of the screen.
- Click the Add Environment Variable button.
- In the first empty Key text box, copy and paste the name from your own .env file: ACCESS_TOKEN_SECRET
- Copy the actual value from the .env file for the ACCESS_TOKEN_SECRET, and paste it into the "value" textarea. Do NOT include the equal sign or surrounding quotes, just the string between the quotes.
- Visually compare everything from the two locations for identical values.
- Click the "Save Changes" button in the render.com window.
- When the save is complete, ensure that the new item has a small checkmark in the bottom right-corner of the "value" textarea.
- The value will be replaced by bullets to hide the value from anyone who may look over your shoulder. To view the value, hover over or click in the textarea.
The Account by Email Function
To be able to compare the submitted password against the stored password hash, the hash must be retrieved from the database. The means of doing so is by seeing if a matching email address exists and retrieve the entire record based on the email.
- Find and open the account-model file.
- Scroll to the bottom of the file and create empty lines between the last existing function and the module.exports line.
- Leave at least one empty line between the bottom of the previous function and the top of the new function.
- Add the function as shown below:
/* ***************************** * Return account data using email address * ***************************** */ async function getAccountByEmail (account_email) { try { const result = await pool.query( 'SELECT account_id, account_firstname, account_lastname, account_email, account_type, account_password FROM account WHERE account_email = $1', [account_email]) return result.rows[0] } catch (error) { return new Error("No matching email found") } } - Add the function to the module.exports object at the bottom of the file.
- Ensure that no warnings or errors exist in the file.
- Save the file.
An Explanation
- Lines 1-3 - a multi-line comment
- Line 4 - creates the function, makes it asyncronous, and adds the client_email as a parameter.
- Line 5 - begins a try - catch block.
- Line 6 - creates a variable to store the results of the query.
- Line 7 - a SQL SELECT query using the parameterized statement syntax. This is the first argument in the pool.query function.
- Line 8 - passes in the client_email, as an array element to replace the placeholder at the end of the SQL statement. This is the second argument in the pool.query function. That function ends on this line.
- Line 9 - sends the first record, from the result set returned by the query, back to where this function was called.
- Line 10 - ends the "try" block and begins the "catch" block, with the "error" variable to store any errors that are thrown by the "try" block.
- Line 11 - sends the error, if any, to the console for review.
- Line 12 - ends the "catch" block.
- Line 13 - ends the function.
Account Management View
As mentioned in the login code explanation, when a client is successful in a login attempt, they are redirected to an account management view. The view must be reachable via the default location "/" attached to the "/account" route. As a team exercise, do the following:
- Add the new default route for accounts to the accountRoute file.
- Create a new view in the views > account folder. It should have the normal components as all the other views and also be able to display a flash message and errors.
- For now, the only content will be "You're logged in".
- Build the function in the accountController to process the request and deliver the view.
- Once the view is in place, test the application to see if you can log in, and be successfully redirected to the account management view. Note: You may have to register a new account if you don't know the password of an already existing account, and the password must meet the requirements. Keep at it, working together, until everyone in your team is successful.
Check the JWT
With the login process in place and successful, it is time to add a means of checking the JWT to confirm that it matches the one we created. Let's add that middleware now.
The Middleware Function
- Open the utilities > index.js file.
- At the top of the file, add two require statements for the "jsonwebtoken" and "dotenv" packages:
const jwt = require("jsonwebtoken") require("dotenv").config() - Scroll down to the bottom of the page and add some blank lines below the last function and before the module.exports statement. Leave at least one blank line between the bottom of the previous function and the new function.
- Add the new function as shown:
/* **************************************** * Middleware to check token validity **************************************** */ Util.checkJWTToken = (req, res, next) => { if (req.cookies.jwt) { jwt.verify( req.cookies.jwt, process.env.ACCESS_TOKEN_SECRET, function (err, accountData) { if (err) { req.flash("Please log in") res.clearCookie("jwt") return res.redirect("/account/login") } res.locals.accountData = accountData res.locals.loggedin = 1 next() }) } else { next() } } - Ensure that no warnings or errors exist in the file.
- Save the file.
An Explanation
- Lines 1-3 - a multi-line comment for the function.
- Line 4 - begins the function and assigns it to the "checkJWTToken" property of the Util object. The function accepts the request, response and next parameters.
- Line 5 - an "if" check to see if the JWT cookie exists.
- Line 6 - if the cookie exists, uses the jsonwebtoken "verify" function to check the validity of the token. The function takes three arguments: 1) the token (from the cookie), 2) the secret value stored as an environment variable, and 3) a callback function.
- Line 7 - the JWT token from the cookie.
- Line 8 - the "secret" which is stored in the .env file.
- Line 9 - the callback function (which returns an error or the account data from the token payload).
- Line 10 - an "if" to see if an error exists.
- Line 11 - if an error, meaning the token is not valid, a flash message is created.
- Line 12 - the cookie is deleted.
- Line 13 - redirects to the "login" route, so the client can "login".
- Line 14 - ends the "if" started on line 10.
- Line 15 - adds the accountData object to the response.locals object to be forwarded on through the rest of this request - response cycle.
- Line 16 - adds "loggedin" flag with a value of "1" (meaning true) to the response.locals object to be forwarded on through the rest of this request - response cycle.
- Line 17 - calls the "next()" function directing the Express server to move to the next step in the application's work flow.
- Line 18 - ends the callback function and the jwt.verify function.
- Line 19 - ends the "if" started on line 5 and begins an "else" block.
- Line 20 - calls the next() function, to move forward in the application process. In this case, there is no JWT cookie, so the process moves to the next step.
- Line 21 - ends the "else" block.
- Line 22 - ends the middleware function, started on line 4.
- Close the file.
Apply the Middleware
Normally, middleware is applied in a "route", between the handler and the controller function. However, in certain instances, a piece of middleware can be "universal", and be applied to all routes. In this case, that is exactly what we want. If a token is present, we will validate it. If there is no token, we simply move on. Let's apply this now.
- Find and open the server.js file.
- Ensure that the utilities > index.js file has been required into the file, at the top of the file, (e.g. "
const utilities = require("../utilities/")"). - Find the "Middleware" section of the file and create an empty line at the bottom of the existing middleware listed there.
- Add the middleware function you just wrote in the empty line, as shown below:
app.use(utilities.checkJWTToken) - Ensure there are no warnings or errors in the file.
- Save the file.
Test Now
You've done a lot of work in the past several activities. Now is a good time to test and make sure things are working.
- Make sure all of the files are saved.
- Start the local, development server.
- Open your development server in a browser tab.
- Register a new account. Be sure to remember the email and password.
- Login using the new account.
- Check the cookies to see if you have a sessionId cookie and a JWt cookie.
- If everything worked, then you're a real pro! Pat yourself on the back.
- If things didn't work, carefully review your code, visit with members of your learning team or get help. But make sure it is working prior to moving on.
- Shut down the local server when done.
What's Next?
With the basics in place we can now use the cookie and JWT to authorize clients. We will do that in the next activity.
