1. The user verifies their email
  2. They may now log in
  1. If the user exists, a session is created and the user is brought to a page that displays their basic info
  2. The user can refresh the page and they will still be logged in.

Let’s set up our directories

Run the following:

mkdir email_auth_tutorial && cd email_auth_tutorial
npx create-react-app email_auth_tutorial_frontend --template typescript
mkdir email_auth_tutorial_backend

Frontend

We are now going to add the user interfaces on the frontend.

cd email_auth_tutorial_frontend && npm start
npm i --save react-router-dom
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import React from 'react';
import './App.css';
import { Link } from 'react-router-dom';
function App() {
return (
<div>
<div>
<div>
<Link to="/login">Log in</Link>
</div>
<div>
<Link to="/signup">Sign up</Link>
</div>
<div>
You are not logged in.
</div>
</div>
</div>
);
}
export default App;
  • Signup.tsx
  • CheckYourEmail.tsx
  • VerifyEmail.tsx
import React from 'react';function Login() {
return (
<div>
log in
</div>
);
}
export default Login;
import React from 'react';function Signup() {
return (
<div>
sign up
</div>
);
}
export default Signup;
import React from 'react';function CheckYourEmail() {
return (
<div>
Please check your email for a verification link.
</div>
);
}
export default CheckYourEmail;
import React from 'react';function VerifyEmail() {
return (
<div>
Verifying...
</div>
);
}
export default VerifyEmail;
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Login from './Login';
import Signup from './Signup';
import CheckYourEmail from './CheckYourEmail';
import VerifyEmail from './VerifyEmail';
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/check-your-email" element={<CheckYourEmail />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Sign up interface:

Our sign up page needs the following qualities:

  • Ability to sign up via name, email, password
  • Ability to validate the user’s inputs
  • A button that submits the user’s inputs to the backend
