Compojure routes with different middleware

北战南征 提交于 2019-12-20 08:27:58

问题


I'm currently writing an API in Clojure using Compojure (and Ring and associated middleware).

I'm trying to apply different authentication code depending on the route. Consider the following code:

(defroutes public-routes
  (GET "/public-endpoint" [] ("PUBLIC ENDPOINT")))

(defroutes user-routes
  (GET "/user-endpoint1" [] ("USER ENDPOINT 1"))
  (GET "/user-endpoint2" [] ("USER ENDPOINT 1")))

(defroutes admin-routes
  (GET "/admin-endpoint" [] ("ADMIN ENDPOINT")))

(def app
  (handler/api
    (routes
      public-routes
      (-> user-routes
          (wrap-basic-authentication user-auth?)))))
      (-> admin-routes
          (wrap-basic-authentication admin-auth?)))))

This doesn't work as expected because wrap-basic-authentication indeed wraps routes so it gets tried regardless of the wrapped routes. Specifically, if the requests needs to be routed to admin-routes, user-auth? will still be tried (and fail).

I resorted to use context to root some routes under a common base path but it's quite a constraint (the code below may not work it's simply to illustrate the idea):

(defroutes user-routes
  (GET "-endpoint1" [] ("USER ENDPOINT 1"))
  (GET "-endpoint2" [] ("USER ENDPOINT 1")))

(defroutes admin-routes
  (GET "-endpoint" [] ("ADMIN ENDPOINT")))

(def app
  (handler/api
    (routes
      public-routes
      (context "/user" []
        (-> user-routes
            (wrap-basic-authentication user-auth?)))
      (context "/admin" []
        (-> admin-routes
            (wrap-basic-authentication admin-auth?))))))

I'm wondering if I'm missing something or if there's any way at all to achieve what I want without constraint on my defroutes and without using a common base path (as ideally, there would be none).


回答1:


(defroutes user-routes*
  (GET "-endpoint1" [] ("USER ENDPOINT 1"))
  (GET "-endpoint2" [] ("USER ENDPOINT 1")))

(def user-routes
     (-> #'user-routes*
         (wrap-basic-authentication user-auth?)))

(defroutes admin-routes*
  (GET "-endpoint" [] ("ADMIN ENDPOINT")))


(def admin-routes
     (-> #'admin-routes*
         (wrap-basic-authentication admin-auth?)))

(defroutes main-routes
  (ANY "*" [] admin-routes)
  (ANY "*" [] user-routes)

This will run the incoming request first through admin-routes and then through user routes, applying the correct authentication in both cases. The main idea here is that your authentication function should return nil if the route is not accessible to the caller instead of throwing an error. This way admin-routes will return nil if a) the route actually does not match defined admin-routes or b) the user does not have the required authentication. If admin-routes returns nil, user-routes will be tried by compojure.

Hope this helps.

EDIT: I wrote a post about Compojure some time back, which you might find useful: https://vedang.me/techlog/2012-02-23-composability-and-compojure/




回答2:


I stumbled on this issue, and it seems wrap-routes (compojure 1.3.2) solves elegantly:

(def app
  (handler/api
    (routes
      public-routes
      (-> user-routes
          (wrap-routes wrap-basic-authentication user-auth?)))))
      (-> admin-routes
          (wrap-routes wrap-basic-authentication admin-auth?)))))



回答3:


This is a reasonable question, which I found surprisingly tricky when I ran into it myself.

I think what you want is this:

(defroutes public-routes
  (GET "/public-endpoint" [] ("PUBLIC ENDPOINT")))

(defroutes user-routes
  (GET "/user-endpoint1" _
       (wrap-basic-authentication
        user-auth?
        (fn [req] (ring.util.response/response "USER ENDPOINT 1"))))

  (GET "/user-endpoint2" _
       (wrap-basic-authentication
        user-auth?
        (fn [req] (ring.util.response/response "USER ENDPOINT 1")))))

(defroutes admin-routes
  (GET "/admin-endpoint" _
       (wrap-basic-authentication
        admin-auth? (fn [req] (ring.util.response/response "ADMIN ENDPOINT")))))

(def app
  (handler/api
   (routes
    public-routes
    user-routes
    admin-routes)))

Two things to note: the authentication middleware is inside the routing form and the middleware calls an an anonymous function that is a genuine handler. Why?

  1. As you said, you need to apply authentication middleware after routing, or the request will never get routed to the authentication middleware! In other words, the routing needs to be on a middleware ring outside the authentication ring.

  2. If you use Compojure's routing forms like GET, and you are applying middleware in the body of the form, then the middleware function needs as its argument a genuine ring response handler (that is, a function that takes a request and returns a response), rather than something simpler like a string or a response map.

