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.