Created
December 16, 2012 02:05
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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