This is because, by definition, middleware functions like wrap-basic-authentication only take handlers as arguments, not bare strings or response maps or anything else.

So why is it so easy to miss this? The reason is that the Compojure routing operators like (GET [path args & body] ...) try to make things easy for you by being very flexible with what form you are allowed to pass in the body field. You can pass in a true handler function, or just a string, or a response map, or probably something else that hasn't occurred to me. It's all laid out in the render multi-method in the Compojure internals.

This flexibility disguises what the GET form is actually doing, so it's easy to get mixed up when you try to do something a bit different.

In my view, the problem with the leading answer by vedang is not a great idea in most cases. It essentially uses compojure machinery that's meant to answer the question "Does the route match the request?" (if not, return nil) to also answer the question "Does the request pass authentication?" This is problematic because usually you want requests that fail authentication to return proper responses with 401 status codes, as per the HTTP spec. In that answer, consider what would happen to valid user-authenticated requests if you added such an error response for failed admin-authentication to that example: all the valid user-authenticated request would fail and give errors at the admin routing layer.




回答4:


I just found the following unrelated page that addresses the same issue:

http://compojureongae.posterous.com/using-the-app-engine-users-api-from-clojure

I didn't realise it's possible to use that type of syntax (which I have not yet tested):

(defroutes public-routes
  (GET "/public-endpoint" [] ("PUBLIC ENDPOINT")))

(defroutes user-routes
  (GET "/user-endpoint1" [] ("USER ENDPOINT 1"))
  (GET "/user-endpoint2" [] ("USER ENDPOINT 1")))

(defroutes admin-routes
  (GET "/admin-endpoint" [] ("ADMIN ENDPOINT")))

(def app
  (handler/api
    (routes
      public-routes
      (ANY "/user*" []
        (-> user-routes
            (wrap-basic-authentication user-auth?)))
      (ANY "/admin*" []
        (-> admin-routes
            (wrap-basic-authentication admin-auth?))))))



回答5:


Have you considered using Sandbar? It uses role-based authorisation, and lets you specify declaratively which roles are needed to access a particular resource. Check Sandbar's documentation for more information, but it could work something like this (note the reference to a fictitious my-auth-function, that's where you'd put your authentication code):

(def security-policy
     [#"/admin-endpoint.*"          :admin 
      #"/user-endpoint.*"           :user
      #"/public-endpoint.*"         :any])

(defroutes my-routes
  (GET "/public-endpoint" [] ("PUBLIC ENDPOINT"))
  (GET "/user-endpoint1"  [] ("USER ENDPOINT1"))
  (GET "/user-endpoint2"  [] ("USER ENDPOINT2"))
  (GET "/admin-endpoint"  [] ("ADMIN ENDPOINT"))

(def app
  (-> my-routes
      (with-security security-policy my-auth-function)
      wrap-stateful-session
      handler/api))



回答6:


I would shift how you end up handling the authentication in general to split apart the process of authenticating and filtering routes on authentication.

Rather than just having the admin-auth? and user-auth? return booleans or a user name, use it as more of an "access level" key which you can filter on on much more of a per-route level without the need to "reauthenticate" for different routes.

(defn auth [user pass]
  (cond
    (admin-auth? user pass) :admin
    (user-auth? user pass) :user
    true :unauthenticated))

You'll also want to consider an alternate to the existing basic authentication middleware for this path. As it's currently designed, it'll always return a {:status 401} if you don't provide credentials, so you'll need to take this into account and have it continue through instead.

The result of this is put in the :basic-authentication key in the request map, which you can then filter at the level you want.

The main "filtering" cases that come to mind are:

  • At a context level (like what you have in your answer), except you can just filter out requests that don't have the required :basic-authentication key
  • On a per route level, where you return a 401 response after a local check on how it's authenticated. Note that this is the only way you'll get a distinction between 404s and 401s unless you do the context level filtering on individual routes.
  • Different views for a page depending on the authentication level

The biggest thing to remember is that you have to continue feeding back nil for invalid routes unless the url being asked for needs authentication. You need to make sure you're not filtering out more than you want by returning a 401, which will cause ring to stop trying any other routes/handles.



来源:https://stackoverflow.com/questions/10822033/compojure-routes-with-different-middleware

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!