Registration Functionality
Learning objectives
- You know how to implement registration functionality in a web application.
Let's look into implementing registration functionality. We'll again use a layered architecture, where we have a service layer, a controller layer, and a route layer. The service layer will provide functionality for adding users to the database as well as for checking whether a user already exists, while the controller layer extracts the form data from the request and calls the service layer. The route layer is responsible for mapping the requests to the correct controller. In addition, we also have a registration form that allows the entering the details for registration.
We'll start with showing the registration form, after which we continue with handling the form data submitted through the registration form.
Showing registration form
We first create a form that has an input field for email, and two input fields for the password. The second input field for the password is used for verifying that the user types in the same password twice. A simple form with the required fields is as follows -- we assume that the form is stored into a file called registration.eta
that is in the templates
folder.
<!DOCTYPE html>
<html>
<head>
<title>Hello user management!</title>
</head>
<body>
<h1>Registration!</h1>
<form method="POST" action="/auth/registration">
<label for="email">Email:</label>
<input type="email" name="email" id="email" /><br />
<label for="password">Password:</label>
<input type="password" name="password" id="password" /><br />
<label for="verification">Password again:</label>
<input type="password" name="verification" id="verification" /><br />
<input type="submit" value="Register!" />
</form>
</body>
</html>
Let's create the application so that the form is available at the path "/auth/registration". We'll use the Eta template engine for rendering the form. The app.js
looks as follows.
import { Eta } from "https://deno.land/x/eta@v3.4.0/src/index.ts";
import { Hono } from "https://deno.land/x/hono@v3.12.11/mod.ts";
const eta = new Eta({ views: `${Deno.cwd()}/templates/` });
const app = new Hono();
app.get("/auth/registration", (c) => c.html(eta.render("registration.eta")));
Deno.serve(app.fetch);
When we make a query to the server, we see the form in the response.
curl localhost:8000/auth/registration
<!DOCTYPE html>
<html>
<head>
<title>Hello user management!</title>
</head>
<body>
<h1>Registration!</h1>
<form method="POST">
<label for="email">Email:</label>
<input type="email" name="email" id="email" /><br />
<label for="password">Password:</label>
<input type="password" name="password" id="password" /><br />
<label for="verification">Password again:</label>
<input type="password" name="verification" id="verification" /><br />
<input type="submit" value="Register!" />
</form>
</body>
</html>
Creating an auth controller
To avoid a situation where we'll end up having plenty of functionality in app.js
, let's extract functionality from app.js
to a separate file authController.js
. The authController.js
will have a function showRegistrationForm
that takes the context as a parameter and renders the registration form to the user.
We could have as well created a separate controller for the registration functionality, i.e.
registrationController.js
. However, as we'll later on build also functionality for logging in, theauthController.js
might be a better name for the combined functionality.
The authController.js
is as follows.
import { Eta } from "https://deno.land/x/eta@v3.4.0/src/index.ts";
const eta = new Eta({ views: `${Deno.cwd()}/templates/` });
const showRegistrationForm = (c) => c.html(eta.render("registration.eta"));
export { showRegistrationForm };
Now, we can also adjust the app.js
so that it maps the route to the showRegistrationForm
function from the auth controller. After the adjustment, the app.js
is as follows.
import { Hono } from "https://deno.land/x/hono@v3.12.11/mod.ts";
import * as authController from "./authController.js";
const app = new Hono();
app.get("/auth/registration", authController.showRegistrationForm);
Deno.serve(app.fetch);
Handling form submission
Let's next create the functionality for handling the form submission. For that, we need a route and a new function to the auth controller. Let's first add the route -- we'll assume that the auth controller will have a function called registerUser
. With the modification, the app.js
is as follows.
import { Hono } from "https://deno.land/x/hono@v3.12.11/mod.ts";
import * as authController from "./authController.js";
const app = new Hono();
app.get("/auth/registration", authController.showRegistrationForm);
app.post("/auth/registration", authController.registerUser);
Deno.serve(app.fetch);
When we try out making a post request to the path "/auth/registration", we see an error, as the function registerUser
does not exist yet. Let's create the first version of registerUser
function into it, which extracts the data from the request and returns the data as text. The first versio of the function is as follows.
const registerUser = async (c) => {
const body = await c.req.parseBody();
return c.text(JSON.stringify(body));
};
Now, when we submit the form to the server, we are shown the sent data as a response.
curl -X POST -d "email=test@test.net" -d "password=secret" -d "verification=secret" localhost:8000/au
th/registration
{"email":"test@test.net","password":"secret","verification":"secret"}%
Registration functionality
The registration functionality consists of three steps, which include checking that the provided passwords match, checking that the entered email is not aready in use, and storing the user and the password to the database.
Checking that the provided passwords match
Let's start by creating the functionality for checking whether the provided passwords match. If not, we return a text stating that the provided passwords did not match.
const registerUser = async (c) => {
const body = await c.req.parseBody();
if (body.password !== body.verification) {
return c.text("The provided passwords did not match.");
}
return c.text(JSON.stringify(body));
};
Now, if the passwords do not match, the response highlights this.
Note that we would also wish to have validation functionality. The validation functionality would check that the provided email is valid, and that the provided password is non-empty. As validation is discussed separately in the course, we omit this part to keep focus on the registration.
Checking whether the entered email is already in use
The next step is to create functionality for checking whether the entered email is already in use. To do this, let's create a userService.js
and add a function findUserByEmail
into it.
We'll use Deno KV for storing the users and for searching for them. Let's use "users" as the key, and the email as the subkey. Now, checking whether a user already exists works as follows.
const findUserByEmail = async (email) => {
const kv = await Deno.openKv();
const user = await kv.get(["users", email]);
return user?.value;
};
export { findUserByEmail };
Let's integrate the function to the authController.js
. With the functions from the user service imported as userService
, we can use the function as follows.
const registerUser = async (c) => {
const body = await c.req.parseBody();
if (body.password !== body.verification) {
return c.text("The provided passwords did not match.");
}
const existingUser = await userService.findUserByEmail(body.email);
if (existingUser) {
return c.text(`A user with the email ${body.email} already exists.`);
}
return c.text(JSON.stringify(body));
};
The above actually poses a security risk. In reality, registration functionality should not tell whether a specific email already exists, as this exposes users of services. We'll discuss this briefly in the chapter on web security basics.
Adding the user to the database
The final step is adding the user to the database. Here, we'll again use Deno KV for storing the user. Instead of storing the data as is to the database, we create a new user object that has the email and the hashed version of the password. To get the hash, we import the scrypt
library to the authController.js
.
If we would store the object as is, we would slip the plain-text passwords to the database as well.
In addition, we'll create a user identifier for the user using the crypto.randomUUID()
method that creates unique identifiers.
const user = {
id: crypto.randomUUID(),
email: body.email,
passwordHash: scrypt.hash(body.password),
};
await userService.createUser(user);
As a whole, the authController.js
would presently look as follows.
import { Eta } from "https://deno.land/x/eta@v3.4.0/src/index.ts";
import * as scrypt from "https://deno.land/x/scrypt@v4.3.4/mod.ts";
import * as userService from "./userService.js";
const eta = new Eta({ views: `${Deno.cwd()}/templates/` });
const showRegistrationForm = (c) => c.html(eta.render("registration.eta"));
const registerUser = async (c) => {
const body = await c.req.parseBody();
if (body.password !== body.verification) {
return c.text("The provided passwords did not match.");
}
const existingUser = await userService.findUserByEmail(body.email);
if (existingUser) {
return c.text(`A user with the email ${body.email} already exists.`);
}
const user = {
id: crypto.randomUUID(),
email: body.email,
passwordHash: scrypt.hash(body.password),
};
await userService.createUser(user);
return c.text(JSON.stringify(body));
};
export { registerUser, showRegistrationForm };
The registration functionality does naturally not yet work, as the createUser
function is missing from the user service. Let's add it next -- we'll store the user using Deno KV, identifying the user based on the email. The function createUser
would look as follows.
const createUser = async (user) => {
const kv = await Deno.openKv();
await kv.set(["users", user.email], user);
};
At this point, the whole userService.js
is as follows.
const createUser = async (user) => {
const kv = await Deno.openKv();
await kv.set(["users", user.email], user);
};
const findUserByEmail = async (email) => {
const kv = await Deno.openKv();
const user = await kv.get(["users", email]);
return user?.value;
};
export { createUser, findUserByEmail };
Now, when we try to add a user for the first time, the response contains the user details. On the second time, however, the response highlights that the user already exists.
curl -X POST -d "email=test@test.net" -d "password=secret" -d "verification=secret" localhost:8000/auth/registration
{"email":"test@test.net","password":"secret","verification":"secret"}%
curl -X POST -d "email=test@test.net" -d "password=secret" -d "verification=secret" localhost:8000/auth/registration
A user with the email test@test.net already exists.%
Let's next look into creating the functionality for logging in to the system.
Validation?
In practice, we would also include validation functionality to the registration form. The validation functionality would check that the provided email is valid, and that the provided password is non-empty. As validation is discussed separately, it is omitted here to keep the focus on user management.