import React from "react";
import { Link, useNavigate } from "react-router-dom";
function SignUp() {const navigate = useNavigate();const [formState, setFormState] = React.useState({
username: "",
email: "",
password: ""
})
const onChange = (e: React.ChangeEvent<HTMLInputElement>, type: string) => {
e.preventDefault();
setFormState({
...formState,
[type]: e.target.value
})
}
const getErrors = (): string[] => {
const errors = [];
if (!formState.username) errors.push("Name required");
if (!formState.email) {
errors.push("Email required");
} else if (!/^\S+@\S+\.\S+$/.test(formState.email)) {
errors.push("Invalid email");
}
if (!formState.password) errors.push("Password required");
return errors;
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const errors = getErrors();
for (let error of errors) {
alert(error);
}
if (errors.length) return;
const response = await fetch('http://localhost:8000/api/v1/signup', {
method: "post",
headers: {
// needed so express parser says the body is OK to read
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: formState.username,
email: formState.email,
password: formState.password
})
})
if (response.status !== 200) {
// TODO: Add more detailed error handling.
return alert("Something went wrong.");
}
navigate("/check-your-email");
}
return (
<div>
<h1>Sign up</h1>
<form onSubmit={onSubmit}>
<div>
<label>Name</label>
<input onChange={(e) => onChange(e, "username")} type="text" value={formState.username} />
</div>
<div>
<label>Email</label>
<input onChange={(e) => onChange(e, "email")} type="email" value={formState.email} />
</div>
<div>
<label>Password</label>
<input onChange={(e) => onChange(e, "password")} type="password" value={formState.password} />
</div>
<button type="submit">Submit</button>
</form>
<div>
<Link to="/login">Already have an account? Log in</Link>
</div>
<div>
<Link to="/">Back to home</Link>
</div>
</div>
);
}
export default SignUp;
import React from "react";
import { Link, useNavigate } from "react-router-dom";
function Login() {const navigate = useNavigate();const [formState, setFormState] = React.useState({
email: "",
password: ""
})

const onChange = (e: React.ChangeEvent<HTMLInputElement>, type: string) => {
e.preventDefault();
setFormState({
...formState,
[type]: e.target.value
})
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const response = await fetch('http://localhost:8000/api/v1/login', {
method: "post",
credentials: "include",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: formState.email,
email: formState.email,
password: formState.password
})
})
if (response.status !== 200) {
return alert("Something went wrong");
}
navigate("/");
}
return (
<div>
<h1>Log in</h1>
<form onSubmit={onSubmit}>
<div>
<label>Email</label>
<input onChange={(e) => onChange(e, "email")} type="email" value={formState.email} />
</div>
<div>
<label>Password</label>
<input onChange={(e) => onChange(e, "password")} type="password" value={formState.password} />
</div>
<button>Submit</button>
</form>
<div>
<Link to="/signup">Don't have an account? Sign up</Link>
</div>
<div>
<Link to="/">Back to home</Link>
</div>
</div>
);
}
export default Login;
import React from "react";
import { Link } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
enum VerificationState {
pending = "pending",
invalid = "invalid",
already_verified = "already_verified",
success = "success"
}
function VerifyEmail() {const [searchParams] = useSearchParams();const [verificationState, setVerificationState] = React.useState(VerificationState.pending);const attemptToVerify = async () => {
const code = searchParams.get("code");
const email = searchParams.get("email");
if (!code || !email) {
return setVerificationState(VerificationState.invalid)
}
const response = await fetch('http://localhost:8000/api/v1/verify-email', {
method: "post",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
body: JSON.stringify({
code,
email
})
})
const result = await response.json();
if (result.data === "already verified") {
return setVerificationState(VerificationState.already_verified)
}
setVerificationState(VerificationState.success);
}

React.useEffect(() => {
attemptToVerify()
}, []);
switch (verificationState) {
case VerificationState.success: {
return (
<div>
Email verified. Please <Link to="/login">sign in</Link>.
</div>
)
}
case VerificationState.already_verified: {
return (
<div>
This link is expired. Please <Link to="/login">log in</Link>.
</div>
)
}
case VerificationState.invalid: {
return (
<div>
This link is invalid link.
</div>
)
}
default: {
return (
<div>
Verifying...
</div>
);
}
}
}export default VerifyEmail;
import React from "react";
import { Link } from "react-router-dom";
import './App.css';
function App() {const [user, setUser] = React.useState(null);React.useEffect(() => {
fetch('http://localhost:8000/api/v1/user', {
method: "get",
credentials: "include",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
})
.then(response => response.json())
.then(result => {
console.log(result, 'result')
if (result.data) {
setUser(result.data)
}
});
}, []);
const logout = async () => {
const response = await fetch('http://localhost:8000/api/v1/logout', {
method: "get",
credentials: "include",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
})
const result = await response.json();
if (result.data) {
setUser(null)
}
}
return (
<div className="App">
{
!user && (
<div>
<div>
<Link to="/login">Log in</Link>
</div>
<div>
<Link to="/signup">Sign up</Link>
</div>
<p>Not logged in</p>
</div>
)
}
{
user && <div>{user}<button onClick={logout}>logout</button></div>
}
</div>
);
}
export default App;

Back end

Let’s start to flesh out our back end. In a separate terminal, navigate such that your present working directory is email_auth_tutorial_backend. Run

npm init -y
touch index.js
npm i --save express
const express = require('express');
const app = express();
const port = 8000;app.listen(port, () => console.log('App listening on port ' + port));
node index.js

Routes

Just as we had routes on the frontend, we now need some backend routes. These will be the endpoints that we hit from the frontend. Let’s get the basic infrastructure set up. Create a new file routes.js from the root directory, and add the following to it:

var express = require('express');
var router = express.Router();
router.route('/ping')
.get((req, res) => {
res.status(200).send({
data: "pong"
})
});
module.exports = router;
const express = require('express');
const routes = require("./routes");
const app = express();app.use('/api/v1/', routes);const port = 8000;app.listen(port, () => console.log('App listening on port ' + port));

