05 Filters - kjellhex/diode GitHub Wiki

Diode supports Filters which can pre-process a request before it arrives at the service, and then post-process the response before it is returned to the client. A common use of filters is for Authentication, whereby the request is rejected if the user is not authenticated, or else the verified userid is securely added to the request for use by the service.

Filters can be chained. The request flows through the first filter where is it pre-processed, then to the next filter, then to the service which produces a response. The response flows back the same way, through the second filter for post-processing of the response, then to the first filter, then back to the client.

Code

Without authentication

Let's start with a simple server that has no authentication. Place this code into the auth-server.rb:

class Hello
  def serve(request)
    body = JSON.dump({ "message": "Hello World!" })
    Diode::Response.new(200, body)
  end
end

require 'diode/server'
routing = [
  [%r{^/}, "Hello"]
]
server = Diode::Server.new(3999, routing)
server.start()

Start the server and visit http://127.0.0.1:3999/ to see the welcome message.

Authentication filter

Add this code to auth-filter.rb:

class AuthFilter

  def serve(request)
    # do pre-processing with the request
    unless request.headers["Authorization"] == "Bearer token-123="  # pretend we verified the token properly
      redirect = Diode::Response.new(402, JSON.dump({"status": "not authenticated"}))
      remoteIP = request.remote.ip_address # the source of the request is available
      puts("[.] intercepting an unauthenticated request from ip=#{remoteIP}")  # pretend we logged the failed access attempt
      return redirect
    end

    # invoke the next filter
    response = (request.filters.shift).serve(request)
    # Warning!! if the parentheses around the shift are omitted, the next filter will NOT be removed from the list beforehand

    # do post-processing with the response
    response.headers["Cache-Control"] = "no-store"
    return response
  end

end

Now let's modify the last part of the server to add the filter:

server = Diode::Server.new(3999, routing)
load 'auth-filter.rb'
authFilter = AuthFilter.new()
server.filters.unshift(authFilter)
server.start()

Start the server and visit http://127.0.0.1:3999/ and the response will be a 402 rejection message because the request was not authenticated. Now add an Authorization header with a value of Bearer token-123= and try again to be authenticated and see the greeting message.

How does it work?

A filter is any class that implements a serve method. For normal processing, a filter's serve method:

  • can optionally perform pre-processing on the request
  • must invoke the next filter
  • can optionally perform post-processing on the response In the event of an error or rejection of the request, a response can be returned instead of passing on control to the next filter in the chain.

Preprocessing

Authentication can be done in a variety of ways: with an API token, a JWT token, or a session cookie. Some of these are granted after signing in. In this demonstration, we accept a hard-coded credential but in practice the token would be verified in some acceptable way, such as comparing against the in-memory list of session tokens, or by verifying a digital signature.

The point of this demonstration is that the filter has access to the request and can intercept it if it is not acceptable. In this case, if the token does not match, the filter does not pass on the request further along the chain. Instead it rejects the request by returning a response immediately.

# do pre-processing with the request
unless request.headers["Authorization"] == "Bearer token-123="  # pretend we verified the token properly
  redirect = Diode::Response.new(402, JSON.dump({"status": "not authenticated"}))
  remoteIP = request.remote.ip_address # the source of the request is available
  puts("[.] intercepting an unauthenticated request from ip=#{remoteIP}")  # pretend we logged the failed access attempt
  return redirect
end

One other aspect worth pointing out is that the remote IP of the requestor is available as request.remote.ip_address which can be useful for logging failed access attempts or even for use in the authentication process itself.

You are free to handle authentication however you prefer. If the user has a session cookie named auth, the value is accessible from request.cookies['auth'].

Chaining

The second task of a filter is to pass the request to the next filter in the chain. The last filter in the chain is always the server itself, which will delegate the request to the service nominated by the routing rules.

When a server is created, it has a list of filters which contains only itself. When a filter is added, it is placed first in the chain:

  # before: server.filters == [server]
  server.filters.unshift(authFilter)
  # after: server.filters == [authFilter, server]

If more filters were added, they too would be prepended to the chain with the server at the end of the chain. When a request arrives, it gets a copy of the filters chain. As each filter performs its processing, it needs to pass control to the next filter:

  nextFilter = request.filters.shift()
  response = nextFilter.serve(request)
    # or
  response = (request.filters.shift).serve(request)

Note that in the second form, the parentheses are important because they cause the evaluation shift to remove the first item in the chain first, before the request (with its copy of the filter chain) is passed to the serve method of the next filter. Without the parentheses, the request is passed with the original chain with the current filter still first in the list, leading to an endless loop.

Post-processing

Some possible applications of post-processing might include:

  • adding standard security headers to responses in one place instead of modifying each service
  • logging information about responses The response can be modified, and then it must be returned:
  # do post-processing with the response
  response.headers["Cache-Control"] = "no-store"
  return response

There are a couple of things every application needs and 06 validation and logging has some suggestions.