Skip to content

Instantly share code, notes, and snippets.

@zoldar
Created December 16, 2012 02:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save zoldar/4302375 to your computer and use it in GitHub Desktop.
Save zoldar/4302375 to your computer and use it in GitHub Desktop.
Exploration of compojure and cemerick/friend with an angle towards integration of cemerick/friend with librarian-clojure.
(ns friendtest.core
(:use midje.sweet
ring.mock.request
compojure.core
friendtest.core
[ring.middleware.session store memory]
[ring.middleware.session.memory :only (memory-store)]
[ring.middleware.session :only (wrap-session)]
[cemerick.friend.util :only (gets)])
(:require [compojure [handler :as handler]]
[compojure [route :as route]]
[clojure.java [io :as io]]
[cemerick.friend :as friend]
[cemerick.friend
[workflows :as workflows]
[credentials :as creds]]))
;; So, I am starting with a blank slate. What I'm trying to do here, is to
;; gain understanding of how to work with compojure and how to correctly integrate
;; cemerick's friend auth library with it. My last endeavor into that issue on
;; occasion of playing with librarian-clojure was quite chaotic and at the end
;; of the day, I was left with a one big mess of a project in a non-working state.
;; Even basic auth wasn't functioning and, to be honest, I couldn't tell why.
;;
;; So in this instalment I'm going to take a completely different approach. I'll
;; start with a clean project, incrementally testing and understanding basic
;; mechanisms in compojure and then in com.cemerick/friend.
;;
;; Let's start with a basic route definition and app using it.
(defroutes simple-routes
(GET "/" [] "Hello compojure!")
(route/not-found "Page not found"))
(def simple-app
(handler/site simple-routes))
;; A simplest possible way to see routing in action is to use the courtesy
;; of request.mock
(fact "get / => Hello ..."
(let [req (request :get "/")
response (simple-app req)]
(:status response) => 200
(:body response) => (contains "Hello" )))
;; => true
;; What could be expected. Now let's trigger not-found handler
(fact "get /meiamnot => 404, Page not found"
(let [req (request :get "/meiamnot")
response (simple-app req)]
(:status response) => 404
(:body response) => (contains "Page not found")))
;; => true
;; As expected, we got a 404 status message.
;; Now, let's define a tiny little bit more interesting set of rules
;; emulating an app with some basic auth. Some basic session fiddling
;; will also go into that mix.
(defroutes auth-routes
(POST "/login" [username password :as {session :session}]
(if (and (= username "admin") (= password "admin"))
{:body "Logged in!"
:session (assoc session :user username)}
"Login failed!"))
(ANY "/logout" {session :session}
{:body "Logged out."
:session nil}))
(def auth-app
(handler/site #'auth-routes))
;; Now let's try logging in
(fact "post /login with user, password => success"
(let [req (request :post "/login" {:user "admin" :password "admin"})
response (auth-app req)]
(:status response) => 200
(:body response) => (contains "Logged in")
(:session response) => (contains {:user "admin"})))
;; Something has gone wrong this time. Looks like the submitted data were
;; wrong or non-existent. Let's find out.
(apply str
(line-seq
(io/reader (:body (request :post "/login"
{:user "admin" :password "admin"})))))
;; => user=admin&password=admin
;; ... of course anybody half-awake would notice that I've passed :user instead
;; of :username used in routing. Riight. No testing will help when one
;; is as observant as me. Uh. So, here's a test with correction.
(fact "post /login with user, password => success"
(let [req (request :post "/login" {:username "admin" :password "admin"})
response (auth-app req)]
(:status response) => 200
(:body response) => (contains "Logged in")
(:session response) => (contains {:user "admin"})))
;; Looks like this time I've botched the way the session should be returned.
(auth-app (request :post "/login" {:username "admin" :password "admin"}))
;; => {:status 200, :headers {"Set-Cookie" ("ring-session=56ab0950-06e2-4743-9149-a4144e19268c;Path=/")}, :body "Logged in!"
;; Now it's clear that my assumption about :session being in response was naive.
;; Of course it would be ridiculous to pass session contents back over the wire.
;; Silly me. But then, how do I test for presence of particular data in session
;; store? Seems like I'll have to declare session store explicitly.
(def session-store (memory-store))
(def auth-app
(-> #'auth-routes handler/site (wrap-session {:store session-store})))
;; Now, let's rerun the request through the app and see what's in session store
(auth-app (request :post "/login" {:username "admin" :password "admin"}))
(read-session session-store :user)
;; => nil
;; So, nothing in there. Let's just see if the store actually works
(write-session session-store :foo "bar")
(read-session session-store :foo)
;; => "bar"
;; Now I've realized that handler/site already wraps with session middleware
;; which in turn strips the session data and that's the cause.
;; What's more, handler/site provides a way to supply the options to session
;; wrapper. So, again:
(def auth-app
(handler/site #'auth-routes {:session {:store session-store}}))
(auth-app (request :post "/login" {:username "admin" :password "admin"}))
(read-session session-store :user)
;; => nil
;; Still nothing. Let's dig a bit deeper. The wrap-session is the main suspect
;; (or rather a place where I could see what exactly have I screwed up).
;; After digging through the internals of wrap-session and related wrap-cookies
;; it became clear that the part that was missing in request was the cookie
;; holding the key under which session is stored in the session-store. In
;; order to confirm it, here's what I've came up with
(write-session session-store "abc" {:foo "bar"})
(defn handler [req] {:body (:session req)})
(def sess-req (assoc (request :get "/") :cookies {"ring-session" {:value "abc"}}))
(def wrapped-handler (wrap-session #'handler {:store session-store}))
(wrapped-handler sess-req)
=> {:body {:foo "bar"}}
;; Yay, it's working! Let's get back to what I've wanted to test (I'll repeat all
;; the necessary declaration even though some may have been already present.
(def session-store (memory-store))
(def session-cookie "abc")
(write-session session-store session-cookie {})
(def auth-app
(handler/site #'auth-routes {:session {:store session-store}}))
(fact "post /login with user, password => success"
(let [req (assoc (request :post "/login" {:username "admin" :password "admin"})
:cookies {"ring-session" {:value session-cookie}})
response (auth-app req)]
(:status response) => 200
(:body response) => (contains "Logged in")
(read-session session-store session-cookie) => (contains {:user "admin"})))
;; Finally, what was expected. However, defining mock request with cookie is a bit
;; verbose. A little helper may reduce the noise
(defn request-with-session
([method uri session-cookie]
(request-with-session method uri session-cookie {}))
([method uri session-cookie params]
(assoc (request method uri params)
:cookies {"ring-session" {:value session-cookie}})))
(fact "post /login with user, password => success"
(let [req (request-with-session :post "/login"
session-cookie
{:username "admin" :password "admin"})
response (auth-app req)]
(:status response) => 200
(:body response) => (contains "Logged in")
(read-session session-store session-cookie) => (contains {:user "admin"})))
;; A little bit better, still there has to be some setup done upfront. However
;; I won't wrap it further for now.
;; Test for combined login/logout follows
(def session-store (memory-store))
(def auth-app
(handler/site #'auth-routes {:session {:store session-store}}))
(fact "post /login user, password => logged in => get /logout => logged out"
(write-session session-store session-cookie {})
(let [req-login (request-with-session :post "/login"
session-cookie {:username "admin" :password "admin"})
req-logout (request-with-session :get "/logout" session-cookie)
resp-login (auth-app req-login)]
(read-session session-store session-cookie) => (contains {:user "admin"})
(auth-app req-logout)
(read-session session-store session-cookie =not=> (contains
{:user anything}))))
;; Still to test a real set of routes, one has to manually rewrap them
;; and this can cause problems when the original app has custom configuration.
;; Testing sandbar stateful session should look more or less the same
;; - it builds upon ring's wrap-session.
;; Now, on to cemerick/friend. For starters, let's look at the most basic
;; authentication.
(def session-store (memory-store))
(def session-cookie "cookie-monster")
(write-session session-store session-cookie {})
(def users {"admin" {:username "admin"
:password (creds/hash-bcrypt "admin")
:roles #{:admin}}})
(defroutes friend-routes
(GET "/" request {:body request})
(friend/logout (ANY "/logout" request
{:body "Logged out."})))
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn
users)
:workflows [(workflows/interactive-form)]})
(handler/site {:session {:store session-store}})))
;; Notice that friend/authenicate must be the first wrapper around the app.
;; The rest of middleware should wrap over it (unless one has a valid reason
;; not to).
;; The attempt at logging in:
(friend-app (request-with-session :post "/login" session-cookie
{:username "admin" :password "admin"}))
;; => {:status 303, :headers {"Location" "/"}, :body ""}
;; ... and one that should fail.
(friend-app (request-with-session :post "/login" session-cookie
{:username "meimnot" :password "boohoo"}))
;; => {:status 302, :headers {"Location" "/login?&login_failed=Y&username=meimnot"}, :body ""}
;; What does the session contain?
(read-session session-store session-cookie)
;; => {:cemerick.friend/identity {:current "admin", :authentications {"admin" {:identity "admin", :username "admin", :roles #{:admin}}}}}
;; It's worth noting that - at least in default setup - a failed attempt
;; at logging in when already logged in does not invalidate the current
;; identity. That's a thing that one has to decide for himself how to handle.
;; Looks okay so far. Now what if we didn't want do redirect in either case but
;; return a particular response? As in a JS frontend doing xhr calls for
;; those actions? The interactive-form workflow contains two configuration
;; parameters which can be of potential interest in that case: redirect-on-auth?
;; and login-failure-handler. First, let's see what happens when redirect-on-auth?
;; is set to false.
(write-session session-store session-cookie {})
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn
users)
:workflows [(workflows/interactive-form :redirect-on-auth? false)]})
(handler/site {:session {:store session-store}})))
(friend-app (request-with-session :post "/login" session-cookie
{:username "admin" :password "admin"}))
;; => nil
(friend-app (request-with-session :post "/login" session-cookie
{:username "meimnot" :password "boohoo"}))
;; => {:status 302, :headers {"Location" "/login?&login_failed=Y&username=meimnot"}, :body ""}
;; There's no redirect in case of successful login but neither there's any useful
;; feedback. In case of failure, there's still redirect happening. In case
;; of the latter we should be able to change it with login-failure-handler.
(write-session session-store session-cookie {})
(defn login-failure-handler
[request]
{:body "Login failed"})
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn
users)
:workflows [(workflows/interactive-form
:redirect-on-auth? false
:login-failure-handler login-failure-handler)]})
(handler/site {:session {:store session-store}})))
(friend-app (request-with-session :post "/login" session-cookie
{:username "meimnot" :password "boohoo"}))
;; => {:body "Login failed"}
;; Looks better.
;; Now, there's still problem with succesful login. After going through authorization
;; process in friend, it came out that succesful response is simply explicitly
;; defined handler for the login route. So the following should work:
(write-session session-store session-cookie {})
(defroutes friend-routes
(GET "/" request {:body request})
(POST "/login" request {:body "Login successful"})
(friend/logout (ANY "/logout" request
{:body "Logged out."})))
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn
users)
:workflows [(workflows/interactive-form
:redirect-on-auth? false
:login-failure-handler login-failure-handler)]})
(handler/site {:session {:store session-store}})))
(friend-app (request-with-session :post "/login" session-cookie
{:username "admin" :password "admin"}))
;; => {:status 200, :headers {}, :body "Login successful"}
;; ... and it does!
;; Next thing that I would like to explore is the possibility
;; to avoid the need for massive refactoring of existing codebase when
(def users {"admin" {:login "admin"
:passwd (creds/hash-bcrypt "admin")
:roles #{:admin}}})
;; Let's assume that existing routing and templates (and even storage) use login
;; for username and passwd for password.
;; Ufortunately interactive-form seems not to provide the ability to operate
;; on such param names. One possible way is to add wrapper that alters params.
(defn custom-interactive-form
[& {:keys [login-uri username-field password-field]
:or {username-field :username password-field :password}
:as form-config}]
(let [form-fn (apply workflows/interactive-form (mapcat identity form-config))]
(fn [{:keys [uri request-method params] :as request}]
(when (and (gets :login-uri form-config (::friend/auth-config request))
(= :post request-method))
(let [[username password] ((juxt username-field password-field) params)]
(form-fn (-> request
(assoc-in [:params :username] username)
(assoc-in [:params :password] password))))))))
;; I'm not sure if such capability shouldn't land in friend itself.
;; Because user data schema in storage also changed, credential-fn must also understand it.
;; There's ability to customize the password key with metadata, when passing
;; found credentials to the function. Let's create a function that does it:
(defn get-user-by-name [username]
(if-let [result (users username)]
(with-meta result {:cemerick.friend.credentials/password-key :passwd})))
;; And now, everything combined in the app:
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn get-user-by-name)
:workflows [(custom-interactive-form
:username-field :login
:password-field :passwd
:redirect-on-auth? false
:login-failure-handler login-failure-handler)]})
(handler/site {:session {:store session-store}})))
(friend-app (request-with-session :post "/login" session-cookie
{:login "admin" :passwd "admin"}))
;; => {:status 200, :headers {}, :body "Login successful"}
(friend-app (request-with-session :post "/login" session-cookie
{:login "wrong" :passwd "doh"}))
;; => {:body "Login failed"}
;; Great!
;; Now on to the final part - role based authorization. The defined credentials store
;; already contains a map of roles. Let's extend the routes by a handler that requires
;; :admin role to be reachable.
(write-session session-store session-cookie {})
(def users {"admin" {:login "admin"
:passwd (creds/hash-bcrypt "admin")
:roles #{:admin}}
"joe" {:login "joe"
:passwd (creds/hash-bcrypt "joe")
:roles #{:user}}})
(defroutes friend-routes
(GET "/" request {:body request})
(GET "/admin" request (friend/authorize #{:admin} "Top secret!"))
(POST "/login" request {:body "Login successful"})
(friend/logout (ANY "/logout" request
{:body "Logged out."})))
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn get-user-by-name)
:workflows [(custom-interactive-form
:username-field :login
:password-field :passwd
:redirect-on-auth? false
:login-failure-handler login-failure-handler)]})
(handler/site {:session {:store session-store}})))
(friend-app (request-with-session :get "/admin" session-cookie))
;; => {:status 302, :headers {"Location" "/login"}, :body ""}
;; As expected, the attempt to view the page as anonymous
;; resulted in redirect to the login page. As far as I can tell,
;; there's no easy way to customize that behavior. However, it's
;; a reasonable default and it would be rather quite unusual to diverge
;; from it.
(friend-app (request-with-session :post "/login" session-cookie
{:login "joe" :passwd "joe"}))
;; => {:status 200, :headers {}, :body "Login successful"}
(friend-app (request-with-session :get "/admin" session-cookie))
;; => {:status 403, :body "Sorry, you do not have access to this resource."}
;; This can be of course customized passing a function to friend/authenticate under
;; :unauthorized-handler key
(friend-app (request-with-session :post "/login" session-cookie
{:login "admin" :passwd "admin"}))
;; => {:status 200, :headers {}, :body "Login successful"}
(friend-app (request-with-session :get "/admin" session-cookie))
;; => {:status 200, :headers {"Content-Type" "text/html; charset=utf-8"}, :body "Top secret!"}
;; Nice! Now when we are at it, the really final thing - logout.
(read-session session-store session-cookie)
;; => {:cemerick.friend/identity {:current nil, :authentications {nil {:identity nil, :login "admin", :roles #{:admin}}}}, :cemerick.friend/unauthorized-uri "/admin"}
;; Ouch, now I've noticed that the earlier change with field naming handling had
;; broken the identity. It's still working, however, only as long as there is
;; only one entry in :authentications. We will get back to it in a moment.
(friend-app (request-with-session :get "/logout" session-cookie))
;; => {:status 200, :headers {}, :body "Logged out."}
(read-session session-store session-cookie)
;; => {:cemerick.friend/unauthorized-uri "/admin"}
;; Identity details got removed from session - so that's also working as expected.
;; Now, to fix the problem of nil identity, we have to assure, that map returned
;; by credential-fn contains :username entry with the associated username.
(defn get-user-by-name [username]
(if-let [result (users username)]
{:username (:login result) :password (:passwd result)}))
;; By the way, I've got rid of fiddling with meta, because it was unnecessary
;; in that particular case.
(def friend-app
(-> #'friend-routes
(friend/authenticate {:credential-fn (partial creds/bcrypt-credential-fn get-user-by-name)
:workflows [(custom-interactive-form
:username-field :login
:password-field :passwd
:redirect-on-auth? false
:login-failure-handler login-failure-handler)]})
(handler/site {:session {:store session-store}})))
(friend-app (request-with-session :post "/login" session-cookie
{:login "admin" :passwd "admin"}))
;; => {:status 200, :headers {}, :body "Login successful"}
(read-session session-store session-cookie)
;; => {:cemerick.friend/identity {:current "admin", :authentications {"admin" {:identity "admin", :username "admin"}, nil {:identity nil, :login "joe", :roles #{:user}}}}}
;; Finally, it looks right.
;; The really really last thing that is left, is handling instant sign in on successful
;; registration. This time I'll just show a solution that is used by clojars website.
;; Registration is defined as an additional workflow that - when succesful - calls
;; workflow/make-auth along with newly created user's data to establish his identity.
;; The workflow definition in that particular case looks as follows:
(defn register [{:keys [email username password confirm ssh-key pgp-key]}]
(if-let [errors (apply validate {:email email
:username username
:password password
:ssh-key ssh-key
:pgp-key pgp-key}
(new-user-validations confirm))]
(response (register-form (apply concat (vals errors)) email username ssh-key))
(do (add-user email username password ssh-key pgp-key)
(workflow/make-auth {:identity username :username username}))))
(defn workflow [{:keys [uri request-method params]}]
(when (and (= uri "/register")
(= request-method :post))
(register params)))
;; That concludes (for now) my exploration of compojure and cemerick/friend.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment