Buddy

Authentication and authorization in Clojure with Buddy.

buddy-auth on GitHub

budd-hashers on GitHub

{:deps {org.clojure/clojure {:mvn/version "1.10.3"}
        ;; complient is used for autocompletion
        compliment/compliment {:mvn/version "0.3.9"}
        buddy/buddy-auth {:mvn/version "3.0.323"}
        buddy/buddy-hashers {:mvn/version "1.8.158"}
        io.pedestal/pedestal.service {:mvn/version "0.5.9"}
        clojure.java-time/clojure.java-time {:mvn/version "1.2.0"}
}}
Extensible Data Notation
(ns my-buddy.auth
  (:require [buddy.hashers :as hashers]
            [buddy.auth :as auth]
            [buddy.sign.jwt :as jwt]
            [buddy.auth.backends :as backends])
  (:import [java.security MessageDigest]
           [java.math BigInteger]))
0.0s
(def jws-secret "some_randomish-string>5358596772")
(def user-database-not-this-hard-coded-thing
  {"admin" {:username        "admin"
            :hashed-password (hashers/encrypt "abc")
            :roles           #{:user :admin}}
   "joe"  {:username         "joe"
           :email            "joe.cool@example.com"
           :hashed-password (hashers/encrypt "so-cool")
           :iex-public-key "some-fancy-thing-that-enables-iex-access"
           :roles            #{:user :admin}}
   "user"  {:username        "user"
            :hashed-password (hashers/encrypt "ok-pwd")
            :roles           #{:user}}})
(defn check-user-password
  "return results from database (without hashed-password)"
  [username password & args]
  (if-let [user (get user-database-not-this-hard-coded-thing username)]
    (if (some->> (:hashed-password user)
                 (hashers/verify password)
                 :valid)
      (dissoc user :hashed-password))))
(defn md5
  "get md5 checksum of a string i.e. for hash of email for gravitar" 
  [s]
  (let [algorithm (MessageDigest/getInstance "MD5")
        raw (.digest algorithm (.getBytes s))]
    (format "%032x" (BigInteger. 1 raw))))
(defn login-handler
  [request]
  (let [{username :username
         password :password} (:json-params request)]
    (if-let [user-info (check-user-password username password)]
      (let [token (jwt/sign user-info jws-secret)]
        {:status  200
         :body    (assoc user-info :token token)
         ; not sure this header does anything here:
         :headers {"Authorization" (str "Token " token)}})
      {:body "user/password combination not found " :status 401})))
1.4s
(login-handler {:json-params {:username "joe" :password "so-cool"}})
0.8s

Expiring tokens

(require '[java-time.api :as jt])
0.0s
(defn login-handler-expiring
  [request]
  (let [{username :username
         password :password} (:json-params request)]
    (if-let [user-info (some-> (check-user-password username password)
                               )]
      (let [token (-> user-info
                    (merge {:iat (jt/instant)
                            :exp (jt/plus (jt/instant) (jt/minutes 15))})
                    (jwt/sign jws-secret))]
        {:status 200
         :body (assoc user-info :token token)}))))
                                                                                                                                                                                                                                                                                             ; not sure this header does anything here:\n         :headers {
0.0s
(login-handler-expiring {:json-params {:username "joe" :password "so-cool"}})
0.5s
(def expiring-token
  (some-> (check-user-password "joe" "so-cool")
          (merge {:iat (jt/instant)
                  :exp (jt/plus (jt/instant) (jt/seconds 10))})
          (jwt/sign jws-secret)))
(jwt/unsign expiring-token jws-secret)
0.5s
(try
  (Thread/sleep 12000)
  (jwt/unsign expiring-token jws-secret)
  
  (catch Exception e (-> e Throwable->map (select-keys [:cause :data]))))
12.0s

Pedestal Interceptor

(require
  '[io.pedestal.interceptor :refer [interceptor]]
  '[buddy.hashers :as hashers]
  '[buddy.auth :refer [authenticated?]]
  '[buddy.auth.backends.token :as token]
  '[buddy.sign.jws :as jws]
  '[buddy.auth.middleware :refer [authentication-request]]
  )
0.0s
(def token-backend
  "created at startup time"
  (token/jws-backend
    {:secret jws-secret}))
;; from interceptor code
(defn token-authentication
  "use the provided (:authfn backend) in authentication-request middleware
  e.g. call check-user-token"
  [backend]
  (interceptor
    {:name  ::token-authentication
     :enter (fn [ctx]
              (-> ctx
                  ;; just append it for future reference.
                  ;; TODO does this already  happen in in the backend: see buddy.auth.middleware/authenticate-request
                  ;; (assoc :auth/backend backend)
                  (update :request authentication-request backend)))}))
(def token-authorization
  "on enter check for :identity and call unauthorized-handler if nil"
  (interceptor
    ;; should this be using the IAuthorization -handle-unauthenticated method?
    {:name  ::token-authorization
     :enter (fn [{:keys [request] :as ctx}]
              (if (authenticated? request)
                ctx
                (-> ctx
                    ;(interceptor.chain/terminate) ;; is this necessary  shouldn't response cause terminate? is this just a testing thing?
                    (assoc :response {:status 401 :headers {} :body "Unauthorized"}))
       ))}))
0.0s

So this is what the signed token will look like that we pass back to an authenticated user

(def freds-token
  (-> {:username "fred" :email "fred.flintstone@example.com" :roles #{"user" "toon"}}
    (jwt/sign jws-secret)))
freds-token
0.0s

(-> (login-handler {:json-params {:username "joe" :password "so-cool"}})
  :body)
0.6s
;; same as above
(defn login-handler
  [request]
  (let [{username :username
         password :password} (:json-params request)]
    (if-let [user-info (check-user-password username password)]
      (let [token (jwt/sign user-info jws-secret)]
        {:status  200
         :body    (assoc user-info :token token)
         ; not sure this header does anything here:
         ; :headers {"Authorization" (str "Token " token)}
         })
      {:body "user/password combination not found " :status 401})))
0.0s

(def response (login-handler {:json-params {:username "joe" :password "so-cool"}}))
0.7s
(-> response
  (get-in [:body :token])
  (jwt/unsign jws-secret))
0.0s

(require '[io.pedestal.interceptor.chain :as chain])
0.0s
;; empty request 401 unauthorized
(-> {:request {}}
  (chain/execute [token-authorization])
  :response
  print)
0.3s
;; bad token
(-> {:request {:headers {:authorization "Bearer of-ill-will"}}}
  (chain/execute [token-authorization])
  :response
  print)
0.4s
;; good token (from above) adds identity
(-> {:request {:headers {:authorization (str "Token " freds-token)}}}
  (chain/execute [(token-authentication token-backend)])
  print)
0.3s
;; same good token with the token-authorization intercepor (which passes)
(-> {:request {:headers {:authorization "Token eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImZyZWQiLCJlbWFpbCI6ImpvZS5jb29sQGV4YW1wbGUuY29tIiwicm9sZXMiOlsidXNlciIsInRvb24iXX0.Y7w1rAtB6WDWpnsSX9yYJubxcC52M01u42Y-wCkykLU"}}}
  (chain/execute [(token-authentication token-backend) token-authorization])
  print)
0.4s

`token-authorization` just checks with `authenticated?` with only checks for for `:identity` . Let's add an interceptor appropriate for all points that would require access to iex (or quandl or Bloomberg or any other protected data).

(def iex-authorization
  "echeck whether " 
  (interceptor
    ;; should this be using the IAuthorization -handle-unauthenticated method?
    {:name ::token-authorization
     :enter (fn [{:keys [request] :as ctx}]
              (if (get-in request [:identity :iex-public-key])
                ctx
                (if (authenticated? request)
                  (assoc ctx :response {:status 403 :headers {} :body "Unauthorized for IEX"})
                  (assoc ctx :response {:status 401 :headers {} :body "Unauthenticated"}))))}))
0.0s
(-> {:request {:headers {}}}
  (chain/execute [(token-authentication token-backend) iex-authorization])
  print)
0.4s
(-> {:request {:headers {:authorization "Token eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImZyZWQiLCJlbWFpbCI6ImpvZS5jb29sQGV4YW1wbGUuY29tIiwicm9sZXMiOlsidXNlciIsInRvb24iXX0.Y7w1rAtB6WDWpnsSX9yYJubxcC52M01u42Y-wCkykLU"}}}
  (chain/execute [(token-authentication token-backend) iex-authorization])
  print)
0.4s
(-> {:request {:headers {:authorization "Token eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImpvZSIsImVtYWlsIjoiam9lLmNvb2xAZXhhbXBsZS5jb20iLCJpZXgtcHVibGljLWtleSI6InNvbWUtZmFuY3ktdGhpbmctdGhhdC1lbmFibGVzLWlleC1hY2Nlc3MiLCJyb2xlcyI6WyJhZG1pbiIsInVzZXIiXX0.ojkjMCRZaGWJcMARdWo_gToVKg00FoNMgQX93wQIwAU"}}}
  (chain/execute [(token-authentication token-backend) iex-authorization])
  print)
0.4s
0.0s
Runtimes (1)