9
votes

I am trying to integrate Friend authentication and authorisation into a Clojure/Compojure single-page web application.

I have a login form backed by an Angular controller, and this controller uses AJAX to authenticate username and password against the web application and obtain an authenticated user record. Because of this, I do not want the default behaviour provided by the Friend form-based login - I basically want to rely on HTTP status codes and I do not want any of the Friend page-redirects.

For example, making an unauthenticated request should simply return a 401 status code, and should not redirect to "/login". I have this part working by specifying a custom ":unauthenticated-handler" when configuring Friend (code included below).

On a successful login I simply want a 200 status code, and not a redirect to the originally requested page. This is what I can't get working.

I wrote a custom Friend authentication workflow based on the various examples (my Clojure skills are beginner level right now):

(defn my-auth
  [& {:keys [credential-fn]}]
    (routes
      (GET "/logout" req
        (friend/logout* {:status 200}))
      (POST "/login" {{:keys [username password]} :params}
        (if-let [user-record (-> username credential-fn)]
          (if
            (and
              [user-record password]
              (creds/bcrypt-verify password (:password user-record)))
            (let [user-record (dissoc user-record :password)]
              (workflows/make-auth user-record {:cemerick.friend/workflow :my-auth :cemerick.friend/redirect-on-auth? true}))
            {:status 401})
          {:status 401}))))

Here is my handler with middlewares declared:

(def app
  (-> 
    (handler/site
      (friend/authenticate app-routes
        {:credential-fn (partial creds/bcrypt-credential-fn my-credential-fn)
         :unauthenticated-handler unauthenticated
         :workflows [(my-auth :credential-fn my-credential-fn)]}))
    (session/wrap-session)
    (params/wrap-keyword-params)
    (json/wrap-json-body)
    (json/wrap-json-response {:pretty true})))

And the additional handler function referenced by the above:

(defn unauthenticated [v]
  {:status 401 :body "Unauthenticated"})

Finally an additional routes fragment to test the authentication:

(GET "/auth" req
  (friend/authenticated (str "You have successfully authenticated as "
    (friend/current-authentication))))

This mostly works, and almost does everything I need.

Because "redirect-on-auth?" is true in "make-auth", on a successful login a page-redirect is generated - I want to prevent that redirect so I set the value to false. However, this single change results in a 404 and a failed login.

So as well as the Friend authentication map I need somehow to return a 200 status code here, and also I want to return the "user-record" in the response body so the client application can tailor the UI depending on the user roles (I already have JSON requests/responses wrapped and working).

So I think I need the equivalent of this when I invoke the Friend "make-auth" function:

{:status 200 :body user-record}

However it seems like I can have either the authentication map, or the response - but not both together.

Can this be achieved with Friend and if so how?

2
Maybe this is related: github.com/cemerick/friend/issues/83, implying that it's not currently possible and needs a fix to Friend.caprica
yes that seems to be the case.ponzao

2 Answers

1
votes

You need to have :redirect-on-auth? as false and you need to wrap your response in a response map {:status 200 :body (workflows/make-auth...)}. Note that you probably need to serialize your body to a String or something else that can be handled.

1
votes

I've built a working solution to your issue. The key is to make sure you return a status, body, and merge the the authentication credentials into response. So you want to return

{:status 200 :body {:message "Hello World"} 
 :session {:cemerick.friend/identity {...}} ...}

The problem with the solution above is that it merges the session results into the body, instead of into ring response itself. Gist

(defn- form-ajax-response 
  "Creates an Ajax Request.
   Authenticates User by merging authentication
   into response along with status and body allowing
   for ajax response."
  [status body resp auth content-type]
  (let [auth-resp (friend/merge-authentication (dissoc resp :body-params) auth)
        formatter (case content-type 
                    "application/edn" noir.response/edn
                    noir.response/json)]
    (merge auth-resp {:status status} (formatter {:body body}))))

(def not-nil? (complement nil?))

(defn form-or-ajax-login 
  "Friend Workflow for Handling forms or ajax requests posted to 
   '/login'.  When a form is posted, the request will initiate a redirect
   on login When the post is performed via ajax. A response will be sent
   with success and redirect information"
  [& {:keys [login-uri credential-fn login-failure-handler redirect-on-auth?] :as ajax-config
      :or {redirect-on-auth? true}}]
  (fn [{:keys [uri request-method content-type params] :as request}]
    (when (and (= :post request-method)
               (= uri (gets :login-uri ajax-config (::friend/auth-config request))))
      (let [creds {:username (:username params) 
                   :password (:password params)}
            {:keys [username password]} creds
            ajax? (not-nil? (ajax-request-type content-type))
            cred-fn (gets :credential-fn ajax-config (::friend/auth-config request))]
        (when-let [user-record (and username password (cred-fn creds))]
          (let [user-auth (workflow/make-auth user-record {::friend/workflow :form-or-ajax
                                                           ::friend/redirect-on-auth? false})]
            (case ajax?
              true (form-ajax-response 202 {:Tom "Peterman"} request user-auth content-type)
              user-auth)))))))