August 30, 2018

Using Buddy for Authentication with JWT

buddy is a set of clojure modules and libraries to incorporate various security feature in your ring application. It is also fairly non-opinionated allowing you to decide how to handle various scenarios. Check out their github page to know more about the library. Their documentation can be found here. They also have basic examples for using various authentication schemes here.

In this tutorial we will configure our webapp to use signed JSON Web Tokens (JWT) and also setup access rules to make sure certain routes can only be accessed with specific authorization levels. If you are not familiar with JWT, This article does a good job of explaining the basics.

Before we start, add the following buddy dependencies to your build.boot or project.clj file.

[buddy/buddy-auth "2.1.0"]
[buddy/buddy-core "1.5.0"]
[buddy/buddy-sign "3.0.0"]

Let us start off by defining a separate namespace to hold all our authentication/authorization code.

(ns buddy-sample-jwt.auth
  (:require
   [buddy.sign.jwt :as jwt]
   [buddy.auth.backends.token :refer [jws-backend]]
   [buddy.auth.accessrules :refer (success error)]
   [buddy.auth :refer [authenticated?]]
   [clj-time.core :as time]
   [clojure.data.codec.base64 :as b64]
   [taoensso.timbre :as log]
   [buddy-sample-jwt.data.user :as user]))

;; Secret used in HMAC Signing of JWT
;;   New secret generated on application start
;;   Take 5 random number, concatenate them and convert to base64
(defonce secret
  (String.
   (b64/encode
    (.getBytes
     (apply str (take 5 (repeatedly #(str (rand)))))))
   "UTF-8"))

;; Create an instance of auth backend.
(def auth-backend (jws-backend {:secret secret :options {:alg :hs512}}))

;; Semantic response helpers
(defn ok [d] {:status 200 :body d})
(defn bad-request [d] {:status 400 :body d})

(defn authenticate
  "Checks if request (with username/password :query-params)
  or username/password is valid"
  ([request]
   (let [username (get-in request [:params :username])
         password (get-in request [:params :password])]
     (authenticate username password)))
  ([username password]
   (if (and username password)
     (user/login? username password)
     false)))

;; authentication handler used with buddy ring wrappers
(defn auth-handler
  [request]
  (if (authenticate request)
    (let [username (get-in request [:params :username])
          password (get-in request [:params :password])
          claims {:user (keyword username)
			      :admin? (user/is-admin? username)
                  :exp (time/plus (time/now) (time/seconds 3600))}
          token (jwt/sign claims secret {:alg :hs512})]
      (ok {:token token}))
    (bad-request {:message "invalid auth data"})))

;; Access Level Handlers

(defn authenticated-access
  "Check if request coming in is authenticated with user/password
  or a valid JWT token"
  [request]
  (if (or (authenticated? request)
          (authenticate request))
    true
    (error "access not allowed")))
	
(defn admin-access
  "Check if request with JWT token has :is-admin? claim"
  [request]
  (if (and (:identity request)
           (:is-admin? (:identity request)))
    true
    (error "requires admin access")))

In the above code we have 3 main pieces which buddy requires.

  1. An authentication backend (auth-backend). This will determine if your buddy ring wrappers (explained below) is going to use Session tokens, JWT or other authentication schemes.
  2. An authentication handler (auth-handler). This function should be called when you want to authenticate an incoming request (with a username and password) and generate a JWT token. We will see this being called from our /login route definition below in buddy-sample-jwt.routes.
  3. We also define our access level handlers (authenticated-access and admin-access).

In our above code we also have login? from user namespace. This function determines if a [username, password] is valid. This can be whatever matches your application. For example, query the Database for the user’s password-hash and match it with the hash of the password provided.

I will post an article on how to use Argon2 in your clojure app for hashing your passwords in a future post and link it here. Edit: Added article

Next, let us look at how we configure our ring app to insert our buddy ring wrappers.

(ns buddy-sample-jwt.core
  (:require [compojure.handler        :as    handler]
            [ring.middleware.json     :refer [wrap-json-response]]
            [buddy.auth.middleware    :refer [wrap-authentication wrap-authorization]]
            [buddy.auth.accessrules   :refer [wrap-access-rules]]
            [taoensso.timbre          :as    log]
            [buddy-sample-jwt.routes  :refer [app-handler access-rules]]
            [buddy-sample-jwt.auth    :refer [auth-backend]])
  (:gen-class))

(defn on-error
  [request value]
  {:status 403
   :headers {}
   :body "Not authorized"})

(def app (as-> #'app-handler $
           (wrap-access-rules $ {:rules access-rules :on-error on-error})
           (wrap-authorization $ auth-backend)
           (wrap-authentication $ auth-backend)
           (wrap-json-response $)
           (handler/site $)))

We are using three buddy wrappers here. The wrap-authorization and wrap-authentication wrappers both take in the auth-backend we defined above. They also parse the JWT Token in the Authorization Header of the request and create the :identity key mapped to the claims from the JWT in the request and pass it along.

The wrap-access-rules sets the access controls. If you do not want any access level controls, you can skip this wrapper.

Finally, let us see how to set up access rules

(ns buddy-sample-jwt.routes
  (:require [compojure.core     :refer [defroutes context routes GET POST]]
            [compojure.route    :refer [not-found files resources]]
            [ring.util.response :refer [response]]
            [taoensso.timbre    :as    log]
            [buddy-sample-jwt.auth :as auth]
            [buddy-sample-jwt.api  :as api]))

(defroutes api-routes
  (context "/api" []
           (GET "/instances" [] api/get-instancces)
           (GET "/ticket/:id" [id]
                    (api/get-ticket id))
           (GET "/tickets" [list style qual limit page]
                (api/get-tickets list style qual limit page))))

(defroutes gen-routes 
  (GET "/" [] "Hello from Compojure!")  ;; for testing only
  (GET "/login" [] auth/auth-handler)   ;; authenticate user
  (files "/" {:root "target"})          ;; to serve static resources
  (resources "/" {:root "target"})      ;; to serve anything else
  (not-found "404 Page Not Found"))     ;; page not found

(defn any-access [_]
  true)

(def access-rules [{:pattern #"^/login$"
                    :handler any-access}
                   {:pattern #"^/api/.*"
                    :handler auth/authenticated-access}
				   {:pattern #"^/admin/.*"
                    :handler auth/admin-access}])

(def app-handler (routes api-routes
                         gen-routes))

Points to note here are

  1. We are defining our /login route to be handled with our auth/auth-handler. When a user accesses this endpoint with a valid [username password] pair, they will receive a signed JWT token, which they can use for future access calls to our server.
  2. We define our access-rules, which we passed to the wrap-access-rules wrapper. So any request a user makes to any /api/* endpoint will require a valid username password pair or an unexpired, signed JWT token. You can combine multiple access rules using :or or :and like so
[{:pattern #"^/foo$"
  :handler {:and [authenticated-access admin-access}}]

Check the documentation for more info on advanced access rules.

Powered by Hugo & Kiss.