Signup, login, and logout app in Deno using Postgres database

Mayank C
Tech Tonic

--

In this article, we’ll write a very simple signup, login, and logout web app in Deno using Oak, ETA, and Postgres database.

In other articles, we’ve seen signup, login, and logout app using Oak, ETA, and:

Features

This simple web app supports the following features:

  • Provides an index page for users to choose between login and signup
  • Provides a signup page where users can create new account
  • Provides a login page where users can log in
  • Provides a landing page where user profile of the logged-in user is displayed

Technologies

The front end is developed using:

  • Vanilla JavaScript
  • Bootstrap CSS library
  • Sweet alert

The back end is developed in Deno using:

  • Oak middleware framework
  • ETA (for templates)
  • DJWT (for JSON web tokens)
  • Postgres Database (installed locally)

Application

The application code is present in the following GitHub repo:

Here is the walk-through of the complete code present inside the application.

cfg.json

This file contains some basic settings like JWT secret, postgres access details, etc.

{
"serverPort": 8000,
"siteName": "Deno Demo using Postgres",
"jwtSecret": "secret",
"postgres": {
"user": "denouser",
"password": "denoPwd",
"host": "127.0.0.1",
"port": "5432",
"db": "deno-db"
}
}

deps.ts

The dependencies are listed in this file.

export {
Application,
Context,
Router,
send,
} from "https://deno.land/x/oak/mod.ts";
export { brightRed } from "https://deno.land/std/fmt/colors.ts";
export { configure, renderFile } from "https://deno.land/x/eta/mod.ts";
export {
create as createToken,
getNumericDate,
verify as verifyToken,
} from "https://deno.land/x/djwt/mod.ts";
export type { Header as JWTHeader } from "https://deno.land/x/djwt/mod.ts";
export { Client } from "https://deno.land/x/postgres/mod.ts";

server.ts

This is the main module of the application. A quick sandbox check is done before starting the router and the HTTP server.

import * as router from "./src/router.ts";
import { printError } from "./src/utils.ts";
import cfg from "./cfg.json" assert { type: "json" };
async function checkAccess() {
if (
(await Deno.permissions.query({ name: "read", path: "./public" })).state !==
"granted"
) {
printError("Missing read permission to ./public");
Deno.exit(1);
}
if (
(await Deno.permissions.query({
name: "net",
host: `0.0.0.0:${cfg.serverPort}`,
})).state !== "granted"
) {
printError(`Missing net permission to 0.0.0.0:${cfg.serverPort}`);
Deno.exit(1);
}
if (
(await Deno.permissions.query({
name: "net",
host: `${cfg.postgres.host}:${cfg.postgres.port}`,
})).state !== "granted"
) {
printError(
`Missing net permission to ${cfg.postgres.host}:${cfg.postgres.port}`,
);
Deno.exit(1);
}
}
await checkAccess();
router.start();

consts.ts

This file contains the interface & constant definitions:

export interface UserProfile {
name: string;
email: string;
password: string;
}
export interface UserData {
name: string;
email: string;
}
export interface LoginData {
email: string;
password: string;
}
export const TABLE_NAME = "users";

The Postgres table name is assumed to be users. If needed, the name can be changed in this file.

router.ts

This file contains the code to create an Oak application, router, and starting the HTTP server. The static files are rendered via ETA (template engine). The router uses signupController, loginController, and userController.

import { Application, Context, Router } from "../deps.ts";
import { configure, renderFile } from "../deps.ts";
import cfg from "../cfg.json" assert { type: "json" };
import { signupUser } from "./signupController.ts";
import { loginUser, logoutUser } from "./loginController.ts";
import { getUserProfile } from "./userController.ts";
export async function start() {
configure({ views: "./public" });
const router = new Router();
router
.get("/", (ctx) => ctx.response.redirect("./index.html"))
.post("/signup", (ctx) => signupUser(ctx))
.post("/login", (ctx) => loginUser(ctx))
.get("/logout", (ctx) => logoutUser(ctx))
.get("/userProfile", (ctx) => getUserProfile(ctx))
.get("/(.*\..*)", (ctx) => sendFile(ctx, { siteName: cfg.siteName }));
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
console.log(`Server started on ${cfg.serverPort}`);
await app.listen({ port: cfg.serverPort });
}
async function sendFile(ctx: Context, data: any) {
const fileName = ctx.request.url.pathname.split(".")[0] + ".eta";
const file = await renderFile(`${fileName}`, data) as string;
ctx.response.body = file;
ctx.response.headers.set("Content-Type", "text/html");
}

