In this article, I’ll show you how to build a user login system with the node framework express.js and Fauna.
What is Fauna?
Fauna is a global cloud database created to integrate with the Jamstack and modern serverless architecture. Fauna is a flexible, developer-friendly, transactional database delivered as a secure and scalable cloud API with native GraphQL.
Fauna is a NoSQL serverless database, so you don’t have to worry about database provisioning, scaling, sharding, replication, or correctness.
Let’s dive right into building our user login system!
Prerequisites
To take full advantage of this article, you need to have the following installed on your laptop.
- Node.js
- Have access to one package manager such as npm or yarn
- Access to Fauna dashboard
- Have a basic knowledge of Node.js, Express, and Handlebars.js or a view engine.
About the App
In this app, we will have six routes:
- Signup Route: In this route, a new user is created using necessary credentials, e.g. email, username, and password, and then the user is logged into their account and shown their dashboard page.
- Sign In Route: In this route, the user logs in by providing sign-up details. If successful, the user is shown their dashboard page, not if not. The user is shown the providing with necessary error message depending on what caused the sign-in to be unsuccessful.
- Dashboard Route: In this route, after a successful sign-up or sign-in, the user is shown a customised dashboard page welcoming the user to their page.
- Sign Out Route: This is the route to sign a user out of their account.
- Delete Account Route: In our app, a user is allowed to delete an account created. If successful, the user’s account is deleted from our Fauna database.
- Confirm Token Route: This route allows users to confirm their email address before successfully redirecting to the dashboard page.
Before we create our routes, we need to create our Fauna database that we’ll use for the app following the steps below.
Step 1: Set Up Our Fauna Database
To get started with our app, we need to create our database for the app in the Fauna dashboard.
You can create a Fauna account here.
In your dashboard, click on the ”Create Database” button, provide a name for your database, and click create.
Step 2: Generating your Fauna API key
The Fauna secret key connects fauna to an application or script, unique to a database.
We need to create a Fauna API key to connect the Fauna database to our app. To do this, go to the security settings on the left side of the screen.
When you click on save in the last image, it will generate a new API key for you. Copy the API key and keep the key somewhere safe as you can’t have access to that key in the dashboard again
Step 3: Creating a Fauna collection
We need to create a Fauna collection that we will use within our code.
A collection is simply a grouping of documents(rows) with the same or a similar purpose. A collection acts similarly to a table in a traditional SQL database.
In our app, we will have only a collection for users. The user collection is where we will store our user data.
To create the collection, click on the database you created, click on “New Collection”, enter your chosen collection name then click save.
You can create as many collection names as you wish to use in your app.
Step 4: Creating a Fauna Index
Indexes are used to quickly find data without searching every document in a database collection every time a database collection is accessed. Indexes can be created using one or more fields of a database collection. To create a Fauna index, click on the indexes section in the left part of your dashboard.
In our app, we will only create one index, which is the user_by_email index.
The user_by_email index is what we’ll use to get a user’s data with a given email. This index needs to be unique, so the collection doesn’t have duplicate emails.
Creating the project and installing dependencies
First, we need to initialise your project in npm; type the following in your terminal to do so:
npm init
This will prompt some questions asked, you can answer them appropriately, and when this is done, a package.json file is created for you.
Next, we need to install the required dependencies. Type the following in your terminal:
npm install express faunadb dotenv express-handlebars
Structuring the App
- The routes folder is where we have our routes.js file for defining our routes.
- The views folder is where our pages will be created and, in this case, handlebars.
- The app.js file is where we will set up our server.
- The configure.js file is where we will set up our app’s middleware.
- The fauna.js file is where we will connect our Fauna database to our app and define functions used to create-user, login-user, and some other functions we will use in our routes.
- The sendMail.js file is where we will use nodemailer to send confirmation emails to verify a user after a user creates an account.
Building Our Application
- Configuring and running the server: In your app.js, write the following code to set up your server.
var express = require('express'),
config = require('./configure'),
path = require("path"),
app = express();
app = config(app);
app.set("port", process.env.PORT || 5000);
app.set("views", path.join(__dirname, "views"));
var server = app.listen(app.get("port"), function () {
console.log("Server up: http://localhost:" + app.get("port"));
});
- In your config file, which is configure.js, write the following code to configure your middleware functions.
var createError = require('http-errors');
routes = require('./routes/routes')
express = require('express'),
session = require('express-session'),
path = require('path'),
cookieParser = require('cookie-parser'),
logger = require('morgan'),
dotenv = require('dotenv').config(),
flash = require('connect-flash'),
exphbs = require('express-handlebars'),
relativeTime = require('dayjs/plugin/relativeTime'),
dayjs = require('dayjs');
module.exports = function (app) {
dayjs.extend(relativeTime);
app.engine('.hbs', exphbs.create({
defaultlayout: 'main',
layoutsDir: path.join(__dirname, './views/layouts'),
partialsDir: path.join(__dirname, './views/partials'),
helpers: { timeago: () => dayjs(new Date().toString()).fromNow()},
extname: '.hbs',
}).engine);
app.set('view engine', 'hbs');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(flash());
app.use(session({
secret: process.env.SECRET,
resave: true,
saveUninitialized: true,
maxAge: 600
}))
app.use(function(req,res,next){
app.locals.isLoggedIn = req.session.user ? true : false
next();
})
app.use(routes)
app.use('/public/', express.static(path.join(__dirname, './public')));
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
return app;
};
- Create a .env file in your route folder and fill it with the following:
NODE_LOGIN_FAUNA_KEY=’your generated fauna API key’
SECRET=’your app secret key’
EMAIL=’your email’
PASSWORD=’your email password’
The email you input here is what you will use to send confirmation emails to new users, so ensure it’s a valid and functional one.
Creating our Fauna helper functions
To create a user, log in a user, update a user verification status that we will use to know if a user is verified or not, and delete a user in Fauna. Fauna has provided helper functions to help with that. Paste the following in your code to help with that:
var dotenv = require('dotenv').config(),
faunadb = require('faunadb'),
bcrypt = require('bcrypt'),
q = faunadb.query;
let Client = new faunadb.Client({ secret: process.env.NODE_LOGIN_FAUNA_KEY });
exports.createUser = async (email, username, password) => {
password = bcrypt.hashSync(password, bcrypt.genSaltSync(10)) // generates a hash for the password
let data
try {
data= await Client.query(
q.Create(
q.Collection('Users'),
{
data: {email, username, password, isVerified: false}
}
)
)
if (data.username === 'BadRequest') return // if there's an error in the data creation it should return null
} catch (error) {
console.log(error)
return
}
const user = data.data
user.id = data.ref.value.id // attaches the ref id as the user id in the client, it will be easy to fetch and you can guarantee that it's unique
return user
}
exports.getUserByEmail = async (email) => {
try {
const user = await Client.query(
q.Get(
q.Match(
q.Index('user_by_email'),
email
)
)
)
return user.data
} catch {
return // return null if there is any error.
}
}
exports.loginUser = async (email, password) => {
try {
let userData = await Client.query(
q.Get(
q.Match(q.Index('user_by_email'), email.trim())
)
)
userData.data.id = userData.ref.value.id
if (bcrypt.compareSync(password, userData.data.password)) return userData.data
else return
} catch (error) {
return
}
}
exports.updateUser = (userId) => {
const user = Client.query(
q.Update(
q.Ref(q.Collection('Users'), userId),
{
data: {
isVerified: true
}
}
)
)
.then((result) => result.data)
.catch((err) => console.log(err.message))
}
exports.deleteUser = (userId) => {
const user = Client.query(
q.Delete(
q.Ref(q.Collection('Users'), userId)
)
)
.then((result) => console.log(result))
.catch((err) => console.log(err.message))
}
Above, we created five Fauna helper functions which are:
- createUser: It takes in an email, username and password, generates a hash for the password using bcrypt, saves the user’s information to false and set isVerified to false until the user confirms the account, then the isVerified will be set to true
- getUserByEmail: It retrieves a user by email using the index we created earlier.
- loginUser: It logs a user in using the email and password.
- updateUser: It updates a user’s information which in this case, updates a user’s verified status.
- deleteUser: Deletes a user from the Fauna database.
Defining Routes
To define all possible routes we discussed earlier for the app, create a routes.js file in the routes folder type the following:
var express = require('express'),
hbs = require('express-handlebars'),
router = express.Router(),
auth = require('../fauna'),
{sendMail} = require('../sendMail'),
dotenv = require('dotenv').config(),
jwt = require('jsonwebtoken');
router.get('/', (req, res) => {
return res.render('index');
});
// Sign Up Routes
router.get('/signup/', (req, res) => {
return res.render('auth/signup')
})
router.post('/signup/', async (req, res) => {
try {
const {username, email, password, confirm_password} = req.body
if (password !== confirm_password) {
return res.render('auth/signup', {
error: 'Passwords do not match'
})
}
const user = await auth.createUser(email, username, password)
let token = jwt.sign(user, process.env.SECRET, {expiresIn: 600})
if (user) {
req.session.user = user
// Send verification mail for confirmation of account using Nodemailer
sendMail(email, `Hi ${username}!,\nTo verify your account, please click on the link below and signin again. \nhttp://${req.headers.host}/confirm/${token}`, 'Verify your account')
req.session.save((err) => {console.log(err)})
return res.redirect('/dashboard/')
}
}
catch (error){
return res.render('auth/signup', {
error: error.message
})
}
return res.render('auth/signup', {
error: 'Username or Email is chosen'
})
})
// Sign In Routes
router.get('/signin/', function(req, res) {
return res.render('auth/signin');
});
router.post('/signin/', async (req, res) => {
try {
const {email, password} = req.body
const user = await auth.loginUser(email, password)
if (user) {
req.session.user = user
req.session.save((err) => console.log(err))
return res.redirect('/dashboard/')
}
}
catch (error){
return res.render('auth/signin', {
error: 'Invalid Email or Password'
})
}
return res.render('auth/signin', {
error: 'Invalid Email or Password'
})
});
// Dashboard Routes
router.get('/dashboard/', async (req, res) => {
try {
if (req.session.user) {
const user = req.session.user
return res.render('dashboard', {user})
}
}
catch (error){
return res.render('dashboard', {
error: error.message
})
}
return res.redirect('/')
});
// Sign Out Routes
router.get('/signout/', (req, res) => {
req.session.destroy((err) => console.log(err))
return res.redirect('/signin/')
})
// Delete Account Route
router.delete('/delete-account/', async (req, res) => {
if (req.session.user) {
auth.deleteUser(req.session.user.id)
req.session.destroy();
return res.status(200).json({success: 'Data Deleted Successfully' })
} else {
return res.status(400).json({error: 'Not Successfully Deleted'})
}
})
// confirm token and update user verification status
router.get('/confirm/:token', (req, res) => {
const token = req.params.token
jwt.verify(token, process.env.SECRET, (err, decoded) => {
try {
if (err) {
return res.render('auth/signup', {
error: 'Invalid Token'
})
}
user = auth.updateUser(decoded.id, {isVerified: true})
if (user) {
req.session.user = user
return res.redirect('/dashboard')
}
} catch (error) {
return res.render('auth/signup', {
error: 'Invalid Token'
})
}
})
})
module.exports = router;
In the dashboard route, we added user session after sign-in for easy logging in for a period of time provided the user hasn’t signed out yet.
In the signout route, the user session is deleted, and the user is redirected back to the home page.
In the delete route, the user is deleted from our Fauna database with the deleteUser function we created in our fauna.js file.
In the confirm route, we generated a unique token using jsonwebtoken, sending an email using nodemailer with a redirect link containing the unique token with the link redirecting to the dashboard page and confirming the user email address. Then the user’s isVerified status will be set to true.
Sending Mails
I’ve been mentioning sending mails, but for the mail to actually be sent, we need a helper function to send a mail after a user has created an account. We would create a sendMail.js file. Type the following below:
var config = require('./configure'),
express = require('express'),
router = express.Router(),
nodemailer = require('nodemailer');
exports.sendMail = async (to, html, subject) => {
var transporter = nodemailer.createTransport({
service: 'gmail',
port:465,
auth: {
user: process.env.EMAIL,
pass: process.env.PASSWORD
}
});
var mailOptions = {
from: process.env.EMAIL,
to: to,
subject: subject || 'Confirmation of Account',
html: html
};
transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
return {error: error.message}
} else {
console.log('Email sent: ' + info.response);
return {success: info.response}
}
});
transporter.close()
}
Testing Our App
Like I said earlier, our front end is built with handlebars. You can choose any view engine you want to use. Let’s test the routes we’ve built:
- SignUp Route
We signup with our credentials (email, username and password), It redirects to the dashboard page with a welcome message but saying the user should check his/her email for verification instructions.
Let’s confirm if the user has been created in the database
We’ll then confirm if our email has been sent.
Ps: For you to enable nodemailer to send mails using your provided email, you have to configure your Gmail settings to “allow less secure apps” and enable Recaptcha.
- Signin Route
We’ll click the link sent to the mail and check if it redirects to the sign-in page.
We’ll sign in again and see the new welcome message of a verified user.
- Signout Route
We’ll click the sign out button and sign out of the account.
- Delete Route
We sign in again and test the delete account feature. The user will be completely deleted from the Fauna database.
Lastly, We’ll now confirm from our database if the user has been deleted.
As we can see above, the only user we created has been deleted.
Conclusion
This article has built a web application that logs users in and logs users out using two exciting technologies, Fauna and Expressjs. The source code for this project is available on Github. If you enjoyed this article, please share it with your friends who will need it. You can reach me on Twitter if you have any questions.
Written in connection with the Write with Fauna Program.
Top comments (3)
if(data.username === 'BadRequest') return // ... should return null
If that and few other lines should return null, it's maybe a good idea to return null instead of undefined.
Great article Sodiq! But here's a suggestion, I think it'd be more secure and advisable to use Oauth2 for the Gmail service for nodemailer..
Alright, thank you