可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
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?
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.
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.