DEV Community

Cover image for AMP CMS: Authentication & Authorization
Valeria
Valeria

Posted on

AMP CMS: Authentication & Authorization

Jeez that's a hard title, right? Well, "The process of validating users' identity and granting them permissions to perform certain actions" sounded even worse, and "A12n & A11n" were too cryptic 🤪.

Most of the "backend" functionality needs to be secured from unauthorized access. There are several ways to do it, for AMP CMS we'll do it the AMP way: using AMP Access.

AMP Access

Each AMP site user is given a unique Reader ID, once the user is authorized (e.g. logged in with email or password), his Reader ID is linked to the user ID in the database.

Whenever there would be a need to access restricted content, AMP would make a call to the provided Authorization Endpoint, which would either fail with Unauthorized error or return some JSON information, based on which content would be shown or hidden.

It all sounds pretty easy in theory, and I think it's time to add some AMP in AMP CMS.

Rendering AMP

Let's create an src/amp folder and add a file called bolerplate.ts:

import { html } from "./lib";
export default ({ body, head }: { body?: string; head?: string }) =>html`
  <!DOCTYPE html>
  <html ⚡ lang="en">
    <head>
      <meta charset="utf-8" />
      <script async src="https://cdn.ampproject.org/v0.js"></script>
      <link
        rel="canonical"
        href="http://localhost:8080"
      />
      <meta name="viewport" content="width=device-width" />
      <style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
    ${head ?? ""}
    </head>
    <body>
      ${body ?? ""}
    </body>
  </html>
`;

Enter fullscreen mode Exit fullscreen mode

That fancy html template is in fact:

// src/amp/lib.ts
export const html = String.raw;
Enter fullscreen mode Exit fullscreen mode

Its sole purpose at the moment is to make a code editor format HTML inside of it, I might or might not have bigger plans for this template literal in the future.

Now we need to render a page. Similarly to api middleware, we'll write amp middleware in src/amp/index.ts:

import { IncomingMessage, ServerResponse } from "http";
import boilerplate from "./boilerplate";
export default function amp() {
  return async (req: IncomingMessage, res: ServerResponse) => {
    res.statusCode = 200;
    const page = boilerplate();
    res.setHeader("Content-Type", "text/html");
    res.setHeader("Content-Length", page.length);
    res.write(page);
    res.end();
  };
}

Enter fullscreen mode Exit fullscreen mode

And add it to src/index.ts:

import { Server } from "http";
import ampCORS from "amp-toolbox-cors";
import modules from "./modules";
import API from "./api";
import AMP from "./amp";

const port = 8080;

const server = new Server((req, res) => {
  ampCORS()(req, res, () => {
    const api = API(modules.resolvers, { log: console });
    if (req.url?.startsWith("/api")) return api(req, res);
    const amp = AMP();
    return amp(req, res);
  });
});

server.listen(port, () => {
  console.log(`http://localhost:${port}/`);
});
Enter fullscreen mode Exit fullscreen mode

And while we're on it, we are also adding amp-toolbox-cors middleware to ensure that our server handles CORS headers properly:

yarn add amp-toolbox-cors
Enter fullscreen mode Exit fullscreen mode

Let's start the server and take a look at our first AMP page!
Hint: use yarn dev to launch in the development mode.

The page is expectably empty, but if we look into the console, we'll see this:
Powered by AMP

Works like a charm! Now let's add something to the page.

Login page

Our boilerplate function accepts two parameters: head and body, lets create login page definition with these two parameters:

//src/amp/login.ts
import { html } from "./lib";

export const body = html`
  <h1>Login</h1>
  <form method="post" action-xhr="/api/login" target="_top">
    <fieldset>
      <label>
        <span>Email:</span>
        <input type="email" name="email" required autocomplete="email" />
      </label>
      <br />
      <label>
        <span>Password:</span>
        <input
          type="password"
          name="password"
          required
          autocomplete="current-password"
        />
      </label>
      <br />
      <input type="submit" value="Login" />
    </fieldset>
    <div submit-success>
      <template type="amp-mustache"> Login successful! </template>
    </div>
    <div submit-error>
      <template type="amp-mustache"> Login failed! </template>
    </div>
  </form>
`;
export const head = html`<script
    async
    custom-element="amp-form"
    src="https://cdn.ampproject.org/v0/amp-form-0.1.js"
  ></script>
  <script
    async
    custom-template="amp-mustache"
    src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"
  ></script>`;

Enter fullscreen mode Exit fullscreen mode

