series

  • [Koa source code] Koa
  • [Koa source code] Koa-router
  • Koa – BodyParser
  • Koa cookie
  • [Koa source code learning] KOa-session

preface

HTTP is a stateless protocol. The browser adopts Cookie technology to maintain state. When the server receives an HTTP request, the server can use the set-cookie response header to Set a Cookie to the browser. After that, when the browser requests the server again, the Cookie is placed in the Cookie request header and sent to the server along with other information.

Koa uses the cookies module internally to provide support for cookies, so let’s take a look at how this is implemented internally.

keys & ctx.cookies

When creating a Koa instance, you can use the keys option to set the key to sign the Cookie. This is an array of keys. By default, the Cookie is always signed using the first key, but you can also adjust the order of the keys at run time, as shown in the following code:

/* koa/lib/application.js */
module.exports = class Application extends Emitter {
  constructor(options) {
    // ...
    // List of keys to sign cookies
    if (options.keys) this.keys = options.keys;
    // ...}};Copy the code

When a request is received, ctx.cookies can be used in the middleware to set and obtain cookies, as shown below:

/* koa/lib/context.js */
const Cookies = require('cookies');
const COOKIES = Symbol('context#cookies');

const proto = module.exports = {
  get cookies() {
    if (!this[COOKIES]) {
      // Create Cookies instance
      this[COOKIES] = new Cookies(this.req, this.res, {
        keys: this.app.keys,
        // https
        secure: this.request.secure
      });
    }
    return this[COOKIES];
  },

  set cookies(_cookies) {
    this[COOKIES] = _cookies; }};Copy the code

As you can see, when ctx.cookies is accessed for the first time in the same request context, an instance of cookies will be created. The reQ and RES of the current request will be passed in together with the previously configured keys. Cookies can be set and obtained through this instance.

Cookies

Let’s start by looking at the Cookies constructor, which looks like this:

/* cookies/index.js */
function Cookies(request, response, options) {
  if(! (this instanceof Cookies)) return new Cookies(request, response, options)

  this.secure = undefined
  this.request = request
  this.response = response

  if (options) {
    // ...
    // Use the Keygrip module for signature and authentication
    this.keys = Array.isArray(options.keys) ? new Keygrip(options.keys) : options.keys
    this.secure = options.secure
  }
}

Cookies.prototype.get = function(name, opts) {
  // ...
};

Cookies.prototype.set = function(name, value, opts) {
  // ...
};
Copy the code

As you can see, when you instantiate Cookies, in addition to saving request and Response on the current instance, you also create an instance of Keygrip, which is used to sign and authenticate Cookies. Let’s first look at how it is used.

Keygrip

The constructor for Keygrip looks like this:

/* keygrip/index.js */
function Keygrip(keys, algorithm, encoding) {
  // The default hash algorithm is sha1, and the default encoding method is base64
  if(! algorithm) algorithm ="sha1";
  if(! encoding) encoding ="base64";
  if(! (this instanceof Keygrip)) return new Keygrip(keys, algorithm, encoding)

  // ...

  // Sign the data according to the key
  function sign(data, key) {
    return crypto
      .createHmac(algorithm, key)
      .update(data).digest(encoding)
      .replace(/\/|\+|=/g.function(x) {
        return ({ "/": "_"."+": "-"."=": "" })[x]
      })
  }

  this.sign = function(data){ return sign(data, keys[0])}this.verify = function(data, digest) {
    return this.index(data, digest) > - 1
  }

  this.index = function(data, digest) {
    for (var i = 0, l = keys.length; i < l; i++) {
      if (compare(digest, sign(data, keys[i]))) {
        return i
      }
    }

    return - 1}}Copy the code

As you can see, in the Keygrip constructor, the key method here is sign, which hmac the data using the native crypto module, using the SHA1 hash algorithm and the key key, generates the message digest, and encodes it in base64URL format and returns it. In addition, Instances of Keygrip also provide three instance methods:

  • Sign: Wraps the sign method above, signing the data using the first key in keys.

  • Verify: the data is signed and compared with the signature digest. If the two are equal, the verification succeeds.

  • Index: Keys is a list of keys. The order of keys may be changed during the process of running the program. The index method can find the key index corresponding to the digest.

The Keygrip module provides a mechanism for signing and authenticating cookies, so let’s look at how to set and get cookies.

set

Let’s first look at how to set cookies through ctx.cookies, which looks like this:

/* cookies/index.js */
Cookies.prototype.set = function(name, value, opts) {
  var res = this.response
    , req = this.request
    // Get the set-cookie response header in the buffer
    , headers = res.getHeader("Set-Cookie") || []
    , secure = this.secure ! = =undefined? !!!!!this.secure : req.protocol === 'https' || req.connection.encrypted
    // Create a Cookie instance
    , cookie = new Cookie(name, value, opts)
    // Whether cookies need to be signed, signed = opts && opts.signed ! = =undefined ? opts.signed : !!this.keys

  if (typeof headers == "string") headers = [headers]

  // ...cookie.secure = opts && opts.secure ! = =undefined
    ? opts.secure
    : secure
  // ...

  // Add cookies to the HEADERS array
  pushCookie(headers, cookie)

  // If the cookie needs to be signed, create a new cookie and add it to the HEADERS array
  if (opts && signed) {
    if (!this.keys) throw new Error('.keys required for signed cookies');
    // Message digest for name=value, cookie.name carries the. Sig suffix
    cookie.value = this.keys.sign(cookie.toString())
    cookie.name += ".sig"
    pushCookie(headers, cookie)
  }

  var setHeader = res.set ? http.OutgoingMessage.prototype.setHeader : res.setHeader
  // Set the set-cookie response header
  setHeader.call(res, 'Set-Cookie', headers)
  return this
};
Copy the code

As you can see, in the cookie.set method, a new instance of Cookie is first created with code like this:

/* cookies/index.js */
function Cookie(name, value, attrs) {
  // ...

  this.name = name
  this.value = value || ""

  for (var name in attrs) {
    this[name] = attrs[name]
  }

  // Set expires to allow the browser to delete the cookie
  if (!this.value) {
    this.expires = new Date(0)
    this.maxAge = null
  }

  // ...
}

// The default attribute on the stereotype is overridden by attrs passed in
Cookie.prototype.path = "/";
Cookie.prototype.expires = undefined;
Cookie.prototype.domain = undefined;
Cookie.prototype.httpOnly = true;
Cookie.prototype.sameSite = false;
Cookie.prototype.secure = false;
Cookie.prototype.overwrite = false;
Copy the code

As you can see, for each Cookie instance, it contains all the cookie-related configuration information, through which you can control the behavior of cookies in the browser.

Back in the cookie. set method above, pushCookie is called to add the cookie to the headers array as follows:

/* cookies/index.js */
function pushCookie(headers, cookie) {
  // The overwrite option overwrites cookies of the same name
  if (cookie.overwrite) {
    for (var i = headers.length - 1; i >= 0; i--) {
      if (headers[i].indexOf(cookie.name + '=') = = =0) {
        headers.splice(i, 1)
      }
    }
  }

  headers.push(cookie.toHeader())
}

Cookie.prototype.toString = function() {
  return this.name + "=" + this.value
};

Cookie.prototype.toHeader = function() {
  / / generated name = value
  var header = this.toString()

  // According to the configuration options, generate cookie-related configuration to control the behavior of cookies
  if (this.maxAge) this.expires = new Date(Date.now() + this.maxAge);

  if (this.path     ) header += "; path=" + this.path
  if (this.expires  ) header += "; expires=" + this.expires.toUTCString()
  if (this.domain   ) header += "; domain=" + this.domain
  if (this.sameSite ) header += "; samesite=" + (this.sameSite === true ? 'strict' : this.sameSite.toLowerCase())
  if (this.secure   ) header += "; secure"
  if (this.httpOnly ) header += "; httponly"

  return header
};
Copy the code

As you can see, pushCookie generates a string based on the cookie configuration and then adds it to the HEADERS array.

Back in the cookie. set method above, if it detects that this cookie needs to be signed, use keys.sign to digest the message name=value, and continue with pushCookie to create a new cookie. Sig is added after the original name, and the signed cookie is also added to headers. Therefore, the server creates two cookies for the cookie that needs to be signed, one for the original and one for the signed cookie.

At the end of the cookie. set, all cookies are set to the set-cookie response header through the setHeader method. At this point, the work of setting cookies is complete.

get

When a request is received, a cookie can be retrieved from the request header using the cookie. get method, which looks like this:

/* cookies/index.js */
Cookies.prototype.get = function(name, opts) {
  var sigName = name + ".sig", header, match, value, remote, data, index , signed = opts && opts.signed ! = =undefined ? opts.signed : !!this.keys

  // Cookie request header
  header = this.request.headers["cookie"]
  if(! header)return

  // Fetch the corresponding cookie according to name
  match = header.match(getPattern(name))
  if(! match)return

  // If no validation is required, the value of cookie is returned directly
  value = match[1]
  if(! opts || ! signed)return value

  // If validation is required, the get method is called again, through [name].sig, to get the message digest
  remote = this.get(sigName)
  if(! remote)return

  // Use keys.index to verify the cookie and return the index in the key list
  data = name + "=" + value
  if (!this.keys) throw new Error('.keys required for signed cookies');
  index = this.keys.index(data, remote)

  if (index < 0) {
    // Cookie may be tampered with, delete [name].sig
    this.set(sigName, null, {path: "/".signed: false})}else {
    // index>0, it indicates that the key sequence has been changed. The first key is re-used for signature, and the value of cookie is returned at last
    index && this.set(sigName, this.keys.sign(data), { signed: false })
    return value
  }
};
Copy the code

As can be seen, in the cookie. get method, the cookie is first obtained from the request header, and then the corresponding value is found according to the name. If no verification is required, the value can be returned directly. Sig to get the last message digest from the cookie, and then use keys.index to verify the cookie. If the check passes, it indicates that the cookie has not been tampered, and then return value. If the verification fails, the cookie is invalid and no return is required.

conclusion

Koa provides support for cookies through the cookies module. In the middleware, cookies can be set and obtained through the CTx. cookies interface. At the same time, the Keygrip module is used inside Koa to provide the mechanism for signature and verification of cookies.