signupController.ts

This is the controller file for signup functionality. It uses signupService to save user record.

import { Context } from "../deps.ts";
import { attemptSignup } from "./signupService.ts";
export async function signupUser(ctx: Context) {
const result = ctx.request.body();
let req;
if (result.type !== "json") {
ctx.response.status = 415;
return;
} else {
req = await result.value;
}
if (!(req.name && req.email && req.password)) {
ctx.response.status = 400;
return;
}
try {
await attemptSignup(req);
ctx.response.status = 201;
} catch (e) {
ctx.response.status = 500;
if (e instanceof Deno.errors.AlreadyExists) {
ctx.response.body = "User already exists";
}
}
}

signupService.ts

This is the business logic for the signup service:

import { addRecord, getRecord } from "./db.ts";
import { UserProfile } from "./consts.ts";
import { getHash } from "./utils.ts";
export async function attemptSignup(userData: UserProfile) {
const rec = await getRecord(userData.email);
if (rec) {
throw new Deno.errors.AlreadyExists();
}
const dbRec = { ...userData };
dbRec.password = await getHash(userData.password);
await addRecord(userData.email, dbRec);
}

loginController.ts

This is the controller file for login and logout functionality. It uses loginService to validate user credentials, and generate/verify JWT.

import { Context } from "../deps.ts";
import { attemptLogin } from "./loginService.ts";
import { validateToken } from "./utils.ts";
export async function loginUser(ctx: Context) {
const result = ctx.request.body();
let req;
if (result.type !== "json") {
ctx.response.status = 415;
return;
} else {
req = await result.value;
}
if (!(req.email && req.password)) {
ctx.response.status = 400;
return;
}
try {
const token = await attemptLogin(req);
ctx.response.body = { token };
} catch (e) {
ctx.response.status = 401;
if (e instanceof Deno.errors.NotFound) {
ctx.response.body = "User does not exists";
} else if (e instanceof Deno.errors.PermissionDenied) {
ctx.response.body = "Wrong password";
}
}
}
export async function logoutUser(ctx: Context) {
const token = ctx.request.headers.get("authorization")?.split(" ")[1];
if (!token) {
ctx.response.status = 401;
return;
}
const email = await validateToken(token);
if (!email) {
ctx.response.status = 401;
return;
}
localStorage.setItem(token, "1");
ctx.response.status = 200;
}

When the user logs out, the invalidated JWT is saved in the local storage. This is to prevent users from reusing invalidated JWTs.

loginService.ts

This is the business logic for the login service:

import { getRecord } from "./db.ts";
import { LoginData } from "./consts.ts";
import { getHash, getToken } from "./utils.ts";
export async function attemptLogin(loginData: LoginData) {
const rec = await getRecord(loginData.email);
if (!rec) {
throw new Deno.errors.NotFound();
}
const passwordHash = await getHash(loginData.password);
if (passwordHash !== rec.password) {
throw new Deno.errors.PermissionDenied();
}
const token = await getToken(loginData.email);
return token;
}

userController.ts

This is the controller file for getting user profile. The supplied JWT is validated before returning the user profile.

import { Context } from "../deps.ts";
import { getUser } from "./userService.ts";
import { validateToken } from "./utils.ts";
export async function getUserProfile(ctx: Context) {
const token = ctx.request.headers.get("authorization")?.split(" ")[1];
if (!token) {
ctx.response.status = 401;
return;
}
if (localStorage.getItem(token)) {
ctx.response.status = 401;
return;
}
const email = await validateToken(token);
if (!email) {
ctx.response.status = 401;
return;
}
try {
const userData = await getUser(email);
ctx.response.body = { name: userData.name, email: userData.email };
} catch (e) {
ctx.response.status = 500;
if (e instanceof Deno.errors.NotFound) {
ctx.response.body = "User does not exists";
}
}
}

userService.ts

This is the business logic for the user profile service:

import { getRecord } from "./db.ts";export async function getUser(email: string) {
const rec = await getRecord(email);
if (!rec) {
throw new Deno.errors.NotFound();
}
return rec;
}

db.ts

This is the interface to the Postgres database.

