DEV Community

Francesco
Francesco

Posted on

Clojure Bites - Ring basic auth

Overview

I am on vacation so I have decided to take it easy and work on small
things on Unrefined; one item in the backlog seemed quite approachable:
adding an admin panel to the project, which requires, among other things,
a way to authenticate users to access those restricted sections.

Here is a quick intro to how to add Basic Auth support to a Ring web
application, using ring-basic-authentication middleware, and, as a bonus,
an intro to clojure.core.cache library.

Project setup

To show how the middleware works we can create a simple web application with
just one endpoint which later we can protect with basic auth. Start a new
project or use your favorite playground and add the following dependencies:

  • ring/ring-core 1.10.0
  • ring/ring-jetty-adapter 1.10.0
  • ring-basic-authentication/ring-basic-authentication 1.2.0

Feel free to use your preferred project and dependency management tool, here I
show how to do that with tools-deps, it is easy to translate it to lein or boot:

{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        ring/ring-core {:mvn/version "1.10.0"}
        ring/ring-jetty-adapter {:mvn/version "1.10.0"}
        ring-basic-authentication/ring-basic-authentication {:mvn/version "1.2.0"}}
 :aliases
 {:dev/repl {:main-opts ["-m" "nrepl.cmdline" "–middleware" "[cider.nrepl/cider-middleware]"]
             :extra-paths ["dev"]
             :extra-deps {cider/cider-nrepl {:mvn/version "0.31.0"}
                          djblue/portal {:mvn/version "0.35.1"}
                          com.github.jpmonettas/clojure {:mvn/version "1.11.1"}
                          com.github.jpmonettas/flow-storm-dbg {:mvn/version "3.3.315"}}
             :jvm-opts ["-Dclojure.storm.instrumentEnable=true"]}
  }}
Enter fullscreen mode Exit fullscreen mode

The extra alias :dev/repl is my usual goto setup for development, it is not required
to run the examples but it can be generally handy; feel free to ignore it if you
have your own way.

Now we can create a new src/app.clj (or whatever ns you prefer) with the following
content:

(ns app
  "A namespace used to show how the basic-authentication middleware works"
  (:require [ring.adapter.jetty :as jetty]))

(defn handler
  "Our basic ring handler function, it takes a request map (unused) and
   return a response map"
  [_request]
  {:status 200
   :headers {"Content-Type" "text/html"}
   :body "Hello World"})

;; We want to hold the server instance in order to close it once done.
;; There are better ways to do it, using life cycle/state management libraries
;; like mount, integrant, component just to name few, but lets keep it simple
(def server-instance (atom nil))

(defn start-server
  "Starts a web server holding its instance in the server-instance atom"
  []
  (reset! server-instance
          (jetty/run-jetty handler {:port 3000
                                    :join? false})))

(defn stop-server
  "If there is a server running, stop it and reset the server-instance atom"
  []
  (swap! server-instance
         (fn [inst]
           (when inst
             (.stop inst)))))

(comment
  (start-server)
  (stop-server)
  ,)
Enter fullscreen mode Exit fullscreen mode

If we start a REPL and evaluate the buffer (file) we can start the server by
evaluating the fist form in the comment form, and pointing a browser to
http://localhost:3000 we should be able to see something like this:

basic handler output

Protecting the route

The library ring-basic-authentication offers the ring middleware
wrap-basic-authentication that will call a function accepting a username
and a password and returns a truty value on success (authenticated)
or a falsy value otherwise. On success the next middleware (or handler) will be
called, otherwise it will return a 401 response.

The first thing we can do is to write a function that will authenticate (or not) a
request based on provided username and password, here is the simplest implementation
we can write:

(defn authenticated?
  [username password]
  (and (= "secret-username" username)
       (= "secret-password" password)))
Enter fullscreen mode Exit fullscreen mode

Now it is time setup ring to use the middleware to autheticate requests. Lets
re-write the start-server function to do that:

(defn gen-app
  []
  (wrap-basic-authentication handler authenticated?))

(defn start-server
  "Starts a web server holding its instance in the server-instance atom"
  []
  (reset! server-instance
          (jetty/run-jetty (gen-app) {:port 3000
                                      :join? false})))
Enter fullscreen mode Exit fullscreen mode

The utility function gen-app returns a new handler using the wrap-basic-authentication
middleware to wrap our request handler; start server calls the gen-app function
when starting the server, to get the application handler function.

We can see it in action in the following screenshot

basic auth form

Now for each request this is the flow:

  • ring calls the app handler
  • the basic auth middleware is called and
  • if successful it will return the result of calling the wrapped handler
  • if not it will return a 401 without even calling the wrapper handler

In ring world this is the usual flow; in general we can expect to have chain of
middlewares before the call to the request can even start, common middlewares can
be used for:

  • request routing: given a path call a specific handler
  • request parameter parsing: parsed parameters, coming from query string, body etc are injected for later use
  • response transformation: for example to serialize to JSON, XML or other formats
  • session, authentication and authorization handling
  • request logging and metrics

There are quite a lot of already existing and useful middlewares that can be
used off the shelf.

