Skip to the content.

HackerAPI’s architecture

Frameworks and libraries used

This whole application was built off of Node.js!

Routing

We use Express.js to help us with routing.

Authentication library

We use a LocalStrategy within Passport.js to authenticate a login session. Given an active session, the user must have a token called session in their cookies which was given by the api in order to continue accessing the api.

For authorization, we decided to write a custom RBAC-esque strategy. Check below for specifics on how it works, and how to work with it, and how to use it when extending this project.

Testing

We use Mocha with Chai to test our routes and services.

Authorization

Before you read this section, it’s important to understand the difference between Authentication and Authorization. The first asks the question: “Do we know who you are?” (i.e. are you logged in?), whereas the second asks the question: “Do you have sufficient permissions to be making this request?”.

We use a custom implementation for authorization, which relies heavily on the design of Role-based access control.

Definitions

Route

A Route, which is a component of a Role, is defined by its uri path, its query parameters in the uri path, the HTTP verb used to access it, and and _id:

{
    "uri": "/api/sponsor/",
    "requestType": "POST",
    "_id": "000000010000000000000000"
}

Role

A Role is a collection of Routes, and a unique name (such as hacker, or sponsor, or my-custom-role). This collection of Routes should approximately encapsulate the set of actions that a user of the api should be able to do. For example, a Hacker might want to have access to GET /api/hacker/507f1f77bcf86cd799439011 (get their own information), or POST /api/hacker/ (create their hacker document):

{
    "Name": "sponsor",
    "routes": [{
        "uri": "/api/sponsor/",
        "requestType": "POST"
    }]
}

Note here that the parameter routes is a list of Route objects described in the previous section.

In our code-base, we have a auto-generated list of single roles and a custom list of user roles

single roles are roles that give permissions for one specific URI and request type. Every route has a single role.

{
    "_id": "000000010000000000000000",
    "name": "postAccount",
    "routes": [{
        "uri": "/api/account/",
        "requestType": "POST",
        "_id": "000000010000000000000000",
    }]
}

user roles are syntactically the same as custom roles that give permissions for a collection of URIs and request types. These are essentially collections of routes that a given user needs access to in order to properly function with the API.

{
    "_id": "000000020000000000000000",
    "name": "Account",
    "routes": [{
            "uri": "/api/account/",
            "requestType": "POST",
            "_id": "000000010000000000000000",
        },
        {
            "uri": "/api/account/:SELF",
            "requestType": "GET",
            "_id": "000000030000000000000000",
        },
        // etc.
    ]
}

Note that for each route in the user role, you have access to the single role document’s _id!

RoleBinding

A RoleBinding is a mapping between an account and a Role. In this way, we can say that an account has a certain set of allowed actions, defined by the Role. An account can have a RoleBinding to multiple Roles.

{
    "accountId": "507f191e810c19729de860ea",
    "roles": ["54759eb3c090d83494e2d804", "51325eb3c090d83494e2d702"],
}

note here that roles is a list of ObjectIds, since the role is also a document in the database.

How to write a Route document

In order to encapsulate permissions based on the actual params of a given route (such as the resource id in /api/hacker/507f1f77bcf86cd799439011), we use two placeholders: :self, and :all. These two placeholders replace the location of the given parameter in the route; in the example above, we would translate the uri to either /api/hacker/:self, or /api/hacker/:all.

:self

The self placeholder provides the account that hits this Route access to only the resource that is related to this particular accountId. For example, if I wanted to hit GET /api/hacker/507f1f77bcf86cd799439011, then the hacker whose id was 507f1f77bcf86cd799439011 would have to have an accountId that is equal to the id of the account accessing this resource.

:all

The all placeholder provides the account that hits this Route access to any resource for a given id.

Chaining :self and :all

It is possible to chain :self and :all together if need-be. An example of chaining is either: /:self/:all, or /:all/:all, etc.

Adding Authentication and Authorization to your middleware

We’ve tried to make this as simple and abstract as possible. To add Authentication, you just need to import the Auth module file, and then insert into the route definition:

