mirror of
https://github.com/penpot/penpot.git
synced 2025-05-24 15:46:11 +02:00
539 lines
20 KiB
Clojure
539 lines
20 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.auth.oidc
|
|
"OIDC client implementation."
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.logging :as l]
|
|
[app.common.spec :as us]
|
|
[app.common.uri :as u]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.http.client :as http]
|
|
[app.http.middleware :as hmw]
|
|
[app.loggers.audit :as audit]
|
|
[app.rpc.queries.profile :as profile]
|
|
[app.tokens :as tokens]
|
|
[app.util.json :as json]
|
|
[app.util.time :as dt]
|
|
[app.worker :as wrk]
|
|
[clojure.set :as set]
|
|
[clojure.spec.alpha :as s]
|
|
[cuerdas.core :as str]
|
|
[integrant.core :as ig]
|
|
[promesa.core :as p]
|
|
[promesa.exec :as px]
|
|
[yetti.response :as yrs]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; HELPERS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- obfuscate-string
|
|
[s]
|
|
(if (< (count s) 10)
|
|
(apply str (take (count s) (repeat "*")))
|
|
(str (subs s 0 5)
|
|
(apply str (take (- (count s) 5) (repeat "*"))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; OIDC PROVIDER (GENERIC)
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- discover-oidc-config
|
|
[{:keys [http-client]} {:keys [base-uri] :as opts}]
|
|
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration")
|
|
response (ex/try (http/req! http-client {:method :get :uri (str discovery-uri)} {:sync? true}))]
|
|
(cond
|
|
(ex/exception? response)
|
|
(do
|
|
(l/warn :hint "unable to discover oidc configuration"
|
|
:discover-uri (str discovery-uri)
|
|
:cause response)
|
|
nil)
|
|
|
|
(= 200 (:status response))
|
|
(let [data (json/read (:body response))]
|
|
{:token-uri (get data :token_endpoint)
|
|
:auth-uri (get data :authorization_endpoint)
|
|
:user-uri (get data :userinfo_endpoint)})
|
|
|
|
:else
|
|
(do
|
|
(l/warn :hint "unable to discover OIDC configuration"
|
|
:uri (str discovery-uri)
|
|
:response-status-code (:status response))
|
|
nil))))
|
|
|
|
(defn- prepare-oidc-opts
|
|
[cfg]
|
|
(let [opts {:base-uri (:base-uri cfg)
|
|
:client-id (:client-id cfg)
|
|
:client-secret (:client-secret cfg)
|
|
:token-uri (:token-uri cfg)
|
|
:auth-uri (:auth-uri cfg)
|
|
:user-uri (:user-uri cfg)
|
|
:scopes (:scopes cfg #{"openid" "profile" "email"})
|
|
:roles-attr (:roles-attr cfg)
|
|
:roles (:roles cfg)
|
|
:name "oidc"}
|
|
|
|
opts (d/without-nils opts)]
|
|
|
|
(when (and (string? (:base-uri opts))
|
|
(string? (:client-id opts))
|
|
(string? (:client-secret opts)))
|
|
(if (and (string? (:token-uri opts))
|
|
(string? (:user-uri opts))
|
|
(string? (:auth-uri opts)))
|
|
opts
|
|
(some-> (discover-oidc-config cfg opts)
|
|
(merge opts {:discover? true}))))))
|
|
|
|
(defmethod ig/prep-key ::generic-provider
|
|
[_ cfg]
|
|
(d/without-nils cfg))
|
|
|
|
(defmethod ig/init-key ::generic-provider
|
|
[_ cfg]
|
|
(when (:enabled? cfg)
|
|
(if-let [opts (prepare-oidc-opts cfg)]
|
|
(do
|
|
(l/info :hint "provider initialized"
|
|
:provider :oidc
|
|
:method (if (:discover? opts) "discover" "manual")
|
|
:client-id (:client-id opts)
|
|
:client-secret (obfuscate-string (:client-secret opts))
|
|
:scopes (str/join "," (:scopes opts))
|
|
:auth-uri (:auth-uri opts)
|
|
:user-uri (:user-uri opts)
|
|
:token-uri (:token-uri opts)
|
|
:roles-attr (:roles-attr opts)
|
|
:roles (:roles opts))
|
|
opts)
|
|
(do
|
|
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :oidc)
|
|
nil))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; GOOGLE AUTH PROVIDER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defmethod ig/prep-key ::google-provider
|
|
[_ cfg]
|
|
(d/without-nils cfg))
|
|
|
|
(defmethod ig/init-key ::google-provider
|
|
[_ cfg]
|
|
(let [opts {:client-id (:client-id cfg)
|
|
:client-secret (:client-secret cfg)
|
|
:scopes #{"openid" "email" "profile"}
|
|
:auth-uri "https://accounts.google.com/o/oauth2/v2/auth"
|
|
:token-uri "https://oauth2.googleapis.com/token"
|
|
:user-uri "https://openidconnect.googleapis.com/v1/userinfo"
|
|
:name "google"}]
|
|
|
|
(when (:enabled? cfg)
|
|
(if (and (string? (:client-id opts))
|
|
(string? (:client-secret opts)))
|
|
(do
|
|
(l/info :hint "provider initialized"
|
|
:provider :google
|
|
:client-id (:client-id opts)
|
|
:client-secret (obfuscate-string (:client-secret opts)))
|
|
opts)
|
|
|
|
(do
|
|
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :google)
|
|
nil)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; GITHUB AUTH PROVIDER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- retrieve-github-email
|
|
[{:keys [http-client]} tdata info]
|
|
(or (some-> info :email p/resolved)
|
|
(-> (http/req! http-client {:uri "https://api.github.com/user/emails"
|
|
:headers {"Authorization" (dm/str (:type tdata) " " (:token tdata))}
|
|
:timeout 6000
|
|
:method :get})
|
|
(p/then (fn [{:keys [status body] :as response}]
|
|
(when-not (s/int-in-range? 200 300 status)
|
|
(ex/raise :type :internal
|
|
:code :unable-to-retrieve-github-emails
|
|
:hint "unable to retrieve github emails"
|
|
:http-status status
|
|
:http-body body))
|
|
(->> response :body json/read (filter :primary) first :email))))))
|
|
|
|
(defmethod ig/prep-key ::github-provider
|
|
[_ cfg]
|
|
(d/without-nils cfg))
|
|
|
|
(defmethod ig/init-key ::github-provider
|
|
[_ cfg]
|
|
(let [opts {:client-id (:client-id cfg)
|
|
:client-secret (:client-secret cfg)
|
|
:scopes #{"read:user" "user:email"}
|
|
:auth-uri "https://github.com/login/oauth/authorize"
|
|
:token-uri "https://github.com/login/oauth/access_token"
|
|
:user-uri "https://api.github.com/user"
|
|
:name "github"
|
|
|
|
;; Additional hooks for provider specific way of
|
|
;; retrieve emails.
|
|
:get-email-fn (partial retrieve-github-email cfg)}]
|
|
|
|
(when (:enabled? cfg)
|
|
(if (and (string? (:client-id opts))
|
|
(string? (:client-secret opts)))
|
|
(do
|
|
(l/info :hint "provider initialized"
|
|
:provider :github
|
|
:client-id (:client-id opts)
|
|
:client-secret (obfuscate-string (:client-secret opts)))
|
|
opts)
|
|
|
|
(do
|
|
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :github)
|
|
nil)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; GITLAB AUTH PROVIDER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defmethod ig/prep-key ::gitlab-provider
|
|
[_ cfg]
|
|
(d/without-nils cfg))
|
|
|
|
(defmethod ig/init-key ::gitlab-provider
|
|
[_ cfg]
|
|
(let [base (:base-uri cfg "https://gitlab.com")
|
|
opts {:base-uri base
|
|
:client-id (:client-id cfg)
|
|
:client-secret (:client-secret cfg)
|
|
:scopes #{"openid" "profile" "email"}
|
|
:auth-uri (str base "/oauth/authorize")
|
|
:token-uri (str base "/oauth/token")
|
|
:user-uri (str base "/oauth/userinfo")
|
|
:name "gitlab"}]
|
|
(when (:enabled? cfg)
|
|
(if (and (string? (:client-id opts))
|
|
(string? (:client-secret opts)))
|
|
(do
|
|
(l/info :hint "provider initialized"
|
|
:provider :gitlab
|
|
:base-uri base
|
|
:client-id (:client-id opts)
|
|
:client-secret (obfuscate-string (:client-secret opts)))
|
|
opts)
|
|
|
|
(do
|
|
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :gitlab)
|
|
nil)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; HANDLERS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- build-redirect-uri
|
|
[{:keys [provider] :as cfg}]
|
|
(let [public (u/uri (:public-uri cfg))]
|
|
(str (assoc public :path (str "/api/auth/oauth/" (:name provider) "/callback")))))
|
|
|
|
(defn- build-auth-uri
|
|
[{:keys [provider] :as cfg} state]
|
|
(let [params {:client_id (:client-id provider)
|
|
:redirect_uri (build-redirect-uri cfg)
|
|
:response_type "code"
|
|
:state state
|
|
:scope (str/join " " (:scopes provider []))}
|
|
query (u/map->query-string params)]
|
|
(-> (u/uri (:auth-uri provider))
|
|
(assoc :query query)
|
|
(str))))
|
|
|
|
(defn- qualify-props
|
|
[provider props]
|
|
(reduce-kv (fn [result k v]
|
|
(assoc result (keyword (:name provider) (name k)) v))
|
|
{}
|
|
props))
|
|
|
|
(defn retrieve-access-token
|
|
[{:keys [provider http-client] :as cfg} code]
|
|
(let [params {:client_id (:client-id provider)
|
|
:client_secret (:client-secret provider)
|
|
:code code
|
|
:grant_type "authorization_code"
|
|
:redirect_uri (build-redirect-uri cfg)}
|
|
req {:method :post
|
|
:headers {"content-type" "application/x-www-form-urlencoded"
|
|
"accept" "application/json"}
|
|
:uri (:token-uri provider)
|
|
:body (u/map->query-string params)}]
|
|
(p/then
|
|
(http/req! http-client req)
|
|
(fn [{:keys [status body] :as res}]
|
|
(if (= status 200)
|
|
(let [data (json/read body)]
|
|
{:token (get data :access_token)
|
|
:type (get data :token_type)})
|
|
(ex/raise :type :internal
|
|
:code :unable-to-retrieve-token
|
|
:http-status status
|
|
:http-body body))))))
|
|
|
|
(defn- retrieve-user-info
|
|
[{:keys [provider http-client] :as cfg} tdata]
|
|
(letfn [(retrieve []
|
|
(http/req! http-client {:uri (:user-uri provider)
|
|
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
|
|
:timeout 6000
|
|
:method :get}))
|
|
(validate-response [response]
|
|
(when-not (s/int-in-range? 200 300 (:status response))
|
|
(ex/raise :type :internal
|
|
:code :unable-to-retrieve-user-info
|
|
:hint "unable to retrieve user info"
|
|
:http-status (:status response)
|
|
:http-body (:body response)))
|
|
response)
|
|
|
|
(get-email [info]
|
|
;; Allow providers hook into this for custom email
|
|
;; retrieval method.
|
|
(if-let [get-email-fn (:get-email-fn provider)]
|
|
(get-email-fn tdata info)
|
|
(let [attr-kw (cf/get :oidc-email-attr :email)]
|
|
(get info attr-kw))))
|
|
|
|
(get-name [info]
|
|
(let [attr-kw (cf/get :oidc-name-attr :name)]
|
|
(get info attr-kw)))
|
|
|
|
(process-response [response]
|
|
(p/let [info (-> response :body json/read)
|
|
email (get-email info)]
|
|
{:backend (:name provider)
|
|
:email email
|
|
:fullname (or (get-name info) email)
|
|
:props (->> (dissoc info :name :email)
|
|
(qualify-props provider))}))
|
|
|
|
(validate-info [info]
|
|
(when-not (s/valid? ::info info)
|
|
(l/warn :hint "received incomplete profile info object (please set correct scopes)"
|
|
:info (pr-str info))
|
|
(ex/raise :type :internal
|
|
:code :incomplete-user-info
|
|
:hint "inconmplete user info"
|
|
:info info))
|
|
info)]
|
|
|
|
(-> (retrieve)
|
|
(p/then validate-response)
|
|
(p/then process-response)
|
|
(p/then validate-info))))
|
|
|
|
(s/def ::backend ::us/not-empty-string)
|
|
(s/def ::email ::us/not-empty-string)
|
|
(s/def ::fullname ::us/not-empty-string)
|
|
(s/def ::props (s/map-of ::us/keyword any?))
|
|
(s/def ::info
|
|
(s/keys :req-un [::backend
|
|
::email
|
|
::fullname
|
|
::props]))
|
|
|
|
(defn retrieve-info
|
|
[{:keys [sprops provider] :as cfg} {:keys [params] :as request}]
|
|
(letfn [(validate-oidc [info]
|
|
;; If the provider is OIDC, we can proceed to check
|
|
;; roles if they are defined.
|
|
(when (and (= "oidc" (:name provider))
|
|
(seq (:roles provider)))
|
|
(let [provider-roles (into #{} (:roles provider))
|
|
profile-roles (let [attr (cf/get :oidc-roles-attr :roles)
|
|
roles (get info attr)]
|
|
(cond
|
|
(string? roles) (into #{} (str/words roles))
|
|
(vector? roles) (into #{} roles)
|
|
:else #{}))]
|
|
|
|
;; check if profile has a configured set of roles
|
|
(when-not (set/subset? provider-roles profile-roles)
|
|
(ex/raise :type :internal
|
|
:code :unable-to-auth
|
|
:hint "not enough permissions"))))
|
|
info)
|
|
|
|
(post-process [state info]
|
|
(cond-> info
|
|
(some? (:invitation-token state))
|
|
(assoc :invitation-token (:invitation-token state))
|
|
|
|
;; If state token comes with props, merge them. The state token
|
|
;; props can contain pm_ and utm_ prefixed query params.
|
|
(map? (:props state))
|
|
(update :props merge (:props state))))]
|
|
|
|
(when-let [error (get params :error)]
|
|
(ex/raise :type :internal
|
|
:code :error-on-retrieving-code
|
|
:error-id error
|
|
:error-desc (get params :error_description)))
|
|
|
|
(let [state (get params :state)
|
|
code (get params :code)
|
|
state (tokens/verify sprops {:token state :iss :oauth})]
|
|
(-> (p/resolved code)
|
|
(p/then #(retrieve-access-token cfg %))
|
|
(p/then #(retrieve-user-info cfg %))
|
|
(p/then' validate-oidc)
|
|
(p/then' (partial post-process state))))))
|
|
|
|
(defn- retrieve-profile
|
|
[{:keys [pool executor] :as cfg} info]
|
|
(px/with-dispatch executor
|
|
(with-open [conn (db/open pool)]
|
|
(some->> (:email info)
|
|
(profile/retrieve-profile-data-by-email conn)
|
|
(profile/populate-additional-data conn)
|
|
(profile/decode-profile-row)))))
|
|
|
|
(defn- redirect-response
|
|
[uri]
|
|
(yrs/response :status 302 :headers {"location" (str uri)}))
|
|
|
|
(defn- generate-error-redirect
|
|
[cfg error]
|
|
(let [uri (-> (u/uri (:public-uri cfg))
|
|
(assoc :path "/#/auth/login")
|
|
(assoc :query (u/map->query-string {:error "unable-to-auth" :hint (ex-message error)})))]
|
|
(redirect-response uri)))
|
|
|
|
(defn- generate-redirect
|
|
[{:keys [sprops session audit] :as cfg} request info profile]
|
|
(if profile
|
|
(let [sxf ((:create session) (:id profile))
|
|
token (or (:invitation-token info)
|
|
(tokens/generate sprops {:iss :auth
|
|
:exp (dt/in-future "15m")
|
|
:profile-id (:id profile)}))
|
|
params {:token token}
|
|
|
|
uri (-> (u/uri (:public-uri cfg))
|
|
(assoc :path "/#/auth/verify-token")
|
|
(assoc :query (u/map->query-string params)))]
|
|
|
|
(when (fn? audit)
|
|
(audit :cmd :submit
|
|
:type "command"
|
|
:name "login"
|
|
:profile-id (:id profile)
|
|
:ip-addr (audit/parse-client-ip request)
|
|
:props (audit/profile->props profile)))
|
|
|
|
(->> (redirect-response uri)
|
|
(sxf request)))
|
|
|
|
(let [info (assoc info
|
|
:iss :prepared-register
|
|
:is-active true
|
|
:exp (dt/in-future {:hours 48}))
|
|
token (tokens/generate sprops info)
|
|
params (d/without-nils
|
|
{:token token
|
|
:fullname (:fullname info)})
|
|
uri (-> (u/uri (:public-uri cfg))
|
|
(assoc :path "/#/auth/register/validate")
|
|
(assoc :query (u/map->query-string params)))]
|
|
(redirect-response uri))))
|
|
|
|
(defn- auth-handler
|
|
[{:keys [sprops] :as cfg} {:keys [params] :as request}]
|
|
(let [props (audit/extract-utm-params params)
|
|
state (tokens/generate sprops
|
|
{:iss :oauth
|
|
:invitation-token (:invitation-token params)
|
|
:props props
|
|
:exp (dt/in-future "15m")})
|
|
uri (build-auth-uri cfg state)]
|
|
(yrs/response 200 {:redirect-uri uri})))
|
|
|
|
(defn- callback-handler
|
|
[cfg request]
|
|
(letfn [(process-request []
|
|
(p/let [info (retrieve-info cfg request)
|
|
profile (retrieve-profile cfg info)]
|
|
(generate-redirect cfg request info profile)))
|
|
|
|
(handle-error [cause]
|
|
(l/error :hint "error on oauth process" :cause cause)
|
|
(generate-error-redirect cfg cause))]
|
|
|
|
(-> (process-request)
|
|
(p/catch handle-error))))
|
|
|
|
(def provider-lookup
|
|
{:compile
|
|
(fn [& _]
|
|
(fn [handler]
|
|
(fn [{:keys [providers] :as cfg} request]
|
|
(let [provider (some-> request :path-params :provider keyword)]
|
|
(if-let [provider (get providers provider)]
|
|
(handler (assoc cfg :provider provider) request)
|
|
(ex/raise :type :restriction
|
|
:code :provider-not-configured
|
|
:provider provider
|
|
:hint "provider not configured"))))))})
|
|
|
|
(s/def ::public-uri ::us/not-empty-string)
|
|
(s/def ::http-client ::http/client)
|
|
(s/def ::session map?)
|
|
(s/def ::sprops map?)
|
|
(s/def ::providers map?)
|
|
|
|
(defmethod ig/pre-init-spec ::routes
|
|
[_]
|
|
(s/keys :req-un [::public-uri
|
|
::session
|
|
::sprops
|
|
::http-client
|
|
::providers
|
|
::db/pool
|
|
::wrk/executor]))
|
|
|
|
(defmethod ig/init-key ::routes
|
|
[_ {:keys [executor session] :as cfg}]
|
|
(let [cfg (update cfg :provider d/without-nils)]
|
|
["" {:middleware [[(:middleware session)]
|
|
[hmw/with-dispatch executor]
|
|
[hmw/with-config cfg]
|
|
[provider-lookup]
|
|
]}
|
|
;; We maintain the both URI prefixes for backward compatibility.
|
|
|
|
["/auth/oauth"
|
|
["/:provider"
|
|
{:handler auth-handler
|
|
:allowed-methods #{:post}}]
|
|
["/:provider/callback"
|
|
{:handler callback-handler
|
|
:allowed-methods #{:get}}]]
|
|
|
|
["/auth/oidc"
|
|
["/:provider"
|
|
{:handler auth-handler
|
|
:allowed-methods #{:post}}]
|
|
["/:provider/callback"
|
|
{:handler callback-handler
|
|
:allowed-methods #{:get}}]]]))
|