diff --git a/examples/buddy-auth/.gitignore b/examples/buddy-auth/.gitignore new file mode 100644 index 00000000..c53038ec --- /dev/null +++ b/examples/buddy-auth/.gitignore @@ -0,0 +1,11 @@ +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.hgignore +.hg/ diff --git a/examples/buddy-auth/README.md b/examples/buddy-auth/README.md new file mode 100644 index 00000000..51b588ed --- /dev/null +++ b/examples/buddy-auth/README.md @@ -0,0 +1,16 @@ +# Buddy auth example + +A Sample project with Buddy Authentication. + +## Usage + +```clj +> lein repl +(start) +``` + +See annotated example in [server.clj](src/example/server.clj) file. + +## License + +Copyright © 2017-2018 Metosin Oy diff --git a/examples/buddy-auth/project.clj b/examples/buddy-auth/project.clj new file mode 100644 index 00000000..3fdc0bd0 --- /dev/null +++ b/examples/buddy-auth/project.clj @@ -0,0 +1,7 @@ +(defproject ring-example "0.1.0-SNAPSHOT" + :description "Reitit Buddy Auth App" + :dependencies [[org.clojure/clojure "1.10.0"] + [ring/ring-jetty-adapter "1.7.1"] + [metosin/reitit "0.5.3"] + [buddy "2.0.0"]] + :repl-options {:init-ns example.server}) diff --git a/examples/buddy-auth/src/example/server.clj b/examples/buddy-auth/src/example/server.clj new file mode 100644 index 00000000..6f648d68 --- /dev/null +++ b/examples/buddy-auth/src/example/server.clj @@ -0,0 +1,244 @@ +(ns example.server + "This example demonstrates how to use Buddy authentication with Reitit + to implement simple authentication and authorization flows. + + HTTP Basic authentication is used to authenticate with username and + password. HTTP Basic authentication middleware checks credentials + against a 'database' and if credentials are OK, a signed jwt-token + is created and returned. The token can be used to call endpoints + that require token authentication. Token payload contains users + roles that can be used for authorization. + + NOTE: This example is not production-ready." + (:require [buddy.auth :as buddy-auth] + [buddy.auth.backends :as buddy-auth-backends] + [buddy.auth.backends.httpbasic :as buddy-auth-backends-httpbasic] + [buddy.auth.middleware :as buddy-auth-middleware] + [buddy.hashers :as buddy-hashers] + [buddy.sign.jwt :as jwt] + [muuntaja.core :as m] + [reitit.ring :as ring] + [reitit.ring.coercion :as coercion] + [reitit.ring.middleware.muuntaja :as muuntaja] + [ring.adapter.jetty :as jetty] + [ring.middleware.params :as params])) + +(def db + "We use a simple map as a db here but in real-world you would + interface with a real data storage in `basic-auth` function." + {"user1" + {:id 1 + :password (buddy-hashers/encrypt "kissa13") + :roles ["admin" "user"]} + "user2" + {:id 2 + :password (buddy-hashers/encrypt "koira12") + :roles ["user"]}}) + +(def private-key + "Used for signing and verifying JWT-tokens In real world you'd read + this from an environment variable or some other configuration that's + not included in the source code." + "kana15") + +(defn create-token + "Creates a signed jwt-token with user data as payload. + `valid-seconds` sets the expiration span." + [user & {:keys [valid-seconds] :or {valid-seconds 7200}}] ;; 2 hours + (let [payload (-> user + (select-keys [:id :roles]) + (assoc :exp (.plusSeconds + (java.time.Instant/now) valid-seconds)))] + (jwt/sign payload private-key {:alg :hs512}))) + +(def token-backend + "Backend for verifying JWT-tokens." + (buddy-auth-backends/jws {:secret private-key :options {:alg :hs512}})) + +(defn basic-auth + "Authentication function called from basic-auth middleware for each + request. The result of this function will be added to the request + under key :identity. + + NOTE: Use HTTP Basic authentication always with HTTPS in real setups." + [db request {:keys [username password]}] + (let [user (get db username)] + (if (and user (buddy-hashers/check password (:password user))) + (-> user + (dissoc :password) + (assoc :token (create-token user))) + false))) + +(defn create-basic-auth-backend + "Creates basic-auth backend to be used by basic-auth-middleware." + [db] + (buddy-auth-backends-httpbasic/http-basic-backend + {:authfn (partial basic-auth db)})) + +(defn create-basic-auth-middleware + "Creates a middleware that authenticates requests using http-basic + authentication." + [db] + (let [backend (create-basic-auth-backend db)] + (fn [handler] + (buddy-auth-middleware/wrap-authentication handler backend)))) + +(defn token-auth-middleware + "Middleware used on routes requiring token authentication." + [handler] + (buddy-auth-middleware/wrap-authentication handler token-backend)) + +(defn admin-middleware + "Middleware used on routes requiring :admin role." + [handler] + (fn [request] + (if (-> request :identity :roles set (contains? "admin")) + (handler request) + {:status 403 :body {:error "Admin role required"}}))) + +(defn auth-middleware + "Middleware used in routes that require authentication. If request is + not authenticated a 401 unauthorized response will be + returned. Buddy checks if request key :identity is set to truthy + value by any previous middleware." + [handler] + (fn [request] + (if (buddy-auth/authenticated? request) + (handler request) + {:status 401 :body {:error "Unauthorized"}}))) + +(def routes + [["/no-auth" + ["" + {:get (fn [_] {:status 200 :body {:message "No auth succeeded!"}})}]] + + ["/basic-auth" + ["" + {:middleware [(create-basic-auth-middleware db) auth-middleware] + :get + (fn [req] + {:status 200 + :body + {:message "Basic auth succeeded!" + :user (-> req :identity)}})}]] + + ["/token-auth" + ["" + {:middleware [token-auth-middleware auth-middleware] + :get (fn [_] {:status 200 :body {:message "Token auth succeeded!"}})}]] + + ["/token-auth-with-admin-role" + ["" + {:middleware [token-auth-middleware + auth-middleware + admin-middleware] + :get (fn [_] {:status 200 :body {:message "Token auth with admin role succeeded!"}})}]]]) + +(def app + (ring/ring-handler + (ring/router + routes + {:data + {:muuntaja m/instance + :middleware ; applied to all routes + [params/wrap-params + muuntaja/format-middleware + coercion/coerce-exceptions-middleware + coercion/coerce-request-middleware + coercion/coerce-response-middleware]}}) + (ring/create-default-handler))) + +(defn start [] + (jetty/run-jetty #'app {:port 3000, :join? false}) + (println "server running in port 3000")) + +(comment + ;; Start server to try with real HTTP clients. + (start) + + ;; ...or just execute following sexps in the REPL. :) + + (def headers {"accept" "application/edn"}) + (def read-body (comp read-string slurp :body)) + + (-> {:headers headers :request-method :get :uri "/no-auth"} + app + read-body) + ;; => {:message "No auth succeeded!"} + + (-> {:headers headers :request-method :get :uri "/basic-auth"} + app + read-body) + ;; => {:error "Unauthorized"} + + (-> {:headers headers :request-method :get :uri "/token-auth"} + app + read-body) + ;; => {:error "Unauthorized"} + + (import java.util.Base64) + + (defn ->base64 + "Encodes a string as base64." + [s] + (.encodeToString (Base64/getEncoder) (.getBytes s))) + + (defn basic-auth-headers [user pass] + (merge headers {:authorization (str "Basic " (->base64 (str user ":" pass)))})) + + (def bad-creds (basic-auth-headers "juum" "joo")) + (-> {:headers bad-creds :request-method :get :uri "/basic-auth"} + app + read-body) + ;; => {:error "Unauthorized"} + + (def admin-creds (basic-auth-headers "user1" "kissa13")) + + (-> {:headers admin-creds :request-method :get :uri "/basic-auth"} + app + read-body) + ;; {:message "Basic auth succeeded!", + ;; :user + ;; {:id 1, + ;; :roles [:admin :user], + ;; :token + ;; "eyJhbGciOiJIUzUxMiJ9.eyJp....." + + (def admin-token + (-> {:headers admin-creds :request-method :get :uri "/basic-auth"} + app + read-body + :user + :token)) + + (def user-creds (basic-auth-headers "user2" "koira12")) + + (def user-token + (-> {:headers user-creds :request-method :get :uri "/basic-auth"} + app + read-body + :user + :token)) + + (defn token-auth-headers [token] + (merge headers {:authorization (str "Token " token)})) + + (def user-token-headers (token-auth-headers user-token)) + + (-> {:headers user-token-headers :request-method :get :uri "/token-auth"} + app + read-body) + ;; => {:message "Token auth succeeded!"} + + (-> {:headers user-token-headers :request-method :get :uri "/token-auth-with-admin-role"} + app + read-body) + ;; => {:error "Admin role required"} + + (def admin-token-headers (token-auth-headers admin-token)) + + (-> {:headers admin-token-headers :request-method :get :uri "/token-auth-with-admin-role"} + app + read-body) + ;; => {:message "Token auth with admin role succeeded!"} + )