const Middleware = {
    Auth: require("../../middlewares/auth.middleware")
};
...
hackerRouter.route("/").post(
    Middleware.Auth.ensureAuthenticated(),
    // Whatever middleware is after this will run only if the user is authenticated.
);

To add Authorization, you will need to import the Auth module file, and then insert into the route definition, just like Authentication. However, if there are route parameters, you will need to also provide as arguments an array which contains the functions required to access the given parameters. The route parameters are typically some model id. The functions in the array need return an object that contains the user id either in _id or accountId. The method signature for the inputted functions must be: (parameter) => {accountId:string} | {_id:string}. There must be a one-to-one mapping between route parameters and function inputs. The order of route parameters and function inputs must be the same. The details of authorization is heavily commented in in ensureAuthorized at auth.service.js. An example is below:

const Middleware = {
    Auth: require("../../middlewares/auth.middleware")
};
const Services = {
    Hacker: require("../../services/hacker.service"),
}
...
// some made-up route that allows a hacker to 'friend' another hacker
hackerRouter.route("/:id1/friend/:id2/").get(
    Middleware.Auth.ensureAuthenticated(),
    Middleware.Auth.ensureAuthorized([Services.Hacker.findById, Services.Hacker.findById]),
    // Whatever middleware is after this will run only if the user is authenticated AND has sufficient permissions to access this route.
);

Here, we note that we pass in one function for id1, and one function for id2.

The ensureAuthorized middleware calls the ensureAuthorized service in auth.service.js.

Limitations

There are some limitations to this authorization method:

Searching

More complex searches on models are available on our API. We provide a subset of the mongoose query language that allows you to quickly get results for a given model. On our search/ route, just assign q to your query in your query parameters.

Structure of your query

The search query is strucutered as a series of param, operation, value objects (P.O.V.) passed into the /search/:model route:

[
    {"param": "a", "operation":"...", "value":"..."},
    {"param": "b", "operation":"...", "value":"..."},
    {"param": "c", "operation":"...", "value":"..."},
    {"param": "d", "operation":"...", "value":"..."}
]

Where param is the parameter you want to search by (email, age, gender, etc.), operation is valid for the type you are conducting the search on:

Type Operations
String ['equals', 'ne', 'regex', 'in']
Number ['equals', 'ne', 'gte', 'lte', 'le', 'ge', 'in']
Boolean ['equals', 'ne']

value is the value that you want to compare entries in the DB against.

An example query to find all hackers whose email ends with @mail.mcgill.ca would be:

[{"param":"email", "operation":"regex", "value":"[email protected]"}]

Sequential P.O.V.s are chained together using an and operator. There currently is no functionality for chaining them as or. To find all hackers whose email ends with @mail.mcgill.ca and were checked in, the query would be:

[
    {"param":"email", "operation":"regex", "value":"[email protected]"},
    {"param":"status", "operation":"equals", "value":"Checked-in"}
]

The http request that this would translate to is:

/api/search/hacker?q=%5B%7B%22param%22%3A%22email%22%2C%20%22operation%22%3A%22regex%22%2C%20%22value%22%3A%22.%2B%40mail.mcgill.ca%22%7D%2C%7B%22param%22%3A%22status%22%2C%20%22operation%22%3A%22equals%22%2C%20%22value%22%3A%22Checked-in%22%7D%5D

Error Codes and Messages

Error messages are in the error.constant.js file. The error constants are of the for TYPE_HTTPCODE_MESSAGE. For example, HACKER_404_MESSAGE. When creating a response, use an existing error message, or create a new one. An example:

next({
    status: 422,
    message: Constants.Error.ACCOUNT_DUPLICATE_422_MESSAGE,
    error: {...}
});

Note that the error status and the HTTPCODE in the error constant name are the same.

Error codes currently in use are: 401 - Invalid authentication 403 - Invalid authorization 404 - Resource not found 409 - Conflict with Id or some attribute from a valid request. 422 - Failure at validation, or other forms of invalid input 500 - General server error