Database

The next thing we want to do is set up the database. If you don’t have Mongo set up on your machine, follow the instructions to download it here:

brew services start mongodb-community@5.0
"C:\Program Files\MongoDB\Server\5.0\bin\mongod.exe" --dbpath="c:\data\db"
mongo
use SampleDatabase;
npm i --save body-parser cors express-session mongoose nodemailer passport passport-local passport-local-mongoose passport-mongoose
/*  EXPRESS SETUP  */const express = require('express');
const app = express();
app.use(express.static(__dirname));const session = require('express-session');
const mongoose = require('mongoose');
const passport = require('passport');
// helps parse the body so the server can understand JSON.
const bodyParser = require('body-parser');
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// sets up session management
const expressSession = session({
secret: 'this_is_a_secret_that_should_not_be_disclosed',
resave: false,
saveUninitialized: false,
});
app.use(expressSession);
// Lots of behind-the-scenes configurations
// happen here to reduce the amount of code
// you have to write in order for express and passport to communicate with each other.
app.use(passport.initialize());
app.use(passport.session());
// cross-origin resource sharing set up
var cors = require('cors');
var corsOption = {
origin: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
exposedHeaders: ['x-auth-token', "Access-Control-Allow-Credentials"]
};
app.use(cors(corsOption));
const port = 8000;
app.listen(port, () => console.log('App listening on port ' + port));
const routes = require("./routes");// database setup
mongoose.connect("mongodb://localhost/SampleDatabase",
{ useNewUrlParser: true, useUnifiedTopology: true });
app.use('/api/v1/', routes);
  • Passport.js: This is a standard 3rd party library that can handle most of the authentication/authorization logic on our behalf.
  • A database: We need a place to store user data. In this tutorial, we’ll be using Mongo because it’s the easiest one to configure.
  • Session manager: Once a user logs in and they refresh the page, how do we verify that they’re the correct person? In this tutorial, we’re going to use Passport’s default session strategy which is to use cookies. We don’t want to touch the cookies ourselves though, so let’s delegate that to the session manager.
  • Cross-origin resource sharing (CORS): Since the website is located at localhost:3000 and our server is located at localhost:8000, the two pages can’t interact by default. This is a default security measure enacted by your browser. We need to configure our server to accept requests originating from localhost:3000.

Bind MongoDB with Passport

The next thing we should do is to take measures to connect the data from our datastore in MongoDB to our passport.js system. Create a new file user.js

  1. Bind passport with our user schema
  2. Create an exportable UserDetails model that we can use elsewhere to interact with the user collection in MongoDB.
  3. Configure the email/password authentication strategy
  4. Add a deserializer/serializer for users. A user is serialized when you first log in and the user data is retrieved from the database. A user is deserialized when an authenticated API call is made; we want the deserialized version of the user so we have access to its methods.
const mongoose = require('mongoose');
const passportLocalMongoose = require('passport-local-mongoose');
const passport = require('passport');
const PassportLocalStrategy = require('passport-local');
const UserDetail = new mongoose.Schema({
username: String,
email: String,
password: String,
emailVerified: Boolean,
emailVerificationHash: String
});
UserDetail.plugin(passportLocalMongoose);const UserDetails = mongoose.model('user', UserDetail, 'user');passport.use(new PassportLocalStrategy(
function(username, password, done) {
UserDetails.findOne({ email: username }, function (err, user) {
if (err) { return done(err); }
if (!user) { return done(null, false); }
user.authenticate(password, function(err,model,passwordError){
if(passwordError){
console.log(err)
done(null, false);
} else if(model) {
done(null, user);
}
})
});
}
));
passport.serializeUser(function(user, done) {
console.log('serializing user: ');
console.log(user);
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
UserDetails.findById(id, function(err, user) {
done(err, user);
});
});
module.exports = { UserDetails };

