This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.
To download the projects, go to the GitHub repository learning-web-dev-code
and follow the instructions there.
I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.
In this chapter, we learn how to write a server that lets users log in via passwords. That process is called authentication.
basic-http-authentication/
This project is a web server that protects the pages it serves via passwords. From now on, when you see a path, it’s always relative to the directory basic-http-authentication/
.
A parameter default value is an assignment we make to a parameter. It is performed whenever the parameter is missing:
function add(x = 0, y = 0) {
return x + y;
}
assert.equal(
add(3, 4), 7
);
assert.equal(
add(3), 3 // default for `y` is used
);
assert.equal(
add(), 0 // defaults for `x` and `y` are used
);
import()
Normal import
statements are called static imports:
To make importing more flexible, JavaScript also lets us dynamically import modules via import()
(an operator that is used like a function). It works like a namespace import and returns a Promise for an object whose properties are the exports of the imported module:
> const {getHashForText} = await import('./server/crypto-tools.js');
> await getHashForText('abc')
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
crypto-tools.js
is a utility library in our current project. We use object destructuring to access its export getHashForText
. It’s an asynchronous function – which is why we use await
to retrieve its result.
Note that we also await
the result of import()
and that its argument can be any string; it’s not fixed.
Base64 is a way to encode binary data as text. That enables us to store binary data in formats that only support text (such as HTML or JSON).
The 64 in “Base64” means that it uses 64 “printable” (visible) characters in the ASCII character range (Unicode code points 0–127). Therefore, one text character encodes 6 bits (64 is 2^6^).
Let’s use the Node.js REPL to explore how Base64 works:
> const {toBase64, fromBase64} = await import('./server/crypto-tools.js');
> toBase64(Uint8Array.of(0, 1, 2))
'AAEC'
> fromBase64('AAEC')
Uint8Array.of(0, 1, 2)
First, we import functions from module crypto-tools.js
that let us convert to and from Base64. Uint8Array
is a so-called typed array for bytes (unsigned 8-bit integers). Think of it as an array for binary data. We convert binary data to a Base64 string via toBase64()
. Then we go the other direction via fromBase64()
.
There are many good authentication libraries available via npm. However, almost all of them are meant to be used with a backend framework such as Express. In this chapter, we use a very simple authentication method: Basic HTTP authentication. It has pros and cons:
Authorization
header field With Basic HTTP authentication, the browser sends the following header field to authenticate a user when requesting a given web page:
Authorization: Basic VXNlcjpQYXNzd29yZA==
The text at the end contains user name and password, encoded in Base64 (we treat a string as if it were binary data):
> const {fromBase64} = await import('./server/crypto-tools.js');
> new TextDecoder().decode(fromBase64('VXNlcjpQYXNzd29yZA=='))
'User:Password'
We use class TextDecoder
to convert a series of bytes in UTF-8 format to a JavaScript string.
This kind of encoding is so simple that it does not count as encryption and is certainly not safe. It only prevents humans from accidentally seeing a password.
Normally, the browser does not add the Authorization
header field to requests. If it requests a protected page without it then the server sends the following response (without content):
401 Unauthorized
WWW-Authenticate: Basic realm="..."
The name of the realm identifies groups of pages on a server that have the same users and passwords. The 401
response causes the browser to ask the user for a user name and a password. The browser then resends its previous request but adds the Authorization
header field this time. When receiving that request, the browser checks user name and password: If correct, it serves the web page. Otherwise, it serves a 401
response again.
After a successful authorization, most browsers store user name and password and use them for all pages in the same directory or below. That saves users from constantly having to enter user name and password. Therefore, if a user logs into:
https://example.com/protected/index.html
Then the browser automatically logs them into:
https://example.com/protected/help.html
https://example.com/protected/docs/info.html
One interesting challenge with Basic HTTP authentication is that we can’t really log out because we can’t tell the browser to forget a given user: It will simply keep sending the authentication data. Therefore, we need to resort to a trick: We make a request via fetch()
and use incorrect authentication data. As a consequence, the browser replaces the previous (correct) data with the new (incorrect) data. The server rejects the latter and the browser asks the user to log in again.
This is what that looks like in site/index.html
:
document.querySelector('a').addEventListener(
'click',
async (event) => {
event.preventDefault();
await fetch(
'/index.html',
{
headers: {
'Authorization': 'Basic ' + btoa('logout:logout'),
},
}
);
// Reload page so that new user data is displayed
location.reload();
}
);
Note that we do all of the above in the client. The server cannot log out a user.
Sources of this section:
How would we store passwords in a file or a database? Maybe in a file passwords.json
that looks like this?
{
"Kane": "Rosebud",
"Wagstaff": "Swordfish"
}
User “Kane” has the password “Rosebud” etc. There is only one problem with doing this: Should someone get their hands on passwords.json
they have free access to all the accounts protected by the passwords stored in it. Let’s see how we can prevent that.
Our initial idea is: Let’s not store the password, let’s store a hash of the password. Roughly, a hash is derived from input data and should by a good (often shorter) representation of it:
Hashes are often used in programming to detect if a long file has changed or to compute IDs for pieces of data.
We can also use hashes to encode passwords. The idea is:
A key requirement when hashing passwords is that going from input to hash is relatively quick but going from hash to input is very difficult. The longer the output, the longer it must take to do that. Otherwise, someone could easily convert hashes back to passwords and we wouldn’t have gained anything. Interestingly, as computers have become faster, password hashes have become longer so that attacks keep being difficult.
Additionally, the lengths of the hashes must be the same for all passwords so that attackers can’t gain any knowledge by looking at the lengths.
We can experiment with hashing in the Node.js REPL:
> const { getHashForText } = await import('./server/crypto-tools.js');
> await getHashForText('Rosebud')
'1727f9eedb5128f0cdf892ad31eac287ea16e261fd7ff9007037807c3ebc02dc'
> await getHashForText('Rosebud')
'1727f9eedb5128f0cdf892ad31eac287ea16e261fd7ff9007037807c3ebc02dc'
As we can see, there is no random element to getHashForText()
: If the argument is the same, it produces the same output. It computes its result asynchronously, which is why we must await
what it returns.
Alas, hashing passwords still isn’t safe enough:
passwords.json
, they can use a table that contains popular passwords and their hashes. That will given them access to many accounts.passwords.json
. And we don’t want anyone to know that.Thankfully, there is a way to fix that: Every time a user sets a new password, we create a random string and use that as salt. Salt is simply another parameter for hashing: Using it is mostly equivalent to appending the salt to the password before hashing it. If we want to check if a given password is correct, we need the salt so that we can replicate the previous hashing. That’s why we keep both salt and hashed password in passwords.json
.
The random factor of the salt makes it virtually impossible to use a table for well-known passwords because, per password, such a table would have to record the hashes for all possible salts. Additionally, thanks to the salts, two users with the same password will virtually never have the same hash.
This is how server/password-tools.js
stores passwords in a Map (which is eventually saved to passwords.json
):
import { generateSalt, hash, toBase64 } from './crypto-tools.js';
// ...
export async function setPassword(passwordsMap, user, password) {
const saltBin = generateSalt();
const hashedPasswordBin = await hash(password, saltBin);
passwordsMap.set(
user,
{
salt: toBase64(saltBin),
hashedPassword: toBase64(hashedPasswordBin),
}
);
}
saltBin
and hashedPasswordBin
are binary data (instances of Uint8Array
) – which is why we use toBase64()
to convert them to strings so that we can easily store them as JSON. hash()
is an async function.
passwords.json
This is what our passwords.json
looks like now:
{
"Kane": {
"salt": "...",
"hashedPassword": "..."
},
"Wagstaff": {
"salt": "...",
"hashedPassword": "..."
}
}
This is how server/password-tools.js
checks if a password is valid:
import { verify, fromBase64 } from './crypto-tools.js';
// ...
export async function isValidPassword(passwordsMap, user, password) {
const entry = passwordsMap.get(user);
if (entry === undefined) {
return false;
}
const { salt, hashedPassword } = entry;
const saltBin = fromBase64(salt);
const hashedPasswordBin = fromBase64(hashedPassword);
return await verify(password, hashedPasswordBin, saltBin);
}
It redoes the hashing and compares the result with the stored hash via the async function verify()
. We can’t use the strict equality operator ===
to do that because it finishes quickly if its operands are not equal. That gives attackers valuable information. Therefore, verify()
always takes the same length of time.
crypto-tools.js
async? There are two reasons for why most of the cryptography functionality in crypto-tools.js
is asynchronous:
basic-http-authentication/
In professional web sites, managing passwords is quite involved – e.g., when you register a new user, you provide an email address and the server sends an email to that address in order to confirm that it exists and that the user has access to its emails.
For our hobby project, we take a simpler approach: We manage a JSON file with passwords via a shell command. The server uses that JSON file to authenticate. We add new users manually. As an exercise, we let users change their passwords via the web interface.
This is what the top level of this project looks like in the file system:
package.json
site/
: the files served by the server
index.html
: the client side of the projectdata/
passwords.json
: the passwordsserver/
: contains the code for a server that protects all pages it serves via Basic HTTP authentication. When it starts, it reads its passwords from data/passwords.json
.cli/
passman.js
: shell command for adding, changing and deleting passwordsWe only need package.json
for one dependency of the server. This time, the client side of our project has no npm dependencies and therefore does not need a build step. It’s simply the file site/index.html
package.json
This is what the package.json
of this project looks like:
{
"type": "module",
"scripts": {
"start": "node --watch server/server.js",
"test": "node --test \"server/**/*_test.js\""
},
"dependencies": {
"http-auth": "^4.2.1"
}
}
Package http-auth
is a dependency of the server – which helps it with authentication. There are two package scripts:
start
lets us run the server via npm start
.test
runs the tests in directory server/
.server/password-tools.js
We have already seen the following two functions:
setPassword(passwordsMap, user, password)
isValidPassword(passwordsMap, user, password)
Module password-tools.js
additionally lets us read a passwords Map from a JSON file and write it to one. The following code shows the latter:
// Sibling of parent directory of this module
const DATA_DIR = new URL('../data/', import.meta.url);
if (!existsSync(DATA_DIR)) {
throw new Error('Could not find data directory: ' + DATA_DIR);
}
const PASSWORDS_FILE = new URL('passwords.json', DATA_DIR);
export const readPasswordsMap = async (fileUrl = PASSWORDS_FILE) => {
if (existsSync(fileUrl)) {
const json = await fs.readFile(fileUrl, 'utf-8');
const passwordsObject = JSON.parse(json);
return new Map(Object.entries(passwordsObject)); // (A)
} else {
return new Map();
}
};
In line A, we use Object.entries()
to convert the plain object from the JSON data to a Map. If we omit the parameter of readPasswordsMap()
then the parameter default value kicks in and the passwords are read from PASSWORDS_FILE
.
cli/passman.js
cli/passman.js
is the shell command for managing passwords. If we invoke it without arguments, we get the following help text:
Subcommands:
passman.js set <passwords.json> <user> <password>
Adds or changes a given password.
Creates the passwords file if it doesn’t exist yet.
passman.js rm <passwords.json> <user>
Removes a password entry.
passman.js check <passwords.json> <user> <password>
Checks if the given password is correct.
passman.js ls <passwords.json>
Lists all users in the passwords file.
This is an example of using it:
% node cli/passman.js set data/passwords.json Kane Rosebud
% node cli/passman.js check data/passwords.json Kane Rosebud
true
% node cli/passman.js check data/passwords.json Kane WRONG
false
Since passwords.json
doesn’t exist yet, the subcommand set
creates it.
server/string-tools.js
Mode string-tools.js
exports a single helper function:
export function insertVars(vars, string) {
for (const [key, value] of Object.entries(vars)) {
string = string.replaceAll('{{' + key + '}}', value);
}
return string;
}
vars
is an object that maps variable names to strings. insertVars()
inserts those strings into its parameter string
and returns it. To insert a variable, we put its name in double braces ({{varName}}
). string-tools_test.js
demonstrates how that works:
import * as assert from 'node:assert/strict';
import { test } from 'node:test';
import { insertVars } from './string-tools.js';
test('insertVars()', () => {
assert.equal(
insertVars({ user: 'Robin' }, 'Hello {{user}}!'),
'Hello Robin!'
);
assert.equal(
insertVars({}, 'Hello {{user}}!'),
'Hello {{user}}!'
);
});
server/server.js
This is our authenticating server:
import auth from 'http-auth';
import * as http from 'node:http';
import { handleFileRequest } from './handle-file-request.js';
import { isValidPassword, readPasswordsMap } from './password-tools.js';
const hostname = 'localhost';
const port = 3000;
const passwordsMap = await readPasswordsMap();
const basic = auth.basic( // (A)
{ realm: 'Users' },
async (user, password, callback) => {
const isValid = await isValidPassword(passwordsMap, user, password);
callback(isValid);
}
);
http
.createServer(
basic.check( // (B)
async (request, response) => { // (C)
await handleFileRequest(
request, response,
{ user: request.user }
);
}
)
)
.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
})
;
In line A, we set up the basic authentication object basic
. The function we pass to the method that creates that object checks if a given password is correct and passes the result to a callback
. That is an alternative to Promises when it comes to handling asynchronous results.
In line B, we use method basic.check()
to create a callback for createServer()
. That callback only invokes the function (line C) it wraps if authentication succeeds.
server/handle-file-request.js
handle-file-request.js
is nearly identical to the same module from the previous chapter, but there is one difference: It has the additional parameter vars
. If it serves and HTML file, it inserts those vars before serving it:
if (contentType === 'text/html') {
let content = await fs.readFile(fileUrl, 'utf-8');
content = insertVars(vars, content);
response.end(content);
return;
}
site/index.html
The client side of our project looks like this:
<h1>Authenticated: {{user}}</h1>
<p><a href="">Log out</a></p>
<script type="module">
document.querySelector('a').addEventListener(
'click',
// ...
);
</script>
We have already seen the code for logging out, which is why it is omitted here. Note the variable {{user}}
in the first line.
The following two exercises are challenging but should teach you a lot:
todo-list-server/
: Add the functionality of project basic-http-authentication
so that each user has their own todo list.
/api
being inside /
, the browser automatically authenticates.basic-http-authentication/
: Provide a user interface for changing the password.
todo-list-server