Authorization
Learning objectives
- Knows the term authorization.
- Knows how to add authorization functionality into an application.
In the previous examples, we learned methods for authentication, that is, working with credentials used to authenticate (or login) the user to the application. In the examples so far, once a user has authenticated, the user has access to all the resources that were not available for the non-authenticated users.
This situation, where a user can access everything after authentication, needs to be changed in certain cases. If there are resources that should be available only to a specific user, then, everyone should not be able to access those resources. As an example, after logging into a bank application, one should not be able to access the bank accounts of all users of the bank.
The term authorization refers to the process of verifying that the user has the rights to perform the actions that the user is trying to perform. In addition, it also refers to the process of defining access rights to specific resources in an application.
Let's start with an example of broken authorization, after which we look into authorization functionality.
An example of broken authorization
Let's study an application that provides the possibility of storing and reading personal notes. Each note has a title and content, and each note is associated with a specific user. The application uses two tables; users
and notes
, which are created as follows (the table users
is the same that we used previously).
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(320) NOT NULL,
password CHAR(60) NOT NULL
);
CREATE UNIQUE INDEX ON users((lower(email)));
CREATE TABLE notes (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
note TEXT NOT NULL,
user_id INTEGER REFERENCES users(id)
);
In the table notes
, the column user_id
is used to link each note to a specific user.
The application provides a page for listing notes and also contains a form that can be used to create a new note. The application also contains a page for reading individual notes. When a user makes a GET request to the path '/notes', the user is shown a form and a list of titles. Each note title is a link to a separate page, where the details of that note can be read.
The following functions outline the relevant functionality -- in addition, the application has login and registration function that work the same way as described in the previous part. When a user logs in to the application, information about the user -- including the id
of the user -- is stored to the session. This allows making queries to the database that shows only the contents for that particular user. A middleware function is used to ensure that users who access the path '/notes' have authenticated.
// (this comes after the middleware that takes sessions into use)...
app.use(async ({ request, response, state }, next) => {
if (request.url.pathname.startsWith("/notes")) {
if (await state.session.get("authenticated")) {
await next();
} else {
response.status = 401;
}
} else {
await next();
}
});
const showNotes = async ({ render, state }) => {
const userId = (await state.session.get("user")).id;
const rows = await sql`SELECT * FROM notes WHERE user_id = ${userId}`;
render("notes.eta", { notes: rows });
};
const postNote = async ({ request, response, state }) => {
const body = request.body();
const params = await body.value;
const title = params.get("title");
const note = params.get("note");
const userId = (await state.session.get("user")).id;
await sql`INSERT INTO NOTES (title, note, user_id) VALUES (${title}, ${note}, ${userId}`;
response.redirect("/notes");
};
const showNote = async ({ params, render }) => {
const rows = await sql`SELECT * FROM notes WHERE id = ${params.id}`;
const obj = rows[0];
render("note.eta", obj);
};
router.get("/notes", showNotes);
router.post("/notes", postNote);
router.get("/notes/:id", showNote);
// ..
The Eta files are as follows. The first file is notes.eta
, while the second file is note.eta
. The notes.eta
page contains a form and creates a list of notes that is shown to the user. We also see how a textarea
that allows typing in multiple rows of text can be used.
<h1>Notes</h1>
<h2>Add note</h2>
<form method="POST">
<input type="text" name="title"/>
<textarea name="note"></textarea>
<input type="submit" value="Add note!"/>
</form>
<h2>Current notes</h2>
<ul>
<% it.notes.forEach((note) => { %>
<li><a href="/notes/<%= note.id %>"><%= note.title %></a></li>
<% }); %>
</ul>
<h1><%= it.title %></h1>
<p><%= it.note %></p>
The application uses the previously created functionality for authentication. When we try out the application, it seems to work as expected. We first authenticate to the application (using an account created earlier), after which we add a note to the application. Then, we list the notes available in the application -- we correctly see one, which we then view.
curl -v -X POST -d "email=my@mail.net&password=easy" http://localhost:7777/login
// ...
< set-cookie: sid=ece978bb-8359-466f-95ae-3e0ab8f1bcf5; path=/; httponly
Authentication successful!%
curl -X POST -d "title=secret¬e=note" -H "Cookie: sid=ece978bb-8359-466f-95ae-3e0ab8f1bcf5" http://localhost:7777/notes
Redirecting to /notes.%
curl -H "Cookie: sid=ece978bb-8359-466f-95ae-3e0ab8f1bcf5" http://localhost:7777/notes
<h1>Notes</h1>
<h2>Add note</h2>
<form method="POST">
<input type="text" name="title"/>
<textarea name="note"></textarea>
<input type="submit" value="Add note!"/>
</form>
<h2>Current notes</h2>
<ul>
<li><a href="/notes/5">secret</a></li>
</ul>%
curl -H "Cookie: sid=ece978bb-8359-466f-95ae-3e0ab8f1bcf5" http://localhost:7777/notes/5
<h1>secret</h1>
<p>note</p>%
As shown below, users who are not authenticated are not able to access the notes. Instead, they see the status code 401, indicating that they are not authorized.
curl -v http://localhost:7777/notes/5
// ...
< HTTP/1.1 401 Unauthorized
// ...
When we authenticate as someone else than my@mail.net
, to whom the note with id 5
belongs to, we do not see the note 5
in the list that is shown on our personal notes page.
curl -v -X POST -d "email=secret@email.net&password=easy" http://localhost:7777/login
// ...
< set-cookie: sid=40f67355-2347-4c8d-b628-ba949a05dc23; path=/; httponly
// ...
Authentication successful!%
curl -H "Cookie: sid=40f67355-2347-4c8d-b628-ba949a05dc23" http://localhost:7777/notes
<h1>Notes</h1>
<h2>Add note</h2>
<form method="POST">
<input type="text" name="title"/>
<textarea name="note"></textarea>
<input type="submit" value="Add note!"/>
</form>
<h2>Current notes</h2>
<ul>
</ul>%
Unfortunately, as the authorization in the application is broken, we can also access personal notes from other users. In the example below, we view two notes that we definitely should not have access to.
curl -H "Cookie: sid=40f67355-2347-4c8d-b628-ba949a05dc23" http://localhost:7777/notes/3
<h1>Breakfast order</h1>
<p>Almas Caviar,Bollinger Vieilles Vignes,Gold Leaf Bread</p>%
curl -H "Cookie: sid=40f67355-2347-4c8d-b628-ba949a05dc23" http://localhost:7777/notes/5
<h1>secret</h1>
<p>note</p>%
In the above case, authorization is broken as the application does not verify whether the currently authenticated user should be able to access a note or not.
Controlling access to resources
The above example demonstrates broken access control on the user level. Notes created by a user should be available only to the user who has created the note. In the case of the above example, the issue lies in the function showNote
. Instead of simply retrieving a note based on the path variable id, the function should also verify that the note belongs to the currently authenticated user.
This can be done by using the user id available in the session when making the database query. In the example below, the issue has been fixed -- if the user tries to access a note that either does not exist or that does not belong to the user, the server responds with the status code 401.
const showNote = async ({ params, render, response, state }) => {
const userId = (await state.session.get("user")).id;
const rows = await sql`SELECT * FROM notes WHERE id = ${params.id} AND user_id = ${userId}`;
if (rows.length > 0) {
const obj = rows[0];
render("note.eta", obj);
} else {
response.status = 401;
}
};
In practice, there are multiple approaches for controlling access to resources. In principle, in all of the approaches, the objective is to verify whether the current user should be able to perform the current operation.
Access can be controlled on multiple levels. In our case, we look at three different approaches. First, we can verify whether the user has authenticated or not, and restrict access to resources based on authentication -- this is what we did with the middleware that verified that only users who are authenticated can access paths that start with /notes
. Then, we can verify whether the user should have access to the current resource that the user is trying to access -- this is what we did above; given that resources are identified on a user basis, we can limit access to the resources. Third, we can also verify whether groups of users should have access to a set of resources.
Role-based access control
Roles can be used to define user groups. A role could be, for example, a user or an admin. Let's continue with the notes application, and add roles to the app. In this case, we create two tables for roles; roles
and user_roles
. The first table can be used to define the roles that are available in the application, and the second table maps users to those roles. In practice, there is a many-to-many association between users and roles, and the table user_roles
is a join table.
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(20) NOT NULL
);
CREATE TABLE user_roles (
role_id INTEGER REFERENCES roles(id),
user_id INTEGER REFERENCES users(id)
);
We next add a role called ADMIN
to the application.
INSERT INTO roles (name) VALUES ('ADMIN');
And, add the role admin to the user with the email my@mail.net
.
INSERT INTO user_roles (role_id, user_id)
VALUES
(
(SELECT id FROM roles WHERE name = 'ADMIN'),
(SELECT id FROM users WHERE email = 'my@mail.net')
);
Now, the user with the email my@mail.net
has an admin role. Next, we update the login functionality to also load user's roles, after which we create functionality that is exclusive to those with the admin role.
const authenticate = async ({ request, response, session }) => {
const body = request.body();
const params = await body.value;
const email = params.get("email");
const password = params.get("password");
// check if the email exists in the database
const rows = await sql`SELECT * FROM users WHERE email = ${email}`;
if (rows.length === 0) {
response.status = 401;
return;
}
// take the first row from the results
const userObj = rows[0];
const hash = userObj.password;
const passwordCorrect = await bcrypt.compare(password, hash);
if (!passwordCorrect) {
response.status = 401;
return;
}
// retrieve the roles for the authenticated user
const rolesRes = await sql`SELECT name FROM roles
JOIN user_roles ON roles.id = user_roles.role_id
WHERE user_roles.user_id = ${userObj.id}`;
await session.set("authenticated", true);
await session.set("user", {
id: userObj.id,
email: userObj.email,
roles: rolesRes.flatMap((x) => x),
});
response.body = "Authentication successful!";
};
In the above authenticate
function, roles are retrieved from the database only after a user has logged in. The variable rows
of the query result contains each role name in a separate array -- to flatten this structure, we use the flatMap
function from JavaScript.
Now, whenever a user authenticates, the session will contain the roles that the user has. If no roles have been defined for the user, the role list is empty.
The following middleware demonstrates limiting access to resources based on whether the user has an ADMIN role or not. If the requested url starts with the path /admin
, the user must have authenticated and the user must have the role ADMIN
.
app.use(async({request, response, state}, next) => {
if (request.url.pathname.startsWith('/admin')) {
if (session
&& await state.session.get('authenticated')
&& (await state.session.get('user')).roles.includes('ADMIN')) {
await next();
} else {
response.status = 401;
}
} else {
await next();
}
});
Now, when we try to access the path /admin
with a user who does not have the role ADMIN
, we see the status code 401, indicating that the user has no rights to access the path.
curl -v -X POST -d "email=secret@email.net&password=easy" http://localhost:7777/login
// ...
< set-cookie: sid=cdf0debe-7555-4fcb-a68a-88c670086c8a; path=/; httponly
// ...
Authentication successful!%
curl -v -H "Cookie: sid=cdf0debe-7555-4fcb-a68a-88c670086c8a" http://localhost:7777/admin
// ...
< HTTP/1.1 401 Unauthorized
// ...
If we access the path with a user who has the role ADMIN
, access is granted. In the example below, status code 404 is shown as nothing is mapped to the route.
curl -v -X POST -d "email=my@mail.net&password=easy" http://localhost:7777/login
// ...
< set-cookie: sid=70d64286-20d4-4b35-8020-1f60d5d5a8d9; path=/; httponly
// ...
Authentication successful!%
curl -v -H "Cookie: sid=70d64286-20d4-4b35-8020-1f60d5d5a8d9" http://localhost:7777/admin
// ...
< HTTP/1.1 404 Not Found
// ...
In practice, as the size of the application increases, we would start defining access control lists, which would define the paths and required roles. As an example, an access control list could look as follows. The following example defines an access control list that considers the path, whether the user has authenticated, and whether the user has specific roles.
const acl = [
{
path: '/admin',
needsAuthentication: true,
expectedOneOfRoles: ['ADMIN']
},
{
path: '/login',
needsAuthentication: false
},
{
path: '/register',
needsAuthentication: false
},
{
path: '/notes',
needsAuthentication: true
}
];
With the above access control list, we could combine the two middlewares that were used to control access to the application. A middleware using the above access control list could look as follows. Effectively, the middleware studies the request and goes over each rule in the access control list, comparing the rule with the request. If a rule matches the request, access is granted (await next()
is called), and if not, the user is sent the status code 401.
const aclMiddleware = async ({ request, response, state }, next) => {
const pathname = request.url.pathname;
for (const aclRule of acl) {
// if the requested path is not related to this access control rule,
// continue from the next rule
if (!pathname.startsWith(aclRule.path)) {
continue;
}
// if there is no need to authenticate,
// continue with showing the content
if (!aclRule.needsAuthentication) {
await next();
return;
}
// if the user is not authenticated, deny access
if (!(await state.session.get("authenticated"))) {
response.status = 401;
return;
}
// if the path does not expect any roles, grant access to it
if (!aclRule.expectedOneOfRoles) {
await next();
return;
}
// if the user has one of the expected roles, grant access,
// otherwise deny access.
const user = await state.session.get("user");
if (aclRule.expectedOneOfRoles.some((r) => user.roles)) {
await next();
return;
} else {
response.status = 401;
return;
}
}
// deny all others!
response.status = 401;
};
app.use(aclMiddleware);
In practice, paths in access control lists could also be defined using regular expressions. In addition, the access control lists could account for -- for example, the used request methods.