December 17, 2015

Password Authentication/Authorization for Clojure

A full example using Buddy (barely)

In my previous post, I gave a quick overview of how the Buddy auth library for Clojure. Today, I’ll give a more fleshed-out example of how to use it to add session-based password authentication to an app.

I’ll be including lots of code examples, but you can also look at the whole thing as a Github project,

Database stuff

The core of any app (that uses a database) is its database. For this article, I didn’t want to bother setting one up, nor did I want to make an example specific to any one, so I just built some database functions around an in-memory store. I only need access to user objects, so this should work fine – you can implement matching functions in your own application using whatever database you like.

(require '[buddy.hashers :as hashers])
(defn uuid [] (java.util.UUID/randomUUID))
(def userstore (atom {}))


(defn create-user! [user]
  (let [password (:password user)
        user-id (uuid)]
    (-> user
        (assoc :id user-id :password-hash (hashers/encrypt password))
        (dissoc :password)
        (->> (swap! userstore assoc user-id)))))


(defn get-user [user-id]
  (get @userstore user-id))


(defn get-user-by-username-and-password [username password]
  (prn username password)
  (reduce (fn [_ user]
            (if (and (= (:username user) username)
                     (hashers/check password (:password-hash user)))
              (reduced user))) (vals @userstore)))

Here, my users will have random UUIDs as ids, and be stored in an atom called userstore. Userstore contains a map of uuids to objects. There are three functions to note.

create-user! handles id generation, password hashing and storing the user in the atom. It also strips out the :password key from the incoming user map (replacint it with :password-hash. When I was writing this, I noticed that, oddly, the encrypt function from buddy (which I think is badly named, since one-way hashes are not encryption) seemed to use a different hash every time I called it. This is fine for this example – all were strong hashes – but if its important to you you should probably use the :alg option to encrypt to pick one.

get-user just gets an item from a map, not much to explain here.

get-user-by-username-and-password does what it says on the tin. The way I chose to do it was to reduce across the values of the userstore, checking the username and then the password for each user. By using reduce, I can use the reduced helper to short-circuit as soon as I find the relevant user, making this ever-so-slightly more efficient than combining filter and first.

With those functions in place, we can move on to the reusable parts of the example.

View functions

These are the functions that we’re going to hook up to routes later with compojure. Most of them are straightforward (If you’ve prepared resources in advance, as I have):

(require '[clojure.java.io :as io])

(defn get-index [req]
  (slurp (io/resource "public/index.html")))

(defn get-admin [req]
  (slurp (io/resource "public/admin.html")))

(defn get-login [req]
  (slurp (io/resource "public/login.html")))

The two POST endpoints are the interesting ones, responsible for handling logging in and logging out:

(require '[ring.util.response :refer [response redirect]])

(defn post-login [{{username "username" password "password"} :form-params
                   session :session :as req}]
  (if-let [user (get-user-by-username-and-password username password)]

    ; If authenticated
    (assoc (redirect "/")
           :session (assoc session :identity (:id user)))

    ; Otherwise
    (redirect "/login/")))

(defn post-logout [{session :session}]
  (assoc (redirect "/login/")
         :session (dissoc session :identity)))

post-login just looks up a user by username and password using the “database” function defined earlier. If it’s present, it adds the :identity key to the session as always. Note that here, we just add the user id; we’ll write a middleware later to turn this into a user object.

post-logout just removes :identity from the session object, logging the user out, and redirects to the login page.

Auth Helpers

Next are two functions that we’ll want to use later:


(defn is-authenticated [{user :user :as req}]
  (not (nil? user)))


(defn wrap-user [handler]
  (fn [{user-id :identity :as req}]
    (handler (assoc req :user (get-user user-id)))))

The first, is-authenticated, just make sure that a user is present on the session. You should be able to extrapolate from this example to see how you could, for example, check a username or a :roles attribute or whatever.

To get the :user on the request, we have a middleware called wrap-user. This grabs a user id from the :identity key, which is placed there by buddy by way of our login function. Then, it looks up the user using our database function above and adds it to the request.

Routes

(require '[buddy.auth.accessrules :refer [restrict]]
         '[compojure.core :refer [defroutes context GET POST]])

(defroutes admin-routes
  (GET "/" [] get-admin))

(defroutes all-routes
  (context "/admin" []
    (restrict admin-routes {:handler is-authenticated}))
  (GET "/" [] get-index)
  (GET "/login/" [] get-login)
  (POST "/login/" [] post-login)
  (POST "/logout/" [] post-logout))

In the routes, we see use of the restrict helper from Buddy. We use it to wrap an entire set of routes under the /admin path in our handler, passing it the is-authenticated function we wrote above. If no user is logged in, an error message will be displayed and the request will be 401’d.

The rest of the routes are hooked up in the usual way.

Putting it together

At the end, we assemble our Ring handler. You’ll remember the wrap-user middleware from above; this is deliberately placed after the buddy middlewares, since it needs access to the :identity key that buddy puts there.

(require '[buddy.auth.backends.session :refer [session-backend]]
         '[buddy.auth.middleware :refer [wrap-authentication wrap-authorization]]
         '[ring.middleware.session :refer [wrap-session]]
         '[ring.middleware.params :refer [wrap-params]])

(def backend (session-backend))
(def my-app
  (-> #'all-routes
      (wrap-user)
      (wrap-authentication backend)
      (wrap-authorization backend)
      (wrap-session)
      (wrap-params)))

(defn -main []
  ; Init admin user
  (create-user! {:username "admin" :password "1234"})
  (run-jetty my-app {:port 8080}))

For testing purposes, we include a main function that will start up a ring server, but we don’t need that to test.

Testing it in the REPL

You can send requests to my-app by just using a map, because it’s just a map, and you’ll get maps in return too. Let’s see what happens when we send a request to the /admin/ route:

(my-app {:request-method :get :uri "/admin/"})
; => {:status 401, :body "Unauthorized", :headers {}}

That is correct! We are not authorized, so we can’t see the page. Let’s fix that:

(my-app {:request-method :post :uri "/login/" :body "username=admin&password=1234"})
; => {:status 302, :headers {"Set-Cookie" ("ring-session=a174eb3e-65db-4a56-8df3-a1ce57218d7b;Path=/;HttpOnly"), "Location" "/"}, :body ""}

Here, we’ve received a response with a cookie header. We can tell we’ve been successful because we’re being redirected to / instead of /login/. Now, we can request /admin again with that cookie:

(my-app {:request-method :get :uri "/admin/"
         :headers {"cookie" "ring-session=a174eb3e-65db-4a56-8df3-a1ce57218d7b"}})
; => {:status 200 :body ...}

You’ll need to adjust your ring session id, of course. Now, we got a 200 response with the admin page body. It works! Note that the “cookie” header is lower-cased. This is actually part of the ring spec, which stumped me for a while the first time I tried passing a session cookie back – "Cookie" will not work!

Discussion

There’s one thing that I had to point out here, and that is that for this simple example, Buddy did almost nothing but help us with hashing! The session backend is mostly just Buddy moving the user id from the session map to the request map. The restrict function with our little handler is approximately equivalent to:

(defn wrap-authenticated? [handler]
  (fn [{user :user :as req}]
    (if (nil? user)
      {:status 401 :body "Unauthorized." :headers {:content-type "text/text"}}
      (handler req))))

Which can be used in the same way:

(defroutes all-routes
  (context "/admin" []
    (wrap-authenticated? admin-routes))
  ;...
)

Buddy’s only advantage in this case is the ease of a) expanding our system into more complex authorization, or b) expanding our authentication to include other methods (tokens etc.). Well, that and the hashers, but you can include the buddy-hashers project separately.

I believe Friend provides (very slightly) more for this narrow case, which I’ve also written up. But you could also just DIY and skip a dependency. The choice is up to you!

Once again, you can check out the full example on Github.

Further Reading

A special message

This is where the affiliate links live, but hear me out! I use these two services every day, and I wouldn't recommend them if I wasn't satisfied.

DigitalOcean - Purveyors of fine (and inexpensive) virtual servers. I use DigitalOcean to host Address Bin and a few others; it's my go-to host. Use this referral link for a $10 credit.

AirBnb - I've been living in AirBnbs for over a year now, and plan to for many more. If you've ever wanted to try them out, you can get a $25 discount from this referral link.