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.