And let's add it directly to the middleware:

import { IncomingMessage, ServerResponse } from "http";
import boilerplate from "./boilerplate";
import * as login from "./login";
export default function amp() {
  return async (req: IncomingMessage, res: ServerResponse) => {
    res.statusCode = 200;
    const page = boilerplate(login);
    res.setHeader("Content-Type", "text/html");
    res.setHeader("Content-Length", page.length);
    res.write(page);
    res.end();
  };
}

Enter fullscreen mode Exit fullscreen mode

This time, we're going to open http://localhost:8080/#development=1, this hash parameter will trigger AMP validation on the page and show results in the console:

AMP validation successful

If you will enter some data and try to log in, it won't work, because we don't have an endpoint we specified just yet.

Authorization endpoint

Create authorization folder inside modules with the following index.ts:

//src/modules/authorization/index.ts
export const resolvers = {
  login: {
    POST: ({ input: { email, password } }) => {
      console.log({ email, password });
      return { status: "OK" };
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Now, add this newly created module to the modules index:

import * as health from "./health";
import * as authorization from "./authorization";

export default Object.assign({}, health, authorization);
Enter fullscreen mode Exit fullscreen mode

Voa lá, we get "Login Successful" and can see the form input in the server log:

"Login successful" and logged email and password values

Setting up AMP Access

Let's add AMP Access to the login head:

export const head = html`<script
    async
    custom-element="amp-form"
    src="https://cdn.ampproject.org/v0/amp-form-0.1.js"
  ></script>
  <script
    async
    custom-template="amp-mustache"
    src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"
  ></script>
  <script
    async
    custom-element="amp-access"
    src="https://cdn.ampproject.org/v0/amp-access-0.1.js"
  ></script>
  <script id="amp-access" type="application/json">
    {
      "authorization": "/api/access?rid=READER_ID&url=SOURCE_URL",
      "pingback": "/api/ping?rid=READER_ID&url=SOURCE_URL",
      "login": "/login?rid=READER_ID&url=SOURCE_URL",
      "authorizationFallbackResponse": { "error": true }
    }
  </script> `;
Enter fullscreen mode Exit fullscreen mode

If you'll check cookies, you'll see that amp-access cookie appeared in the list:
AMP Access Cookie, a.k.a Reader ID

Let's add authorization and ping endpoints to modules/authorization/index.ts:

export const resolvers = {
  login: {
    POST: ({ input: { email, password } }) => {
      console.log({ email, password });
      return { status: "OK" };
    },
  },
  access: {
    GET: (params) => {
      console.log("access", params);
      return { authorized: true };
    },
  },
  ping: {
    POST: (params) => {
      console.log("ping", params);
      return { message: "OK" };
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

And add some logic to src/amp/login.ts:

export const body = html`
  <div amp-access="authorized">You are logged in!</div>
  <div amp-access="NOT authorized" amp-access-hide>
    <h1>Login</h1>
    <form method="post" action-xhr="/api/login" target="_top">
      <fieldset>
        <label>
          <span>Email:</span>
          <input type="email" name="email" required autocomplete="email" />
        </label>
        <br />
        <label>
          <span>Password:</span>
          <input
            type="password"
            name="password"
            required
            autocomplete="current-password"
          />
        </label>
        <br />
        <input type="submit" value="Login" />
      </fieldset>
      <div submit-success>
        <template type="amp-mustache"> Login successful! </template>
      </div>
      <div submit-error>
        <template type="amp-mustache"> Login failed! </template>
      </div>
    </form>
  </div>
`;
Enter fullscreen mode Exit fullscreen mode

You'll see "You are logged in!" and the form will disappear.
Server console will show something like that:

http://localhost:8080/
access {
  rid: 'amp-yR9udDo9MkG8gPApYeb9KA',
  url: 'http://localhost:8080/',
  __amp_source_origin: 'http://localhost:8080'
}
ping {
  rid: 'amp-yR9udDo9MkG8gPApYeb9KA',
  url: 'http://localhost:8080/',
  __amp_source_origin: 'http://localhost:8080',
  files: {},
  input: {}
}
Enter fullscreen mode Exit fullscreen mode

Now, if you change authorized: true to authorized: false and reload the page, you'll see the login form.

However, to link the reader ID to an actual user during a login, we need to get our hands on the cookies:

//src/api/index.ts
import cookie from 'cookie'
//...
context.cookies = context.cookies = req.headers.cookie && cookie.parse(req.headers.cookie);
const response = await resolver(params, context);
//...
Enter fullscreen mode Exit fullscreen mode

We'll need to install module cookie and its types, of course:

yarn add cookie
yarn add @types/cookie
Enter fullscreen mode Exit fullscreen mode

And add cookies to APIContext type:

export type APIContext = { log: APILogger; cookies?: Record<string, string> };
Enter fullscreen mode Exit fullscreen mode

For now we adjust login resolver to log cookies:

//src/modules/authorization/index.ts
login: {
    POST: ({ input: { email, password } }, { cookies }) => {
      console.log({ email, password, cookies });
      return { status: "OK" };
    },
  },
Enter fullscreen mode Exit fullscreen mode

Submit the form again, and you'll see something like that in the server log:

{
  email: 'admin@website.com',
  password: '12345',
  cookies: { 'amp-access': 'amp-yR9udDo9MkG8gPApYeb9KA' }
}
Enter fullscreen mode Exit fullscreen mode

Yay! Looks like we have everything we need for actual implementation.

Users data source

We've created a pretty powerful abstract data source, but to log in and authorize users we need to have a way to query users by email. Which in its turn means we need to handle secondary indexes. One way to do it is to store emails in Redis sorted set with the same weights and leverage lexicographical search like this:

ZADD users::email 0 clark.kent@daily.planet::usr_1
Enter fullscreen mode Exit fullscreen mode

To find all users with an email clark.kent@daily.planet we can run:

ZRANGEBYLEX [clark.kent@daily.planet [clark.kent@daily.planet:\xff
Enter fullscreen mode Exit fullscreen mode

And we can check is a user exists by:

ZCOUNTBYLEX [clark.kent@daily.planet [clark.kent@daily.planet:\xff
Enter fullscreen mode Exit fullscreen mode

We can check our idea with the following integration test:

// src/modules/authorization/Users.spec.ts
import { describe, it, beforeEach, afterEach, after } from "mocha";
import { expect } from "chai";
import Redis from "ioredis";
import Users from "./Users";

const redis = new Redis({ db: 9 });
const users = new Users({ redis });

describe("Users Integration Test", () => {
  before(() => redis
      .multi()
      .flushdb()
      .hset("users::usr_1", "id", "usr_1", "email", "clark.kent@daily.planet")
      .zadd("users::email", "0", "clark.kent@daily.planet::usr_1")
      .exec()
  );
  after(async () => {
    await redis.flushdb();
    redis.disconnect();
  });
  it("can find user by email", async () => {
    const user = await users.byEmail("clark.kent@daily.planet");
    expect(user).not.to.be.null;
    expect(user).to.have.property("id", "usr_1");
    expect(user).to.have.property("email", "clark.kent@daily.planet");
    expect(await users.byEmail("lex@luther.corp")).to.be.null;
  });
});
Enter fullscreen mode Exit fullscreen mode

In the DataSource test, I've initially used redis.flushall, which was deleting all the data across all Redis databases. Naturally, it bit me in the fillet and I've changed it to redis.flushbd along with choosing a non-default database index for tests.

Now, back to the Users. First, we can create our boilerplate:

// src/modules/authentification/Users.ts
import { RedisDataSource } from "api/DataSource";
import { TString } from "api/datatypes";

export default class Users extends RedisDataSource<
  {
    id: string;
    name: string;
    email: string;
  }
> {
  collection = "users";
  prefix = "usr";
  schema = {
    name: TString,
    email: TString
  };
}
Enter fullscreen mode Exit fullscreen mode

Now let's add a method called byEmail:

  byEmail(email: string) {
    const normalizedEmail = email.trim().toLowerCase();
    return this.context.redis["userbyemail"](
      this.collection,
      this.prefix,
      normalizedEmail
    ).then(async (result) => {
      return this.decode(this.collect(result));
    });
  }
Enter fullscreen mode Exit fullscreen mode

And define LUA script in the constructor:

constructor(context) {
    super(context);
    context.redis.defineCommand("userbyemail", {
      numberOfKeys: 3,
      lua: `
      local emailidx = KEYS[1]..'::email'
      local emails = redis.call('ZRANGEBYLEX', emailidx, '['..KEYS[3], '['..KEYS[3]..':\xff');
      local i = next(emails);
      if i == nil
        then
          return nil;  
        else
          local email = emails[i];
          local id = string.gsub(email,KEYS[3]..'::','');
          local cid = KEYS[1]..'::'..id;
          return redis.call('hgetall',cid);
      end  
      `,
    });
}
Enter fullscreen mode Exit fullscreen mode

Now we can move to the second part of the login pair: to the password.

Password authentication

Storing plain passwords in the database is an absolute no-no. It's unsecured and unethical. Instead, we'll use bcrypt. First, we'll generate a random salt, and then we'll use it to hash plain password. This way even the same password will have a different hash.

Here's how it works (you can try it in node repl, by installing yarn bcryptjs and running node in console):

const bcrypt = require('bcryptjs');
bcrypt.genSalt(5).then(console.log)
// logs something like
// $2a$05$omaO8ndXQYtfSMqBsB.6SO 
bcrypt.hash('12345','$2a$05$omaO8ndXQYtfSMqBsB.6SO').then(console.log)
// logs $2a$05$omaO8ndXQYtfSMqBsB.6SOrCTR40XDVxG09pHwKlf2eaDQdvbDRaa
Enter fullscreen mode Exit fullscreen mode

We used 5 rounds to generate salt, the more rounds you use, the longer it takes, and the more random the salt gets.

Now, let's test the login function. First, we add pwhash in the fixture:

before(() =>
    redis
      .multi()
      .flushdb()
      .hset(
        "users::usr_1",
        "id",
        "usr_1",
        "email",
        "clark.kent@daily.planet",
        "pwhash",
        "$2a$05$omaO8ndXQYtfSMqBsB.6SOrCTR40XDVxG09pHwKlf2eaDQdvbDRaa"
      )
      .zadd("users::email", "0", "clark.kent@daily.planet::usr_1")
      .exec()
  );
Enter fullscreen mode Exit fullscreen mode

And then the tests:

it("can login user", async () => {
    const user = await users.login({
      email: "clark.kent@daily.planet",
      password: "12345",
    });
    expect(user).not.to.be.null;
    expect(user).to.have.property("id", "usr_1");
    expect(user).to.have.property("email", "clark.kent@daily.planet");
  });
  it("doesnt log user in with wrong password", async () => {
    try {
      await users.login({
        email: "clark.kent@daily.planet",
        password: "54321",
      });
      throw Error("Should not login");
    } catch (error) {
      expect(error.message).to.match(/wrong|password/);
    }
  });
  it("doesnt log user in with wrong email", async () => {
    try {
      await users.login({
        email: "clark@daily.planet",
        password: "12345",
      });
      throw Error("Should not login");
    } catch (error) {
      expect(error.message).to.match(/wrong|email/);
    }
  });
Enter fullscreen mode Exit fullscreen mode

Let's try to implement this:

// Users.ts
async login({ email, password }: UserLoginInput) {
    const user = await this.byEmail(email);
    if (!user)
      throw new HTTPUserInputError(
        "email",
        "User with this email does not exist"
      );
    const isCorrect = await bcrypt.compare(password, user.pwhash);
    if (!isCorrect)
      throw new HTTPUserInputError("password", "Wrong email or password");
    return user;
  }

Enter fullscreen mode Exit fullscreen mode

While the logic seems to be correct, the tests will fall with:

1) Users Integration Test
       can login user:
     Error: Illegal arguments: string, undefined
Enter fullscreen mode Exit fullscreen mode

This happens because the field pwhash is stripped away during the decode step, as our schema doesn't define this field. Though it is a useful feature because we don't really want to return pwhash in API responses, we do need it here.

Let's modify byEmail to do decode optionally:

byEmail(email: string, decode: boolean = true) {
    const normalizedEmail = email.trim().toLowerCase();
    return this.context.redis["userbyemail"](
      this.collection,
      this.prefix,
      normalizedEmail
    ).then((result) => {
      const user = this.collect(result);
      if (!decode) return user;
      return this.decode(user);
    });
  }
Enter fullscreen mode Exit fullscreen mode

And opt-out of decoding in the login function:

async login({ email, password }: UserLoginInput) {
    const user = await this.byEmail(email, false);
    if (!user)
      throw new HTTPUserInputError(
        "email",
        "User with this email does not exist"
      );
    const isCorrect = await bcrypt.compare(password, user.pwhash);
    if (!isCorrect)
      throw new HTTPUserInputError("password", "Wrong email or password");
    return user;
  }
Enter fullscreen mode Exit fullscreen mode

Nice, the test passes now. However, we did meddle with decoding functionality, let's make sure we broke nothing.

First, let's add some non-string fields to the schema, for example, timestamps:

export default class Users extends RedisDataSource<{
  id: string;
  name: string;
  email: string;
  createdAt?: number;
  updatedAt?: number;
  activeAt?:number;
}> {
  collection = "users";
  prefix = "usr";
  schema = {
    name: TString,
    email: TString,
    createdAt: TInt,
    updatedAt: TInt,
    activeAt: TInt,
  };
/*...*/
}
Enter fullscreen mode Exit fullscreen mode

Add one of those to the test:

const createdAt = Date.now();

describe("Users Integration Test", () => {
  before(() =>
    redis
      .multi()
      .flushdb()
      .hset(
        "users::usr_1",
        "id",
        "usr_1",
        "email",
        "clark.kent@daily.planet",
        "pwhash",
        "$2a$05$omaO8ndXQYtfSMqBsB.6SOrCTR40XDVxG09pHwKlf2eaDQdvbDRaa",
        "createdAt",
        createdAt.toString()
      )
      .zadd("users::email", "0", "clark.kent@daily.planet::usr_1")
      .exec()
  );
/*...*/
})
Enter fullscreen mode Exit fullscreen mode

And add a check in both successful login and byEmail tests:

it("can find user by email", async () => {
    const user = await users.byEmail("clark.kent@daily.planet");
    expect(user).not.to.be.null;
    expect(user).to.have.property("id", "usr_1");
    expect(user).to.have.property("email", "clark.kent@daily.planet");
    expect(user).to.have.property("createdAt", createdAt);
    expect(await users.byEmail("lex@luther.corp")).to.be.null;
  });
  it("can login user", async () => {
    const user = await users.login({
      email: "clark.kent@daily.planet",
      password: "12345",
    });
    expect(user).not.to.be.null;
    expect(user).to.have.property("id", "usr_1");
    expect(user).to.have.property("email", "clark.kent@daily.planet");
    expect(user).to.have.property("createdAt", createdAt);
  });
Enter fullscreen mode Exit fullscreen mode

Login test fails because we forgot to decode the user in the end! Hint: change the last line in the login method.

Creating users

Unfortunately, the default RedisDataSource create method won't work for us here: we need to hash the password on the creation and add email to the secondary index, checking that the user with this email does not yet exist.

Let's test:

it("can create user", async () => {
    const user = await users.create({
      email: "admin@website.com",
      password: "54321",
    });
    expect(user).not.to.be.null;
    expect(user).to.have.property("id", "usr_2");
    const pwhash = await redis.hget("users::usr_2", "pwhash");
    expect(pwhash).to.not.be.empty;
    expect(await bcrypt.compare("54321", pwhash)).to.be.true;
  });
Enter fullscreen mode Exit fullscreen mode

We also want to add users::next to the fixture to get proper id generated:

before(() =>
    redis
      .multi()
      .flushdb()
      .hset(
        "users::usr_1",
        "id",
        "usr_1",
        "email",
        "clark.kent@daily.planet",
        "pwhash",
        "$2a$05$omaO8ndXQYtfSMqBsB.6SOrCTR40XDVxG09pHwKlf2eaDQdvbDRaa",
        "createdAt",
        createdAt.toString()
      )
      .zadd("users::email", "0", "clark.kent@daily.planet::usr_1")
      .set("users::next", "1")
      .exec()
  );
Enter fullscreen mode Exit fullscreen mode

Users.create function would look like this:

async create({ email, password, ...rest }: UserInput) {
    if (!password)
      throw new HTTPUserInputError("password", "Please provide a password");
    const pwhash = await bcrypt.hash(password, await genSalt(5));
    // prevent forced id
    delete rest["id"];
    rest["createdAt"] = Date.now();
    const input = { ...rest, email: email.trim().toLowerCase() };
    return this.context.redis["newuser"](
      this.collection,
      this.prefix,
      input.email,
      ...this.flatten(this.encode(input)),
      "pwhash",
      pwhash
    )
      .then((result) => {
        return this.decode(this.collect(result));
      })
      .catch((error) => {
        if (error.message === "already_exists")
          throw new HTTPUserInputError(
            "email",
            "User with this email already exists"
          );
        throw error;
      });
  }
Enter fullscreen mode Exit fullscreen mode

It uses an LUA script similar to:

context.redis.defineCommand("newuser", {
      numberOfKeys: 3,
      lua: `
      local emailidx = KEYS[1]..'::email'
      local emails = redis.call('ZLEXCOUNT', emailidx, '['..KEYS[3], '['..KEYS[3]..':\xff');
      if emails == 0 
        then
          local next = redis.call('incr',KEYS[1]..'::next');
          local id = KEYS[2]..'_'..next;
          local cid = KEYS[1]..'::'..id;
          redis.call('hset',cid,'id',id, 'email', KEYS[3], unpack(ARGV));
          redis.call('zadd',emailidx,0,KEYS[3]..'::'..id);
          return redis.call('hgetall',cid);
        else
          return {err="already_exists"}  
      end  
      `,
    });
Enter fullscreen mode Exit fullscreen mode

Authorization tokens

Instead of generating random tokens, we are going to use Reader ID, provided by AMP. When a user enters valid credentials, we should link his AMP Reader Id and CMS user ID.

We could be using the same approach, as we used for storing email indexes, but this time we need to have the ability to retrieve users by a token as well as to manage all the tokens, that belong to a certain user. And a cherry on top: the token should have an expiration date.

Expiration requirement leaves us no choice but to store user id with a token as a key:

SET amp-xxxxxx usr_1 EX 3600 <- in seconds
Enter fullscreen mode Exit fullscreen mode

In order to retrieve all user tokens, we need another index, and a sorted set seems to be a good fit. We could even use the last access time and IP address to show it in the user interface (i.e. last accessed yesterday from Stockholm).

Let's write Token.spec.ts:

import { describe, it, before, after } from "mocha";
import { expect } from "chai";
import Redis from "ioredis";
import Tokens from "./Tokens";

const redis = new Redis({ db: 9 });
const tokens = new Tokens({ redis });

const createdAt = Date.now();
const ipnum = 2835744532;

describe("Tokens Integration Test", () => {
  before(() =>
    redis
      .multi()
      .flushdb()
      .set("tokens::amp-example-token", "usr_1")
      .zadd(
        "tokens::usr_1",
        createdAt.toString(),
        "amp-example-token::2835744532"
      )
      .set("tokens::amp-another-token", "usr_2")
      .zadd(
        "tokens::usr_2",
        createdAt.toString(),
        "amp-another-token::2835744532"
      )
      .set("tokens::amp-yet-another-token", "usr_3")
      .zadd(
        "tokens::usr_3",
        createdAt.toString(),
        "amp-yet-another-token::2835744532"
      )
      .exec()
  );
  after(async () => {
    await redis.flushdb();
    redis.disconnect();
  });
  it("can get user by token", async () => {
    const id = await tokens.get("amp-example-token");
    expect(id).to.eq("usr_1");
  });
  it("can get tokens for user", async () => {
    const list = await tokens.list("usr_1");
    expect(list).to.have.length(1);
    expect(list[0]).to.have.property("token", "amp-example-token");
    expect(list[0]).to.have.property("createdAt", createdAt);
    expect(list[0]).to.have.property("ip", ipnum);
  });

  it("can delete all tokens for user", async () => {
    const result = await tokens.deleteAll("usr_2");
    expect(result).to.eq(1);
    const list = await tokens.list("usr_2");
    expect(list).to.have.length(0);
    expect(await tokens.list("usr_1")).to.have.length(1);
  });

  it("can delete token for user", async () => {
    const result = await tokens.delete({
      id: "usr_3",
      token: "amp-yet-another-token",
    });
    expect(result).to.eq(1);
    const list = await tokens.list("usr_3");
    expect(list).to.have.length(0);
    expect(await tokens.list("usr_1")).to.have.length(1);
  });

  it("can save token for user", async () => {
    const result = await tokens.save({
      id: "usr_4",
      token: "amp-some-token",
    });
    expect(result).to.eq(1);
    const list = await tokens.list("usr_4");
    expect(list).to.have.length(1);
    expect(await tokens.list("usr_1")).to.have.length(1);
  });
});


Enter fullscreen mode Exit fullscreen mode

Note: IP is presented in a decimal number form, conversion to a.b.c.d and back is based on the following formula:

256^3*a +256^2*b + 256*c + d
Enter fullscreen mode Exit fullscreen mode

Now the Token.ts will not extend RedisDataSource, but instead raw DataSource:

import { DataSource } from "api/DataSource";
import { Redis } from "ioredis";

export default class Tokens extends DataSource {
  readonly collection = "tokens";
  readonly ttl = 30 * 24 * 60 * 60; // 30 days

  constructor(protected context: { redis: Redis }) {
    super(context);
    this.context.redis.defineCommand("remtokens", {
      numberOfKeys: 2,
      lua: `
      local tokenidx = KEYS[1]..'::'..KEYS[2];
      local tokens = redis.call('ZRANGE', tokenidx, 0, -1);
      local count = 0;
      for k,v in pairs(tokens) do
        local token = string.gsub(v,'(.+)::(.*)','%1');
        local deleted = redis.call('DEL',KEYS[1]..'::'..token);
        count = count + deleted;
      end
      redis.call('DEL', tokenidx);
      return count
      `,
    });
  }

  /**
   * Save token for user with `id` and `ip`
   * @param input
   */
  save({ token, id, ip }: { token: string; id: string; ip?: number }) {
    return this.context.redis
      .multi()
      .set(this.collection + "::" + token, id, "EX", this.ttl)
      .zadd(
        this.collection + "::" + id,
        Date.now().toString(),
        `${token}::${ip}`
      )
      .exec()
      .then((results) => {
        return results[1][1];
      });
  }

  /**
   * Get user id by token
   * @param token
   */
  get(token: string): Promise<string> {
    return this.context.redis.get(this.collection + "::" + token);
  }
  /**
   * List all tokens, that belongs to user with provided `id`
   * @param id
   */
  list(
    id: string
  ): Promise<Array<{ ip?: number; createdAt: number; token: string }>> {
    return this.context.redis
      .zrange(this.collection + "::" + id, 0, -1, "WITHSCORES")
      .then((results) => {
        const entries = [];
        for (let i = 0; i < results.length; i += 2) {
          const [token, ip] = results[i].split("::");
          const createdAt = parseInt(results[i + 1]);
          entries.push({
            token,
            createdAt,
            ip: parseInt(ip),
          });
        }
        return entries;
      });
  }

  /**
   * Delete provided token for user, a.k.a. logout
   * @param token
   */
  delete({ token, id }: { token: string; id: string }) {
    return this.context.redis
      .multi()
      .del(this.collection + "::" + token)
      .zremrangebylex(
        this.collection + "::" + id,
        `[${token}::`,
        `[${token}::\uffff`
      )
      .exec()
      .then((results) =>
        results.reduce((a, c) => {
          return a & c[1];
        }, 1)
      );
  }

  /**
   * Delete all tokens from user, a.k.a logout from all devices
   * @param id
   */
  deleteAll(id: string) {
    return this.context.redis["remtokens"](this.collection, id);
  }
}


Enter fullscreen mode Exit fullscreen mode

User permissions

We are going to implement a very simple yet powerful permission system with two types of permissions: global and scoped. Global permissions allow create, read, update, delete, or list any resources. Look at the following enum:

export enum Permission {
  all = 0b11111,
  delete = 0b10000,
  update = 0b01000,
  create = 0b00100,
  list = 0b00010,
  read = 0b00001,
  view = 0b00011,
}
Enter fullscreen mode Exit fullscreen mode

When we need to check if the user has certain permission we simply do a binary & operation between actual and desired permissions.

Scoped permissions simply limit access to a certain scope, here's an example:

{
'permissions::usr_1': 0b11111,
'permissions::pages::pg_1::usr_2': 0b00001
}
Enter fullscreen mode Exit fullscreen mode

User usr_1 is admin, they can do anything, while usr_2 can only get a particular page pg_1.

This can be used for a lot of things, from limiting access to the dashboard to creating a paywall or even a subscription with expiring permission.

I'll spare you the lengthy tests and implementation, after all, you can always take a look at the repository:

GitHub logo ValeriaVG / amp-cms

Content management system for blazingly fast AMP websites, written in TypeScript and powered by Redis

AMP CMS Logo

Maintainability Test Coverage

Content management system for blazingly fast AMP websites, written in TypeScript and powered by Redis.

AMP CMS is currently in active development. It's not ready for production use until it reaches v1.0.

Current stage: alpha

How to deploy

Note: Currenty it's not possible to automatically create Redis database for you, please do it manually and then link to the app deployment either by adding existing database in App dashboard components and setting env variable REDIS_URL to ${your-db-name.REDIS_URL}

Note: App Platform uses public paths and CMS won't run without a database, do not restrict access by api until you know it's IP address

Deploy to DO

First-time & Emergency access

You can set up a superuser account though the following environment variables:

  • SUPERUSER_EMAIL, by default is set to clark.kent@daily.planet
  • SUPERUSER_PASSWORD, by default is set to clark&lois

WARNING: consider changing the default superuser credentials

This user is never stored or rendered anywhere else…

Connecting everything together

First of all, let's add newly created data sources to the export of authorization module:

import Tokens from "./Tokens";
import Users from "./Users";
import Permissions from "./Permissions";
import { DataSource } from "api/DataSource";

export { default as resolvers } from "./resolvers";

export const dataSources: Record<string, typeof DataSource> = {
  users: Users,
  permissions: Permissions,
  tokens: Tokens,
};

Enter fullscreen mode Exit fullscreen mode

Next, we need to connect the data sources to a Redis instance. Let's create a file called context.ts in the api folder:

import Redis from "ioredis";
import { redis as redisOptions } from "../config";
export const redis = new Redis(redisOptions);
export const log = console;
Enter fullscreen mode Exit fullscreen mode

Adjust the API middleware to initialize data sources with provided context:

export default function api(
  modules: {
    resolvers: APIResolvers;
    dataSources?: Record<string, typeof DataSource>;
  },
  context: APIContext
): any {
  return async (req: IncomingMessage, res: ServerResponse) => {
    const log = context.log;
    const sendResponse = answeringFactory(res);
    try {
      res.setHeader("Content-Type", "application/json");
      const method = req.method?.toUpperCase();
      const url = new URL(req.url, "http://localhost");
      const resolver = routeRequest(
        url,
        method as HTTPMethod,
        modules.resolvers
      );
      const params = await requestParams(req);
      context.cookies = req.headers.cookie && cookie.parse(req.headers.cookie);
      const dataSources = {};
      if (modules.dataSources) {
        for (let source in modules.dataSources) {
          dataSources[source] = new (modules.dataSources[source] as any)(
            context
          );
        }
      }
      const response = await resolver(params, { ...context, ...dataSources });
      const code = "code" in response ? response.code : 200;
      return sendResponse(code, response);
    } catch (error) {
      const code = "code" in error ? error.code : 500;
      const message = code >= 500 ? "Internal Server Error" : error.message;
      if (code >= 500) log?.error(error);
      return sendResponse(code, {
        errors: [{ name: error["field"] ?? error.name, message }],
      });
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

To write API endpoints with ease, I've added a couple of functions:

// modules/authorization/lib.ts

export function requiresUser<P = any, C = any, R = any>(
  next: APIResolver<P, C, R>
): APIResolver<P, C & { user: User; users: Users }, R> {
  return async (params, context) => {
    if (context.user) return next(params, context);
    const token = params["rid"] ?? context.cookies?.["amp-access"];
    if (!token) throw new HTTPNotAuthorized();
    const user = await context.users.byToken(token);
    if (!user) throw new HTTPNotAuthorized();
    context.user = user;
    return next(params, context);
  };
}

export function requiresPermission<P = any, C = any, R = any>(
  { scope, permissions }: { scope?: string; permissions: number },
  next: APIResolver<P, C, R>
): APIResolver<P, C & { user: User; permissions: Permissions }, R> {
  return requiresUser(async (params, context) => {
    const hasAccess = await context.permissions.check({
      scope,
      permissions,
      user: context.user.id,
    });
    if (!hasAccess) throw new HTTPNotAuthorized();
    return next(params, context);
  });
}
Enter fullscreen mode Exit fullscreen mode

We'll use those a lot and it would be so much easier to import from the root folder. Gladly, its an easy fix for TypeScript:
Add baseUrl to tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "commonjs",
    "allowJs": true,
    "noImplicitAny": false,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "baseUrl": "src/"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

We also need to install tsconfig-paths to make ts-node resolve paths:

yarn add tsconfig-paths --dev
Enter fullscreen mode Exit fullscreen mode

Adjust the scripts in package.json adding -r tsconfig-paths/register where needed:

{
 "scripts": {
    "start": "ts-node -r tsconfig-paths/register src/index",
    "dev": "ts-node-dev -r tsconfig-paths/register src/index",
    "test": "mocha -r ts-node/register -r tsconfig-paths/register src/**/*.spec.ts src/modules/**/*.spec.ts",
    "coverage": "nyc --reporter=lcov --reporter=text-summary mocha -r ts-node/register -r tsconfig-paths/register src/**/*.spec.ts src/modules/**/*.spec.ts"
  },
}
Enter fullscreen mode Exit fullscreen mode

And that's all, we can now set up our resolvers to work with an actual Redis! We've completed yet another task in the roadmap and nearly completed API implementation: we have all the building blocks for pages, scripts, styles, and files.

In the next article of the series, we're going to implement the CMS dashboard itself, starting with content pages.

Stay tuned!

Discussion (0)