From ef3dd5ede1215507f06a52a5aef9e07181c3a211 Mon Sep 17 00:00:00 2001 From: Michiel de Mare Date: Tue, 10 Sep 2024 14:32:55 +0200 Subject: [PATCH] Added authentication plus tests --- .gitignore | 1 + deps.edn | 9 +- .../validator/service/authentication.clj | 115 ++++++++++++++++++ src/nl/surf/eduhub/validator/service/main.clj | 27 +++- .../validator/service/authentication_test.clj | 97 +++++++++++++++ 5 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 src/nl/surf/eduhub/validator/service/authentication.clj create mode 100644 test/nl/surf/eduhub/validator/service/authentication_test.clj diff --git a/.gitignore b/.gitignore index c17218c..e7db612 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ target/ .lsp/ .envrc opentelemetry-javaagent.jar +.nrepl-port diff --git a/deps.edn b/deps.edn index 69798f1..ac3dc92 100644 --- a/deps.edn +++ b/deps.edn @@ -1,8 +1,8 @@ -{:paths ["src" "resources" "classes"] - :deps {cheshire {:mvn/version "5.13.0"} - ch.qos.logback/logback-classic {:mvn/version "1.5.8"} - ch.qos.logback.contrib/logback-jackson {:mvn/version "0.1.5"} +{:paths ["src" "test" "resources" "classes"] + :deps {ch.qos.logback.contrib/logback-jackson {:mvn/version "0.1.5"} ch.qos.logback.contrib/logback-json-classic {:mvn/version "0.1.5"} + ch.qos.logback/logback-classic {:mvn/version "1.5.8"} + cheshire/cheshire {:mvn/version "5.13.0"} com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.17.2"} compojure/compojure {:mvn/version "1.7.1"} nl.jomco/clj-http-status-codes {:mvn/version "0.1"} @@ -10,6 +10,7 @@ org.babashka/http-client {:mvn/version "0.4.19"} org.babashka/json {:mvn/version "0.1.6"} org.clojure/clojure {:mvn/version "1.12.0"} + org.clojure/core.memoize {:mvn/version "1.1.266"} org.clojure/tools.logging {:mvn/version "1.3.0"} ring/ring-codec {:mvn/version "1.2.0"} ring/ring-core {:mvn/version "1.12.2"} diff --git a/src/nl/surf/eduhub/validator/service/authentication.clj b/src/nl/surf/eduhub/validator/service/authentication.clj new file mode 100644 index 0000000..ea79f5a --- /dev/null +++ b/src/nl/surf/eduhub/validator/service/authentication.clj @@ -0,0 +1,115 @@ +;; This file is part of eduhub-rio-mapper +;; +;; Copyright (C) 2022 SURFnet B.V. +;; +;; This program is free software: you can redistribute it and/or +;; modify it under the terms of the GNU Affero General Public License +;; as published by the Free Software Foundation, either version 3 of +;; the License, or (at your option) any later version. +;; +;; This program is distributed in the hope that it will be useful, but +;; WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +;; Affero General Public License for more details. +;; +;; You should have received a copy of the GNU Affero General Public +;; License along with this program. If not, see +;; . + +(ns nl.surf.eduhub.validator.service.authentication + "Authenticate incoming HTTP API requests using SURFconext. + + This uses the OAuth2 Client Credentials flow for authentication. From + the perspective of the RIO Mapper HTTP API (a Resource Server in + OAuth2 / OpenID Connect terminology), this means that: + + 1. Calls to the API should contain an Authorization header with a + Bearer token. + + 2. The token is verified using the Token Introspection endpoint, + provided by SURFconext. + + The Token Introspection endpoint is described in RFC 7662. + + The SURFconext service has extensive documentation. For our use + case you can start here: + https://wiki.surfnet.nl/display/surfconextdev/Documentation+for+Service+Providers + + The flow we use is documented at https://wiki.surfnet.nl/pages/viewpage.action?pageId=23794471 " + (:require [babashka.http-client :as http] + [cheshire.core :as json] + [clojure.core.memoize :as memo] + [clojure.tools.logging :as log] + [nl.jomco.http-status-codes :as http-status] + [ring.util.response :as response])) + +(defn bearer-token + [{{:strs [authorization]} :headers}] + (some->> authorization + (re-matches #"Bearer ([^\s]+)") + second)) + +;; Take a authentication uri, basic auth credentials and a token extracted from the bearer token +;; and make a call to the authentication endpoint. +;; Returns the client id if authentication is successful, otherwise nil. +(defn authenticate-token [uri token auth] + {:pre [(string? uri) + (string? token) + (map? auth)]} + (try + (let [opts {:basic-auth auth :form-params {:token token} :throw false} + {:keys [status] :as response} (http/post uri opts)] + (when (= http-status/ok status) + ;; See RFC 7662, section 2.2 + (let [json (json/parse-string (:body response) true) + active (:active json)] + (when-not (boolean? active) + (throw (ex-info "Invalid response for token introspection, active is not boolean." + {:body json}))) + (when active + (:client_id json))))) + (catch Exception ex + (log/error ex "Error in token-authenticator") + nil))) + +(defn make-token-authenticator + "Make a token authenticator that uses the OIDC `introspection-endpoint`. + + Returns a authenticator that tests the token using the given + `instrospection-endpoint` and returns the token's client id if the + token is valid. + Returns nil unless the authentication service returns a response with a 200 code." + [introspection-endpoint auth] + {:pre [introspection-endpoint auth]} + (fn [token] + (authenticate-token introspection-endpoint + token + {:basic-auth auth}))) + +(defn handle-request-with-token [request request-handler client-id] + (if (nil? client-id) + (response/status http-status/forbidden) + ;; set client-id on request and response (for tracing) + (-> request + (assoc :client-id client-id) + request-handler + (assoc :client-id client-id)))) + +(defn wrap-authentication + "Authenticate calls to ring handler `f` using `token-authenticator`. + + The token authenticator will be called with the Bearer token from + the incoming http request. If the authenticator returns a client-id, + the client-id gets added to the request as `:client-id` and the + request is handled by `f`. If the authenticator returns `nil` or + if the http status of the authenticator call is not successful, the + request is forbidden. + + If no bearer token is provided, the request is executed without a client-id." + ; auth looks like {:user client-id :pass client-secret} + [f introspection-endpoint auth] + (let [authenticator (memo/ttl (make-token-authenticator introspection-endpoint auth) :ttl/threshold 60000)] ; 1 minute + (fn [request] + (if-let [token (bearer-token request)] + (handle-request-with-token request f (authenticator token)) + (f request))))) diff --git a/src/nl/surf/eduhub/validator/service/main.clj b/src/nl/surf/eduhub/validator/service/main.clj index bb79209..04bd583 100644 --- a/src/nl/surf/eduhub/validator/service/main.clj +++ b/src/nl/surf/eduhub/validator/service/main.clj @@ -7,6 +7,7 @@ [environ.core :refer [env]] [nl.jomco.envopts :as envopts] [nl.jomco.http-status-codes :as http-status] + [nl.surf.eduhub.validator.service.authentication :as auth] [ring.adapter.jetty :refer [run-jetty]] [ring.middleware.defaults :refer [wrap-defaults api-defaults]] [ring.middleware.json :refer [wrap-json-response]])) @@ -44,17 +45,31 @@ (def opt-specs {:gateway-url ["URL of gateway" :str :in [:gateway-url]] :gateway-basic-auth-user ["Basic auth username of gateway" :str :in [:gateway-basic-auth :user]] - :gateway-basic-auth-pass ["Basic auth password of gateway" :str :in [:gateway-basic-auth :pass]]}) + :gateway-basic-auth-pass ["Basic auth password of gateway" :str :in [:gateway-basic-auth :pass]] + :introspection-client-id ["Basic auth username of introspection" :str :in [:introspection-basic-auth :user]] + :introspection-secret ["Basic auth password of introspection" :str :in [:introspection-basic-auth :pass]] + :introspection-endpoint ["Introspection endpoint url" :str :in [:introspection-endpoint-url]]}) + +(defn start-server [routes] + (let [server (run-jetty routes {:port 3002 :join? false}) + handler ^Runnable (fn [] (.stop server))] + ;; Add a shutdown hook to stop Jetty on JVM exit (Ctrl+C) + (.addShutdownHook (Runtime/getRuntime) + (Thread. handler)) + server)) (defn -main [& _] - (let [[config errs] (envopts/opts env opt-specs)] + (let [[config errs] (envopts/opts env opt-specs) + introspection-endpoint (:introspection-endpoint-url config) + introspection-auth (:introspection-basic-auth config)] (when errs (.println *err* "Error in environment configuration") (.println *err* (envopts/errs-description errs)) (.println *err* "Available environment vars:") (.println *err* (envopts/specs-description opt-specs)) (System/exit 1)) - (run-jetty (-> app-routes - (wrap-validator config) - (wrap-defaults api-defaults) - wrap-json-response) {:port 3002}))) + (start-server (-> app-routes + (wrap-validator config) + (auth/wrap-authentication introspection-endpoint introspection-auth) + (wrap-defaults api-defaults) + wrap-json-response)))) diff --git a/test/nl/surf/eduhub/validator/service/authentication_test.clj b/test/nl/surf/eduhub/validator/service/authentication_test.clj new file mode 100644 index 0000000..2d717f6 --- /dev/null +++ b/test/nl/surf/eduhub/validator/service/authentication_test.clj @@ -0,0 +1,97 @@ +;; This file is part of eduhub-rio-mapper +;; +;; Copyright (C) 2022 SURFnet B.V. +;; +;; This program is free software: you can redistribute it and/or +;; modify it under the terms of the GNU Affero General Public License +;; as published by the Free Software Foundation, either version 3 of +;; the License, or (at your option) any later version. +;; +;; This program is distributed in the hope that it will be useful, but +;; WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +;; Affero General Public License for more details. +;; +;; You should have received a copy of the GNU Affero General Public +;; License along with this program. If not, see +;; . + +(ns nl.surf.eduhub.validator.service.authentication-test + (:require [babashka.http-client :as http] + [cheshire.core :as json] + [clojure.test :refer [deftest is]] + [nl.jomco.http-status-codes :as http-status] + [nl.surf.eduhub.validator.service.authentication :as authentication])) + +(deftest test-bearer-token + (is (nil? + (authentication/bearer-token {:headers {"authorization" "Bearerfoobar"}}))) + (is (= "foobar" + (authentication/bearer-token {:headers {"authorization" "Bearer foobar"}})))) + +(def valid-token + (str "eyJraWQiOiJrZXlfMjAyMl8xMF8wNF8wMF8wMF8wMF8wMDEiLCJ0eXAiOiJK" + "V1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJwbGF5Z3JvdW5kX2NsaWVudCI" + "sInN1YiI6InBsYXlncm91bmRfY2xpZW50IiwibmJmIjoxNjY0ODkyNTg0LCJ" + "pc3MiOiJodHRwczpcL1wvY29ubmVjdC50ZXN0LnN1cmZjb25leHQubmwiLCJ" + "leHAiOjE2NjU3NTY1ODQsImlhdCI6MTY2NDg5MjU4NCwianRpIjoiNTlkNGY" + "yZDQtZmRhOC00MTBjLWE2MzItY2QzMzllMTliNTQ2In0.nkQqZK02SamkNI2" + "ICDrE1LxN6kBBDOwQd5zU9BsPxNIfOwP1qnCwNQELo5xX0R2cJJJqCgmq8nw" + "BjZ4xNba4lTS8dii4Fmy-8u7fN427mx-_G-GoCGQSKQD6OdVKjDsRMJX4rHN" + "DSg5HhtDz5or-2Xp_H0Vi0mWMOBgQGjfbjLjJJZ1T0rlaZbq-_ZAatb2dFcr" + "WliqbFrous_fSPo4jrbPVHYunF-wZZoLZFlOaCyJM24A_3Mrv4JPw78WRnyu" + "ZG0H7aS2v_KLe5Xh2lUkSa0lkO_xP2uhQQ_69bnmF0RQiKe9vVDi7mhi0aGE" + "do2f-iJ8JQj4EwPzZkSvdJt569w")) + + +(def count-calls (atom 0)) + +;; Mock out the introspection endpoint. Pretend token is active if +;; it's equal to `valid-token`, invalid otherwise. +(defn mock-introspection + [{:keys [form-params]}] + (swap! count-calls inc) + (if (= valid-token (:token form-params)) + {:status http-status/ok + :body (json/encode {:active true + :client_id "institution_client_id"})} + {:status http-status/ok + :body {:active false}})) + +(deftest token-validator + ;; This binds the *dynamic* http client in clj-http.client + (reset! count-calls 0) + (with-redefs [http/request mock-introspection] + (let [introspection-endpoint "https://example.com" + basic-auth {:user "foo" :pass "bar"} + handler (-> (fn [req] + (let [body {:client (:client-id req)}] + {:status http-status/ok + :body body})) + (authentication/wrap-authentication introspection-endpoint basic-auth))] + (is (= {:status http-status/ok + :body {:client "institution_client_id"} + :client-id "institution_client_id"} + (handler {:headers {"authorization" (str "Bearer " valid-token)}})) + "Ok when valid token provided") + + (is (= {:status 200, :body {:client nil}} + (handler {})) + "Authorized without client when no token provided") + + (is (= http-status/forbidden + (:status (handler {:headers {"authorization" (str "Bearer invalid-token")}}))) + "Forbidden with invalid token") + + (is (= 2 @count-calls) + "Correct number of calls to introspection-endpoint") + + (reset! count-calls 0) + (is (= {:status http-status/ok + :body {:client "institution_client_id"} + :client-id "institution_client_id"} + (handler {:headers {"authorization" (str "Bearer " valid-token)}})) + "CACHED: Ok when valid token provided") + + (is (= 0 @count-calls) + "No more calls to introspection-endpoint"))))