cemerick / friend

An extensible authentication and authorization library for Clojure Ring web applications and services.
1.16k stars 122 forks source link

new workflow for single page applications (non-redirecting API) #120

Open GetContented opened 10 years ago

GetContented commented 10 years ago

Hi,

I have a use-case for a new type of workflow which is for single page applications. These require user/pass combo through a http post, but there's no form or redirection involved (kind of a hybrid of http-basic and interactive-form in a way).

So, I wrote the following code. It's not a pull request because I don't have it in a separate repo or anything, and it's lifted straight from my source. Maybe it could be adjusted and added to the main repo as workflows/api?

I hope I haven't done anything crap by submitting this. I just found this incredibly useful and it may be to others, too.

(ns myapp.middleware.auth
  (:use [cemerick.friend.util :only (gets)])
  (:require [ring.util.request :as req]
            [cemerick.friend :as friend]
            (cemerick.friend [workflows :as workflows]
                             [credentials :as creds])))

; the following two methods are private helper functions for the api-workflow function, taken from cemerick.freind.credentials
(defn- username
  [form-params params]
  (or (get form-params "username") (:username params "")))

(defn- password
  [form-params params]
  (or (get form-params "password") (:password params "")))

; this is a custom workflow for Single Page Application login
(defn api-workflow
  [& {:keys [login-uri credential-fn login-failure-handler] :as form-config}]
  (fn [{:keys [request-method params form-params] :as request}]    
    (when (and (= (gets :login-uri form-config (::friend/auth-config request)) (req/path-info request))
               (= :post request-method))

      (let [credentials {:username (username form-params params)
                         :password (password form-params params)}
            {:keys [username password]} credentials]
        (if-let [user-record (and username password
                                  ((gets :credential-fn form-config (::friend/auth-config request))
                                   (with-meta credentials {::friend/workflow :api-workflow})))]
          (workflows/make-auth user-record
                     {::friend/workflow :api-workflow
                      ::friend/redirect-on-auth? false})
          ((or (gets :login-failure-handler form-config (::friend/auth-config request))
               (fn [handler] {:status 400 :body "Incorrect credentials for API authentication."}))
           (update-in request [::friend/auth-config] merge form-config)))))))
GetContented commented 10 years ago

As I've been working on this this further and using it more, I realised it still redirects on unauthorized requests, which isn't what I want. Is there an easy way to make it not do redirects AT ALL? It seems to be kind of baked in at a low level that redirects would be expected.

My aim is to have my single page application (writte in Om, of course) that does requests to the server, and if the user has been timed out, then it should receive an unauthorised response from the server. The SPA will then dutifully drop its state and request the user log back in again, at which point it'll reload its state back to where it was when the user was logged out by the server (timeout).

Any help would be greatly appreciated :) <3

yayitswei commented 10 years ago

How are you using the workflow? Here's what I have in my single-page app that doesn't redirect on unauthorized requests.

(friend/authenticate ... {:allow-anon? true
                          :unathorized-handler (constantly {:status 401})
                          :unauthenticated-handler (constantly {:status 401})
                          :workflows [...]})

Then your workflow can just return nil on auth failure.

GetContented commented 10 years ago

Wow. That looks elegant! :) I was under the impression you had to have a workflow, and none of the existing ones fit, so I wrote my own (above). I'm using it in a ring middleware that I'm calling "auth":

(defn auth
  [handler]
  (friend/authenticate
   handler
   {:credential-fn (partial creds/bcrypt-credential-fn users)
    :workflows [(api-workflow)]
    :login-uri "/spicyapp/login"
    :redirect-on-auth? false
    :login-failure-handler (fn [req] {:body "invalid username or password" :status 401})}))

What does :allow-anon? do? I'll have a look in the source... nowhere in the doc does it say you can use :unauthorized-handler or :unauthenticated-handler ! :) haha... I only noticed them from reading the source. It'd be nice if they were in the doc.

Oh I should probably mention that I have wrap-edn-params in my middleware stack:

; using ring.middleware.defaults/wrap-defaults
; ring.middleware.edn/wrap-edn-params
; and compojure routes
(def app
  (-> (routes home-routes app-routes)
      (auth)
      (wrap-edn-params)
      (wrap-defaults (assoc-in site-defaults [:security :anti-forgery] false))))
