diff --git a/VERSION b/VERSION index 66c4c22..29fa924 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.9 +1.1.0-BETA diff --git a/dev/dev.clj b/dev/dev.clj index e99d0a4..ea99126 100644 --- a/dev/dev.clj +++ b/dev/dev.clj @@ -19,6 +19,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ dev-config)) diff --git a/project.clj b/project.clj index 67b5810..b537ae9 100644 --- a/project.clj +++ b/project.clj @@ -25,6 +25,9 @@ [ch.qos.logback/logback-classic "1.1.7"] [org.jboss.weld.se/weld-se "2.2.7.Final"] [net.unit8.weld/weld-prescan "0.1.0"] + [crypto-random "1.2.0"] + [ring/ring-codec "1.0.1"] + [org.clojure/data.codec "0.1.0"] ;; for Scheduler [org.quartz-scheduler/quartz "2.2.3"] diff --git a/src/clj/job_streamer/control_bus/component/auth.clj b/src/clj/job_streamer/control_bus/component/auth.clj index 9884a3a..22cd4eb 100644 --- a/src/clj/job_streamer/control_bus/component/auth.clj +++ b/src/clj/job_streamer/control_bus/component/auth.clj @@ -8,12 +8,30 @@ [liberator.core :as liberator] [liberator.representation :refer [ring-response]] [clojure.string :as str] + [clojure.data.json :as json] + [clojure.data.codec.base64 :as base64] + [org.httpkit.client :as http] [ring.util.response :refer [response content-type header redirect]] + [ring.util.codec :refer [form-encode]] (job-streamer.control-bus [validation :refer [validate]] - [util :refer [parse-body]]) + [util :refer [parse-body generate-token]]) (job-streamer.control-bus.component [datomic :as d] [token :as token]))) +(defn- find-permissions [datomic user-id app-name] + (->> (d/query datomic + '{:find [?permission] + :in [$ ?app-name ?user-id] + :where [[?member :member/user ?user] + [?member :member/roles ?role] + [?role :role/permissions ?permission] + [?user :user/id ?user-id] + [?app :application/members ?member] + [?app :application/name ?app-name]]} + app-name user-id) + (apply concat) + set)) + (defn- auth-by-password [datomic user-id password app-name] (when (and (not-empty user-id) (not-empty password)) (when-let [user (d/query datomic @@ -30,18 +48,21 @@ [?app :application/members ?member] [?app :application/name ?app-name]]} user-id password app-name)] - (let [permissions (->> (d/query datomic - '{:find [?permission] - :in [$ ?app-name ?user-id] - :where [[?member :member/user ?user] - [?member :member/rolls ?roll] - [?roll :roll/permissions ?permission] - [?user :user/id ?user-id] - [?app :application/members ?member] - [?app :application/name ?app-name]]} - app-name user-id) - (apply concat) - set)] + + (let [permissions (find-permissions datomic user-id app-name)] + (assoc user :permissions permissions))))) + +(defn- find-user [datomic user-id app-name] + (when (not-empty user-id) + (when-let [user (d/query datomic + '{:find [(pull ?s [:*]) .] + :in [$ ?uname ?app-name] + :where [[?s :user/id ?uname] + [?member :member/user ?s] + [?app :application/members ?member] + [?app :application/name ?app-name]]} + user-id app-name)] + (let [permissions (find-permissions datomic user-id app-name)] (assoc user :permissions permissions))))) (defvalidator unique-name-validator @@ -53,16 +74,16 @@ :where [[?u :user/id ?id]]} id))) -(defvalidator exist-roll-validator +(defvalidator exist-role-validator {:default-message-format "%s is used by someone."} - [roll-name datomic] + [role-name datomic] (d/query datomic '[:find ?e . :in $ ?n - :where [?e :roll/name ?n]] - roll-name)) + :where [?e :role/name ?n]] + role-name)) -(defn- signup [datomic user roll-name] +(defn- signup [datomic user role-name] (when-let [user (let [salt (nonce/random-nonce 16) password (some-> (not-empty (:user/password user)) (.getBytes) @@ -78,11 +99,11 @@ :user/salt salt}) (when-let [token (:user/token user)] {:user/token token}))))] - (let [roll-id (d/query datomic + (let [role-id (d/query datomic '[:find ?e . :in $ ?n - :where [?e :roll/name ?n]] - roll-name) + :where [?e :role/name ?n]] + role-name) member-id (d/tempid :db.part/user) app (d/query datomic '[:find (pull ?e [*]) . @@ -92,11 +113,39 @@ (let [result (d/transact datomic [user {:db/id member-id :member/user (select-keys user [:db/id]) - :member/rolls [roll-id]} + :member/roles [role-id]} (update-in (select-keys app [:db/id :application/members]) [:application/members] #(conj % member-id))])] - (log/infof "Signup %s as %s succeeded." (:user/id user) roll-name) + (log/infof "Signup %s as %s succeeded." (:user/id user) role-name) result)))) +(defn fetch-access-token [{:keys [oauth-providers control-bus-url]} provider-id state code] + (when (and (not-empty code) (oauth-providers provider-id)) + (log/info "Fetch access-token with code :" code) + (let [{:keys [domain token-endpoint client-id client-secret]} (oauth-providers provider-id) + url (str domain "/" token-endpoint) + query (-> (merge {:code code + :state state + :grant_type "authorization_code" + :client_id client-id + :redirect_uri (str control-bus-url "/oauth/" provider-id "/cb")} + (when client-secret {:client_secret client-secret})) + form-encode) + auth-header (->> (str client-id ":" client-secret) + .getBytes + base64/encode + String. + (str "Basic ")) + {:keys [body status] :as res} @(http/post (str url "?" query) + {:body query + :headers {"Accept" "application/json" + "Authorization" auth-header + "Content-Type" "application/x-www-form-urlencoded"}})] + (when (= 200 status) + (let [{:keys [access_token]} (json/read-str body :key-fn keyword)] + (when access_token + (log/info "access-token :" access_token) + access_token)))))) + (defn auth-resource [{:keys [datomic token] :as component}] (liberator/resource @@ -115,7 +164,7 @@ (ring-response {:session {:identity (select-keys user [:user/id :permissions])} :body (pr-str {:token (str access-token)})})) (do (log/info "Login attempt failed because of authentification failure.") - (ring-response {:status 401 :body (pr-str {:messages ["Autification failure."]})}))))) + (ring-response {:status 401 :body (pr-str {:messages ["Authentication failure."]})}))))) :handle-no-content (fn [_] (ring-response {:session {}})))) (defn list-resource @@ -143,49 +192,98 @@ [v/min-count 3 :message "Username must be at least 3 characters long."] [v/max-count 20 :message "Username is too long."] [unique-name-validator datomic]] - :roll [[v/required] + :role [[v/required] [v/matches #"^[\w\-]+$"] - [exist-roll-validator datomic]])) + [exist-role-validator datomic]])) :post! (fn [{user :edn}] - (let [roll-name (:roll user) + (let [role-name (:role user) user (select-keys user [:user/id :user/password])] - (signup datomic user roll-name))) + (signup datomic user role-name))) :delete! (fn [_] (d/transact datomic [[:db.fn/retractEntity [:user/id user-id]]])))) +(defn oauth-resource [{:keys [oauth-providers]}] + (liberator/resource + :available-media-types ["application/edn" "application/json"] + :allowed-methods [:get] + :exists? (fn [_] + {:providers (->> oauth-providers + (map (fn [[id provider]] + [id (select-keys provider [:name :class-name])])) + (into {}))}) + :handle-ok (fn [{:keys [providers]}] + providers))) + +(defn redirect-to-auth-provider [{:keys [oauth-providers control-bus-url console-url]} provider-id] + (fn [request] + (if-let [{:keys [domain auth-endpoint client-id client-secret scope]} (oauth-providers provider-id)] + (let [state (generate-token) + session-with-state (assoc (:session request) :state state) + url (str domain "/" auth-endpoint) + query (merge {:client_id client-id + :response_type "code" + :redirect_uri (str control-bus-url "/oauth/" provider-id "/cb") + :state state} + (when client-secret {:client_secret client-secret}) + (when scope {:scope scope}))] + (log/info "Redirect to auth provider :" provider-id) + (-> (str url "?" (form-encode query)) + redirect + (assoc :session session-with-state))) + (do (log/infof "Redirect attempt to auth provider, %s, failed because of no configuration." provider-id) + (-> (redirect (str console-url "/login")) + (assoc :body (pr-str {:messages [(str "No configuration : " provider-id)]}))))))) + +(defn oauth-callback [{:keys [datomic console-url] :as auth} provider-id] + (fn [request] + (let [app-name "default" + {:keys [state code error]} (:params request) + session-state (get-in request [:session :state])] + (if (and (some? code) + (= state session-state)) + (if-let [token (fetch-access-token auth provider-id state code)] + (let [identity (-> (find-user datomic "guest" app-name) + (select-keys [:user/id :permissions]))] + (log/info "Login attempt succeeded :" (:user/id identity)) + (-> (redirect console-url) + (assoc-in [:session :identity] identity))) + (redirect (str console-url "/login"))) + (redirect (str console-url "/login")))))) + (defrecord Auth [datomic] component/Lifecycle (start [component] - ;; Create an initil user and rolls. + ;; Create an initil user and roles. (->> [{:db/id (d/tempid :db.part/user) - :roll/name "admin" - :roll/permissions [:permission/read-job + :role/name "admin" + :role/permissions [:permission/read-job :permission/create-job :permission/update-job :permission/delete-job :permission/execute-job]} {:db/id (d/tempid :db.part/user) - :roll/name "operator" - :roll/permissions [:permission/read-job + :role/name "operator" + :role/permissions [:permission/read-job :permission/execute-job]} {:db/id (d/tempid :db.part/user) - :roll/name "watcher" - :roll/permissions [:permission/read-job]}] + :role/name "watcher" + :role/permissions [:permission/read-job]}] (filter #(nil? (d/query datomic '[:find ?e . :in $ ?n - :where [?e :roll/name ?n]] - (:roll/name %)))) + :where [?e :role/name ?n]] + (:role/name %)))) (d/transact datomic)) (when-not (d/query datomic '[:find ?e . :in $ ?n :where [?e :user/id ?n]] "admin") - (signup datomic {:user/id "admin" :user/password "password123"} "admin")) + (signup datomic {:user/id "admin" :user/password "password123"} "admin") + (signup datomic {:user/id "guest" :user/password "password123"} "operator")) component) diff --git a/src/clj/job_streamer/control_bus/config.clj b/src/clj/job_streamer/control_bus/config.clj index 1742fdf..a323db6 100644 --- a/src/clj/job_streamer/control_bus/config.clj +++ b/src/clj/job_streamer/control_bus/config.clj @@ -1,5 +1,7 @@ (ns job-streamer.control-bus.config (:require [environ.core :refer [env]] + [clojure.java.io :as io] + [clojure.edn :as edn] [job-streamer.control-bus.model :as model])) (def defaults @@ -24,3 +26,8 @@ :auth {:access-control-allow-origin access-control-allow-origin} :datomic {:uri datomic-uri}})) +(def resource-file + (some-> "job-streamer-control-bus/config.edn" + io/resource + slurp + edn/read-string)) diff --git a/src/clj/job_streamer/control_bus/endpoint/api.clj b/src/clj/job_streamer/control_bus/endpoint/api.clj index c1b05e2..3923902 100644 --- a/src/clj/job_streamer/control_bus/endpoint/api.clj +++ b/src/clj/job_streamer/control_bus/endpoint/api.clj @@ -27,6 +27,12 @@ (ANY "/users" [] (auth/list-resource auth)) (ANY "/user" [] (auth/entry-resource auth nil)) (ANY ["/user/:user-id" :user-id #".*"] [user-id] (auth/entry-resource auth user-id)) + (ANY "/oauth" [] + (auth/oauth-resource auth)) + (ANY "/oauth/:provider-id" [provider-id] + (auth/redirect-to-auth-provider auth provider-id)) + (ANY "/oauth/:provider-id/cb" [provider-id] + (auth/oauth-callback auth provider-id)) ;; Job (ANY "/:app-name/jobs" [app-name] diff --git a/src/clj/job_streamer/control_bus/main.clj b/src/clj/job_streamer/control_bus/main.clj index 0c2218f..0fcd32e 100644 --- a/src/clj/job_streamer/control_bus/main.clj +++ b/src/clj/job_streamer/control_bus/main.clj @@ -13,6 +13,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ prod-config)) diff --git a/src/clj/job_streamer/control_bus/model.clj b/src/clj/job_streamer/control_bus/model.clj index 7a77b14..c24bbb5 100644 --- a/src/clj/job_streamer/control_bus/model.clj +++ b/src/clj/job_streamer/control_bus/model.clj @@ -85,14 +85,14 @@ (schema member (fields [user :ref] - [rolls :ref :many])) + [roles :ref :many])) (schema user (fields [id :string :indexed :unique-value] [password :string] [salt :bytes] [token :string])) - (schema roll + (schema role (fields [name :string :indexed :unique-value] [permissions :keyword :many]))]) diff --git a/src/clj/job_streamer/control_bus/system.clj b/src/clj/job_streamer/control_bus/system.clj index 0000a49..e558dc9 100644 --- a/src/clj/job_streamer/control_bus/system.clj +++ b/src/clj/job_streamer/control_bus/system.clj @@ -57,7 +57,7 @@ (header "Access-Control-Allow-Origin" access-control-allow-origin) (header "Access-Control-Allow-Credentials" "true")))))) -(def access-rules [{:pattern #"^/(?!auth|user|healthcheck|version).*$" +(def access-rules [{:pattern #"^/(?!auth|oauth|user|healthcheck|version).*$" :handler authenticated?}]) (defn token-base [token-provider] diff --git a/src/clj/job_streamer/control_bus/util.clj b/src/clj/job_streamer/control_bus/util.clj index 7156c37..44dd526 100644 --- a/src/clj/job_streamer/control_bus/util.clj +++ b/src/clj/job_streamer/control_bus/util.clj @@ -4,7 +4,9 @@ [clojure.java.io :as io] [datomic.api :as d] [ring.util.request :refer [content-type]] - [clojure.data.json :as json]) + [clojure.data.json :as json] + [clojure.string :as string] + [crypto.random :as random]) (:import [org.jsoup Jsoup])) (defn to-int [n default-value] @@ -136,3 +138,8 @@ (catch Exception e (log/error e "fail to parse edn.") {:message (format "IOException: %s" (.getMessage e))})))) + +(defn generate-token + "Generates random string for anti-forgery-token." + [] + (string/replace (random/base64 60) #"[\+=/]" "-")) diff --git a/test/clj/job_streamer/control_bus/component/apps_test.clj b/test/clj/job_streamer/control_bus/component/apps_test.clj index 4858515..9de0440 100644 --- a/test/clj/job_streamer/control_bus/component/apps_test.clj +++ b/test/clj/job_streamer/control_bus/component/apps_test.clj @@ -19,6 +19,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ test-config)) diff --git a/test/clj/job_streamer/control_bus/component/auth_test.clj b/test/clj/job_streamer/control_bus/component/auth_test.clj index 0a4953e..4fe46a6 100644 --- a/test/clj/job_streamer/control_bus/component/auth_test.clj +++ b/test/clj/job_streamer/control_bus/component/auth_test.clj @@ -21,6 +21,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ test-config)) @@ -70,12 +71,12 @@ {:keys [status body session headers] :as res} (handler request)] (are [x y] (= x y) 401 status - {:messages ["Autification failure."]} (-> body edn/read-string) + {:messages ["Authentication failure."]} (-> body edn/read-string) nil (:identity session)))) (testing "login as created user" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "addeduser" :user/password "password123" :roll "watcher"})} + :body (pr-str {:user/id "addeduser" :user/password "password123" :role "watcher"})} {:keys [status body]} (let [handler (auth/entry-resource (:auth system) nil)] (handler request))] (is (= 201 status))) (let [request {:request-method :post @@ -105,7 +106,7 @@ {:keys [status body]} (handler request)] (are [x y] (= x y) 200 status - 1 (-> body edn/read-string count) + 2 (-> body edn/read-string count) "admin" (-> body edn/read-string first :user/id)))))) (deftest entry-resource @@ -115,7 +116,7 @@ (testing "lacking id" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/password "password123" :roll "watcher"})} + :body (pr-str {:user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status @@ -123,23 +124,23 @@ (testing "lacking password" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "test" :roll "watcher"})} + :body (pr-str {:user/id "test" :role "watcher"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status ["password must be present" "token must be present"] (-> body edn/read-string :messages)))) - (testing "lacking roll" + (testing "lacking role" (let [request {:request-method :post :content-type "application/edn" :body (pr-str {:user/id "test" :user/password "password123"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status - ["roll must be present"] (-> body edn/read-string :messages)))) + ["role must be present"] (-> body edn/read-string :messages)))) (testing "id does not satisfy min length" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "te" :user/password "password123" :roll "watcher"})} + :body (pr-str {:user/id "te" :user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status @@ -147,7 +148,7 @@ (testing "id does not satisfy max length" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "test12345678901234567" :user/password "password123" :roll "watcher"})} + :body (pr-str {:user/id "test12345678901234567" :user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status @@ -155,7 +156,7 @@ (testing "password does not satisfy min length" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "test" :user/password "passwor" :roll "watcher"})} + :body (pr-str {:user/id "test" :user/password "passwor" :role "watcher"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status @@ -163,23 +164,23 @@ (testing "id conflicts" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "admin" :user/password "password123" :roll "watcher"})} + :body (pr-str {:user/id "admin" :user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status ["id is used by someone."] (-> body edn/read-string :messages)))) - (testing "invalid roll" + (testing "invalid role" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "test" :user/password "password123" :roll "nothing"})} + :body (pr-str {:user/id "test" :user/password "password123" :role "nothing"})} {:keys [status body]} (handler request)] (are [x y] (= x y) 400 status - ["roll is used by someone."] (-> body edn/read-string :messages)))) + ["role is used by someone."] (-> body edn/read-string :messages)))) (testing "create user" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "test" :user/password "password123" :roll "watcher"})} + :body (pr-str {:user/id "test" :user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (is (= 201 status))) (let [handler (auth/list-resource (:auth system)) @@ -187,18 +188,18 @@ {:keys [status body]} (handler request)] (are [x y] (= x y) 200 status - 2 (-> body edn/read-string count)))) + 3 (-> body edn/read-string count)))) (let [system (new-system config) handler (auth/entry-resource (:auth system) "test")] (testing "delete user" (let [request {:request-method :post :content-type "application/edn" - :body (pr-str {:user/id "test" :user/password "password123" :roll "watcher"})} + :body (pr-str {:user/id "test" :user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (is (= 201 status))) (let [request {:request-method :delete :content-type "application/edn" - :body (pr-str {:user/password "password123" :roll "watcher"})} + :body (pr-str {:user/password "password123" :role "watcher"})} {:keys [status body]} (handler request)] (is (== 204 status))) (let [handler (auth/list-resource (:auth system)) @@ -206,4 +207,4 @@ {:keys [status body]} (handler request)] (are [x y] (= x y) 200 status - 1 (-> body edn/read-string count))))))) + 2 (-> body edn/read-string count))))))) diff --git a/test/clj/job_streamer/control_bus/component/calendar_test.clj b/test/clj/job_streamer/control_bus/component/calendar_test.clj index 34ac775..9a91545 100644 --- a/test/clj/job_streamer/control_bus/component/calendar_test.clj +++ b/test/clj/job_streamer/control_bus/component/calendar_test.clj @@ -22,6 +22,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ test-config)) diff --git a/test/clj/job_streamer/control_bus/component/jobs_test.clj b/test/clj/job_streamer/control_bus/component/jobs_test.clj index 6e92f05..8032e51 100644 --- a/test/clj/job_streamer/control_bus/component/jobs_test.clj +++ b/test/clj/job_streamer/control_bus/component/jobs_test.clj @@ -23,6 +23,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ test-config)) diff --git a/test/clj/job_streamer/control_bus/component/scheduler_test.clj b/test/clj/job_streamer/control_bus/component/scheduler_test.clj index 83fab03..f416226 100644 --- a/test/clj/job_streamer/control_bus/component/scheduler_test.clj +++ b/test/clj/job_streamer/control_bus/component/scheduler_test.clj @@ -17,6 +17,7 @@ (def config (meta-merge config/defaults + config/resource-file config/environ test-config))