import { Client } from "../deps.ts";
import { printError } from "./utils.ts";
import cfg from "../cfg.json" assert { type: "json" };
import { TABLE_NAME, UserProfile } from "./consts.ts";
let dbConn: Client;export async function addRecord(id: string, data: any) {
await dbConn.queryObject(
`INSERT INTO ${TABLE_NAME} (EMAIL, NAME, PASSWORD) VALUES ($1, $2, $3)`,
[id, data.name, data.password],
);
}
export async function getRecord(id: string) {
const result = await dbConn.queryObject(
`SELECT * FROM ${TABLE_NAME} WHERE EMAIL = $1`,
[id],
);
if (result && result.rows && result.rows.length > 0) {
return result.rows[0] as UserProfile;
}
}
async function checkTables() {
try {
await dbConn.queryObject(`SELECT * FROM ${TABLE_NAME}`);
} catch (e) {
await dbConn.queryObject(`CREATE TABLE ${TABLE_NAME} (
EMAIL VARCHAR(50) PRIMARY KEY,
NAME VARCHAR(50) NOT NULL,
PASSWORD VARCHAR(50) NOT NULL
)`);
console.log(
`${TABLE_NAME} table is created in ${cfg.postgres.db} database`,
);
}
}
async function connectDatabase() {
try {
dbConn = new Client({
user: cfg.postgres.user,
password: cfg.postgres.password,
hostname: cfg.postgres.host,
port: cfg.postgres.port,
database: cfg.postgres.db,
});
await dbConn.connect();
console.log("Connected to postgres database");
await checkTables();
} catch (e) {
printError("Unable to connect to database: " + e.message);
Deno.exit(1);
}
}
connectDatabase();

The users table is created if it doesn’t exists.

utils.ts

This contains utility functions to:

  • print error message
  • Generate SHA for a string
  • Create JWT
  • Validate JWT
  • Regularly remove expired invalidated JWTs from localStorage

The JWT signing key is taken from cfg.json and converted to CryptoKey using importKey.

import {
brightRed,
createToken,
getNumericDate,
JWTHeader,
verifyToken,
} from "../deps.ts";
import cfg from "../cfg.json" assert { type: "json" };
const jwtKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(cfg.jwtSecret),
{ name: "HMAC", hash: "SHA-512" },
true,
["sign", "verify"],
);
const header: JWTHeader = {
alg: "HS512",
typ: "JWT",
};
export function printError(msg: string) {
console.log(`${brightRed("ERROR:")} ${msg}`);
}
export async function getHash(src: string) {
const strBytes = new TextEncoder().encode(src);
const rawHash = await crypto.subtle.digest("SHA-1", strBytes);
const bufArr = new Uint8Array(rawHash);
const hexString = Array.from(bufArr).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
return hexString;
}
export async function getToken(email: string) {
const payload = {
exp: getNumericDate(60),
email,
};
return await createToken(header, payload, jwtKey);
}
export async function validateToken(token: string) {
try {
const payload = await verifyToken(token, jwtKey);
if (!payload) {
return;
}
return payload.email as string;
} catch (e) {}
}
//Cleanup expired JWTs
setInterval(async () => {
for (let i = 0; i < localStorage.length; i++) {
const token = localStorage.key(i);
if (!token) continue;
try {
await verifyToken(token, jwtKey);
} catch (e) {
localStorage.removeItem(token);
}
}
}, 10000);

Static files

There are four static files used by this application:

  • index.eta
  • signup.eta
  • login.eta
  • app.eta

These files are converted to HTML using ETA engine.

index.eta

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet" crossorigin="anonymous">
<title>Home - <%= it.siteName %></title>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
</head>
<body>
<div class="d-grid gap-2 col-6 mx-auto">
<h1>Welcome to <b><%= it.siteName %></b></h1>
<br>
</div>
<div class="d-grid gap-2 col-6 mx-auto">
<button class="btn btn-primary btn-lg" type="button"
onclick="window.location.href='../login.html'">Login</button>
<br>
<button class="btn btn-secondary btn-sm" type="button"
onclick="window.location.href='../signup.html'">Sign up</button>
</div>
</body>
</html>

signup.eta

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet" crossorigin="anonymous">
<title>Signup - <%= it.siteName %></title>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
async function doSignup() {
const name = document.getElementById("name").value;
if(!name) return swal.fire("Error", "Name is required", "error");
const email = document.getElementById("email").value;
if(!email) return swal.fire("Error", "Email is required", "error");
const password = document.getElementById("password").value;
if(!password) return swal.fire("Error", "Password is required", "error");
const res = await fetch(`${window.location.origin}/signup`, {
method: 'POST',
body: JSON.stringify({name, email, password}),
headers: {
'content-type': 'application/json'
}
});
if(res.status === 201) {
Swal.fire("Signup complete",
"Redirecting to login page",
"success"
).then(() => {
window.location.href = "../login.html";
});
} else {
Swal.fire({
icon: "error",
title: "Signup failed",
text: await res.text(),
});
}
}
</script>
</head>
<body>
<section class="vh-100">
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card shadow-2-strong" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h1><%= it.siteName %></h1>
<h3 class="mb-5">Sign up for a new account</h3>
<div class="form-outline mb-4">
<input type="name" id="name" class="form-control form-control-lg" required/>
<label class="form-label" for="name">Name</label>
</div>

