Phil Cooper / May 18 2023
Buddy
Authentication and authorization in Clojure with Buddy.
{: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