December 15, 2015

Clojure auth with buddy

I'm not your buddy, guy!

A while back, I wrote about using Friend to handle auth in Clojure. I neglected to mention (or look at, in particular) the other auth framework making waves in the Clojure community, aptly named Buddy.

Buddy is, well, another authentication/authorization framework. It also contains some lower-level crypto utilities, but the main use right now seems to be handling auth for web applications in Ring. I had heard good things about how Buddy works, so I decided to give it a try, and now you get to hear about that.

Password authentication with Buddy

Let’s take a look at how to implement your own auth in Buddy. Buddy supports a few different auth methods, including good old http basic and session auth but also the newer JWS/JWE token methods Let’s start with sessions.

Buddy is much less opinionated than Friend when it comes to how you arrange your authentication. In fact, for the most part, Buddy’s session auth just checks to make sure that an :identity key is present in the session; you’re mostly left to your own devices to get it there or remove it.

Here’s the code sample for session auth taken straight from the docs:

(require '[buddy.auth.backends.session :refer [session-backend]])

;; Create an instance
(def backend (session-backend))

;; Wrap the ring handler.
(def app (-> my-sample-handler
             (wrap-authentication backend)))

They left out the wrap-params and wrap-session middlewares from Ring that you’ll want to add if you want any of this to work, but overall that seems pretty simple. Here are some sample Ring handlers implementing login and logout:

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

(defn do-login [{{username "username" password "password" next "next"} :params
                 session :session :as req}]
  (if-let [user (lookup-user username password)]    ; lookup-user defined elsewhere
    (assoc (redirect (or next "/"))                 ; Redirect to "next" or /
           :session (assoc session :identity user)) ; Add an :identity to the session
    (response "Login page goes here")))             ; If no user, show a login page


(defn do-logout [{session :session}]
  (-> (redirect "/login")                           ; Redirect to login
      (assoc :session (dissoc session :identity)))) ; Remove :identity from session

Notice that no buddy functions are used here at all. Also notice that as an :identity, you can use whatever you want. Simple apps could use the user’s username, the user object, or some custom map with role definitions and access levels included. Be careful using a user object though, in case the user changes in the meantime!

Here’s an example of what the lookup-user function might look like returning user objects, making use of some utilities from buddy.hashers to handle the password correctly:


(require '[buddy.hashers :as hashers])


(def users {"admin" {:username "admin"
                     :hashed-password (hashers/encrypt "adminpass")
                     :roles #{:user :admin}}
            "user"  {:username "user"
                     :hashed-password (hashers/encrypt "userpass")
                     :roles #{:user}}})


(defn lookup-user [username password]
  (if-let [user (get users username)]  ; Use a database IRL
    (if (hashers/check-password password (get user :hashed-password))
      (dissoc user :hashed-password))) ; Strip out user password

JW* Token Authentication

Buddy’s most innovative feature is the JWS/JWE authentication method. In this method, like in typical token-based auth systems, the user is given a token through some means (this part is up to you) and then must send the token with all their requests to be authenticated.

However, in “typical” token auth schemes (which Buddy also supports), this requires you to store some lookup of token to user, meaning you have to put the token in a database or something like that.

With the JW* systems, the token you hand the user actually contains a JSON object, which can contain whatever data your server will need to know about the user later. To prevent tampering, this data can be signed (JWS) or encrypted (JWE) – in the former case, the client will have access to the user object, in the latter they will not. This way, you don’t need to store the user’s token, although you still need some way to authenticate them to begin with (unless you want them to simply lose all access and have to create a new user if they lose the token).

Anyhow, JW* is really neat and here’s how it looks:

(require '[buddy.auth.backends.token :refer [jws-backend]])

(def secret "mysecret")

(def backend (jws-backend {:secret secret}))

(def app (-> your-ring-app
             (wrap-authentication backend)))

Now, users can authenticate by passing a token in an Authorization header:

Authorization: Token <Token goes here>

Note that the “Token” part of the above is customizable by passing an option called :token-name to jws-backend. For example, if you were implementing OAuth2 you might pass :token-name "Bearer".

That just leaves the matter of giving the user the token. You can do this with the sign function. Here’s a re-implementation of do-login above using JWS:

(require '[buddy.sign.jws :as jws])
(require '[cheshire.core :as json])

(defn do-login [{{username "username" password "password" next "next"} :params :as req}]
  (if-let [user (lookup-user username password)]    ; lookup-user defined elsewhere
    {:status 200
     :headers {:content-type "application/json"}
     :body (json/encode {:token (jws/sign user secret)})}
    (redirect "/login")                             ; Redirect to /login on failure

Remember, the user can read your jws-signed tokens, so don’t put any sensitive information in there or anything – use jwe if you want to do that, or better yet don’t.

Authorization

After you’ve done the above, Buddy provides some help with authorization, too. At the most basic level, you can pass a ring request to the authenticated? function anywhere you need to know if a user is authenticated:

(require '[buddy.auth :refer [authenticated?]])

(defn my-handler [req]
  (if (authenticated? req)
    ; Do auth'd stuff
    ; Deny access))

Note that different auth backends use different methods to check whether a request is authenticated. The session method checks the :identity key in the ring session, the token methods look for Authorization: Token headers, and so on.

For more control, you can make your own checkers and hook them up to rules. The user object is available on the incoming request on the :identity key, although note that in some instances (coughJWScough) it may have been translated to JSON and back and lost some type information in the process. For example, {:roles #{:admin :user}} becomes {:roles ["admin" "user"]}. To check this property, for example, you might write something like this:

(defn is-admin [{user :identity :as req}]
  (contains? (apply hash-set (:roles user)) "admin"))

You can use validators like this in a few ways, as documented in the authorization docs. My preferred method is the restrict function, which you can use to wrap a ring handler:

(require '[buddy.auth.accessrules :refer [restrict]])

(defn admin-view [req]
  (response "ADMINS ONLY"))

(defroutes myapp
  (GET "/admin" [] (restrict admin-view {:handler is-admin})))

That’s about it for Buddy. I’m excited to try it out in my next project – perhaps some JWS-based OAuth is in order.

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.