Even if this approach can be good enough for some small and private services it
can become unusable or hard to maintain pretty easily if the service grows or must
be exposed to the public internet; few issues that can come to mind:

  • Hardcoded credentials: even if the project sources can be private it is easy to leak them, better to load them from another source
  • If we want to change credentials a new build is required; not the end of the world but not really convenient
  • There is no way to have different handlers and URL with or without the basic auth

Lets make it flexible

First thing we can do to improve this example is to get rid of those ugly hardcoded
credentials.

One way could be to load the username and password from environment variables; as one
extra step would be to on the safe side we can avoid having defaults so that if
we forget to set the environment variables no one can access the restricted
resources. Here is one possible way to authenticated? for our new requirements:

(let [secret-username (:basic-auth-username env "")
      secret-password (:basic-auth-password env "")]
  (defn authenticated?
    [username password]
    (and (seq secret-username) ;; making sure this two are set to something
         (seq secret-password) ;; TIL that (seq "") is equivalent to (not (empty? "")), thanks clj-kondo!
         (= secret-username username)
         (= secret-password password))))
Enter fullscreen mode Exit fullscreen mode

Wait wait wait, where is this env coming from?

Even if is possible to get the value of environment variables using Java
interop via System/getenv I have decided to introduce the library environ
because it offers a Clojure only interface and provides more goodies like
reading environment variables from env files or JVM properties and has a
nicer interface (IMHO).

To be able to use this library all we need to do is
to add it to our deps.edn (or…you know) and refer the env symbol in our
namespace. For more details please refer to the library docs, all we need to
know now is that it behaves like a normal map, at least for reading.

Oh, one more thing, keywords are translated to "UPPERSNAKECASE" when looking
them up, in our case :basic-auth-username will lookup BASIC_AUTH_USERNAME
environmental variable.

Even more flexible

Now we want to give access to the protected resource to more people and usually
sharing the same credentials is not a good practice; what happens if we want
to invalidate the access for one of the users? We should create new credentials
and share it only with the ones whom should have access to the resource. It is
not really practical, is it? So at this point we may want to have user specific
credentials but handling them with env vars can be too complicated or unpractical.

An option is to store credentials in a file that we can read at the start of the
application, in case someone leaves our organization we can update our credentials
file and restart the app. For convenience we can read the credentials file path
from an environment variable, we already know how to do it right? Right.

(defn get-credentials
  "Return a username -> password map reading it form the specified file, or an
   empty map it reading fails"
  [credentials-path]
  (try
    (with-open [r (io/reader credentials-path)]
      (edn/read (java.io.PushbackReader. r)))
    (catch Throwable  _ {})))

(defn authenticated?
  [username password]
  (let [credentials (get-credentials (:basic-auth-credentials env "credentials.edn"))]
    (= (get credentials username) password)))
Enter fullscreen mode Exit fullscreen mode

Now authenticated? will read the credentials from an edn file whose path is taken
from the BASIC_AUTH_CREDENTIALS environment variable (defaulting to some well
known path). If the credentials will ever change we must restart the application
to see the new values, not the end of the world but we can do a bit better.

The idea is to cache the credentials with a TTL, so when it expires we can read
the file again with possibly new values. Implementing a cache with TTL is a nice
exercise but we can leverage the clojure.core.cache library that implements
this functionality for us. I am not going to write about this library in detail,
the documentation does a great job anyway, so I'll focus on the bare minimum
to get things done.

Assuming that we have added the library to our dependencies and required it in
our namespace, we can change autheticated? to make use of it:

;; create a TTL cache with an empty map and a ttl of 60 seconds
(def credentials-cache (cache/ttl-cache-factory {} :ttl 60000))

(defn authenticated?
  [username password]
  (let [credentials (cache/lookup-or-miss
                      credentials-cache
                      :credentials ;; we try to lookup the key :credentials in the cache
                      (fn [_]      ;; if the key is missing or expired we load the credentials file
                        (get-credentials (:basic-auth-credentials env "credentials.edn"))))]
    (= (get credentials username) password)))
Enter fullscreen mode Exit fullscreen mode

This is a sorts of an hack of the TTL cache; usually you want to cache with TTL different keys,
and load the value of those keys (from DB, external APIs, whatever) when the TTL expires. In our
case we just want to load the whole file so we are using the key :credentials to hold its
content. The clojure.core.cache implements quite a lot of caching strategies, I encourage everyone
to give it a try!

Conclusions

If dealing with a simple application, using basic auth can be good enough
and we have explored different ways to achieve that; clearly, as the application
grows, other authentication mechanisms like a full suited identity/role management
solution may be a better fit. Currently I am exploring Keycloak, hoping to come
up with a library for Clojure.

Sources for this little project can be found in my shiny new playground repo,
specifically in the ring_basic_auth.clj file

Alternatives

Every web server provides a way to protect one (or more) endpoint at the
server configuration level, sometimes it can be even convenient to
leverage that functionality; the only downside I see in this approach is
that it couples the application to the web server serving it.
If the endpoint structure is sufficiently complex it is easy to forget
something in case of a migration to a different web server.

Another alternative is to use the same authentication/authorization mechanism
used for normal users and play with roles and permissions to enable/disable
access to specific resources. In the longer term this is the approach I
would take given that it reduces special cases and makes role management
more consistent.

Top comments (0)