You’ve got mail

We need a service that can send mail. Though this is just a basic demo, let’s still go through the process of setting up a place to hold all of our services, run:

mkdir services && touch services/mail.service.js
const nodemailer = require("nodemailer");const sendMail = async ({
subject,
to,
html,
text
}) => {
let transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: '<YOUR_GMAIL_USERNAME<@gmail.com',
pass: '<YOUR_PASSWORD_DONT_SAVE_THIS_TO_GITHUB>' // naturally, replace both with your real credentials or an application-specific password
}
});
return await transporter.sendMail({
from: '"The Demo App Team" <foo@example.com>', // sender address
to, // list of receivers
subject, // Subject line
text, // plain text body
html, // html body
});
}
module.exports = {
sendMail
}

Authentication endpoints

We are now going to add endpoints for the following:

  1. Login
  2. Logout
  3. Email Verification
var express = require('express');
var router = express.Router();
const { UserDetails } = require("./user");
const { sendMail } = require('./services/mail.service');
const passport = require('passport');
const generateHash = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
const sendVerificationEmail = async ({
to,
verificationHash,
email
}) => {
const linkAddress = `https://localhost:3000/verify-email?code=${verificationHash}&email=${email}`
return await sendMail({
to,
subject: "Please verify your email",
text: `Hi John,
\nPlease click on the following link to verify your email:
\n${linkAddress}
\n
\nSincerely,
The Demo App Team`,
html: `Hi John,
<br>Please click on the following link to verify your email:
<br><a href="${linkAddress}">${linkAddress}</a>
<br><br>Sincerely,
<br>The Demo App Team`
});
}
router.route('/ping')
.get((req, res) => {
res.send({
data: "pong"
})
});
router.route('/verify-email')
.post(async (req, res) => {
UserDetails.findOne({
email: req.body.email,
emailVerificationHash: req.body.code
}, (err, account) => {
if (err) {
console.log(err);
return res.status(400).send({ error: err.message });
}
if (account.emailVerified) {
return res.status(200).send({data: "already verified"});
}
UserDetails.updateOne({_id: account.id}, {
$set: {
emailVerified: true
}
}, (_err) => {
if (_err) {
console.log(_err);
return res.status(400).send({ error: _err.message });
}
return res.status(200).send({data: "done"})
})

})
});
function checkAuthentication(req,res,next){
if(req.isAuthenticated()) {
next();
} else{
res.status(403).send({reason: "unauthenticated"});
}
}
router.get('/user', checkAuthentication, (req, res) => {
console.log(req.session, 'sesion', req.user)
res.status(200).send({data: req.user.email})
})
router.route('/logout')
.get((req, res) => {
req.logout()
res.status(200).send({data: "OK"})
})
router.route('/login')
.post((req, res, next) => {
passport.authenticate('local',
(err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(400).send({data: "no user"})
}
req.logIn(user, function(err) {
if (err) {
return res.status(400).send({data: "error"})
}
return res.status(200).send({data: "ok"})
});
})(req, res, next);
})
router.route('/signup')
.post((req, res) => {
const emailVerificationHash = generateHash();
UserDetails.register({
username: req.body.username,
email: req.body.email,
emailVerified: false,
emailVerificationHash
}, req.body.password, async (err, account) => {
if (err) {
console.log(err);
return res.status(400).send({ error: err.message });
}
await sendVerificationEmail({
to: req.body.email,
verificationHash: emailVerificationHash,
email: req.body.email
})
res.status(200).send({ data: "ok" });
})
});
module.exports = router;
  • Check your email for the link and verify your email
  • Log in to your account
  • In the home page, refresh the page and notice your email appears in the page (this is the user info we sent over from the backend)
  • Log out

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Alexander Leon

Alexander Leon

I help developers with easy-to-understand technical articles encompassing System Design, Algorithms, Cutting-Edge tech, and misc.