<div class="form-outline mb-4">
<input type="email" id="email" class="form-control form-control-lg" required/>
<label class="form-label" for="userName">Email</label>
</div>

<div class="form-outline mb-4">
<input type="password" id="password" class="form-control form-control-lg" required/>
<label class="form-label" for="password">Password</label>
</div>

<button class="btn btn-primary btn-lg btn-block" onclick="doSignup()">Signup & Create Account</button>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>

login.eta

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet" crossorigin="anonymous">
<title>Login - <%= it.siteName %></title>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
async function doLogin() {
const email = document.getElementById("email").value;
if(!email) return swal.fire("Error", "Email is required", "error");
const password = document.getElementById("password").value;
if(!password) return swal.fire("Error", "Password is required", "error");
const res = await fetch(`${window.location.origin}/login`, {
method: 'POST',
body: JSON.stringify({email, password}),
headers: {
'content-type': 'application/json'
}
});
if(res.status === 200) {
window.localStorage.setItem("token", (await res.json()).token);
window.location.href = "../app.html";
} else {
Swal.fire({
icon: "error",
title: "Login failed",
text: await res.text(),
});
}
}
</script>
</head>
<body>
<section class="vh-100">
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card shadow-2-strong" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h1><%= it.siteName %></h1>
<h3 class="mb-5">Sign in to your account</h3>

<div class="form-outline mb-4">
<input type="email" id="email" class="form-control form-control-lg" required/>
<label class="form-label" for="userName">Email</label>
</div>

<div class="form-outline mb-4">
<input type="password" id="password" class="form-control form-control-lg" required/>
<label class="form-label" for="password">Password</label>
</div>

<button class="btn btn-primary btn-lg btn-block" onclick="doLogin()">Login</button>
<br>
<br>
<button class="btn btn-secondary btn-sm" type="button"
onclick="window.location.href='../signup.html'">Sign up & create account</button>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>

app.eta

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet" crossorigin="anonymous">
<title>Welcome back - <%= it.siteName %></title>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
loadUserProfile();
async function loadUserProfile() {
const token = localStorage.getItem("token");
if(!token) {
window.location.href = "../login.html";
return;
}
const res = await fetch(`${window.location.origin}/userProfile`, {
headers: {
'authorization': 'bearer '+token
}
});
if(res.status === 200) {
const resJson = await res.json();
document.getElementById('name').innerHTML = resJson.name;
document.getElementById('email').innerHTML = resJson.email;
} else {
window.location.href = "../login.html";
}
}
async function doLogout() {
const token = window.localStorage.getItem("token");
const res = await fetch(`${window.location.origin}/logout`, {
headers: {
'authorization': 'bearer '+token
}
});
if(res.status === 200) {
window.localStorage.removeItem("token");
window.location.href = "../login.html";
}
}
</script>
</head>
<body>
<section class="vh-100">
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card shadow-2-strong" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h1><%= it.siteName %></h1>
<h3 class="mb-5">Welcome back, <label id="name"/></h3>
<h5>Your email is <label id="email"/></h5>
<br>
<button class="btn btn-secondary btn-lg btn-block" onclick="doLogout()">Logout</button>
<br>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>

Testing

The application can be started by going to appopriate type in the base repo and running the runApp script.

starting backend

The postgres access details must be updated in cfg.json before starting the app

> git clone https://github.com/mayankchoubey/deno-signup-login-logout> cd deno-signup-login-logout> cd app-postgres-db> ./runApp
Watcher Process started.
Server started on 8000
Connected to postgres database
users table is created in deno-db database

Open http://localhost:8000 in a browser tab

Use signup to create new users

Use login page for logging into the app

Upon successful login, the user profile is shown

Use logout button to log out the user from the app.

That’s all about the simple signup, login, and logout app using Oak, ETA, and Mongo database.

In other articles, we’ve seen signup, login, and logout app using:

--

--