vseguip commented 10 years ago

I'm using the interactive form workflow with a similar configuration to Julian for my Single Page. The thing that's missing for me is that after a successful authorization the server will send a 303 code no matter what and I would rather prefer to send a 200 with user profile as a response. I've tried a wrapper around the default interactive-workflow without much success, the response seems to be generated in another point. Anybody knows how to modify the response to a login request?

GetContented commented 10 years ago

Yeah I had 303's for a couple of minutes there when I was devving this. My app has a modified response pretty much exactly what you described... it simply sends back the user profile as you want. This is up to your routes... the response from the route that accepts the login data POST request is the one that should be sending back, assuming success. If you're using liberator, it's the :handle-created key/val pair.

vseguip commented 10 years ago

Hello Julian, Since the route for login seems to be setup by friend automatically how do you modify it? Can you post an example so I can grep how to do it? I'm a total noob to Clojure web development and ritz so I'm still struggling around it's concepts (btw, I'm not using liberator, will take a look at it)...

GetContented commented 10 years ago

Take a look above at my code... you can specify it with the key :login-uri in the map passed to the authenticate fn. By the way I recommend reading the web development with closure book and following along if you haven't. It puts a lot of things into perpective that otherwise can take a while to understand.

vseguip commented 10 years ago

Thanks Julian for your pointers. For other people trying the same, I managed to avoid the redirect using the standard interactive form with the following configuration (so I do not in fact use Julian's code):

(defroutes routes
  (GET "/" []    (do (println "hi") (index "page")))
  (POST "/login" request (generate-response (get-profile request))))

(def app
  (-> routes
      wrap-edn-params))

(def secured-app
  (handler/site  (wrap-edn-params
   (friend/authenticate app {:allow-anon? true
                             :redirect-on-auth? false
                             :login-failure-handler (fn [e] (generate-response {:error "Wrong credentials"} 401))
                             :unauthenticated-handler #(auth-failure-handler % "unauthenticated");; (fn [e]
                             :login-uri "/login"
                             :default-landing-uri "/"
                             :unauthorized-handler #(auth-failure-handler % "unauthorized");; (fn [e]
                             :credential-fn (fn[c] 
                                              (println "Credential: " c)
                                              (creds/bcrypt-credential-fn users c))
                             :workflows [ (workflows/interactive-form :redirect-on-auth? false) ]
                             })
    )))

auth-failure-handler is just a helper functions that give the client more information in EDN format about what went wrong (returns a HTTP 401) in my problem domain.

generate-reponse wraps the answer in EDN.

GetContented commented 10 years ago

What on earth does allow-anon do? I couldn't really tell so far from people's comments, dox, or the codebase... :(

cemerick commented 10 years ago

@JulianLeviston allow-anon? true will allow unauthenticated users to access resources wrapped by the authenticate middleware; this allows you to wrap an entire app with the middleware (including e.g. a login page/resource that anonymous users have to be able to hit) and control access strictly with authorization assertions.

Redirect-less API logins should be reasonably straightforward to set up using the interactive-form workflow as others have suggested. If you're looking to modify the ring response sent by it (or any other friend workflow), you'll technically have to create your own workflow, though that's not as onerous as it might sound: remember, workflows are just ring handlers that can optionally return authentication maps, so your own workflow will just be another bit of ring middleware that happens to be aware of that authentication data.

GetContented commented 10 years ago

@cemerick Thanks for your response... Perhaps your description of "allow-anon?" should go in the docs somewhere, along with a note about how to set up a non-redirecting workflow. I still find your description a bit confusing. I thought wrapping the entire app with the middleware and controlling access with auth assertions (friend/authorize wrapping each individual route?) was the entire point of friend in the first place? Odd... I'd love to know more about the differences. I think perhaps I need to take another look into the source.

As you can see, I already rolled my own workflow (which I pasted above). It wasn't hard once I read and understood the friend source code. It's not exactly easy to get set up, though. I'm probably naïve, so my apologies for being a rude noob! I appreciate your project very very much. Simple for an average clojurian maybe... but I've only been one professionally for the last two weeks... :)