Implementation of the SAML backend for SSO. The basic flow of of a SAML login is:
The basic flow of a SAML logout is:
| (ns metabase-enterprise.sso.integrations.saml (:require [buddy.core.codecs :as codecs] [clojure.data.xml :as xml] [clojure.string :as str] [clojure.walk :as walk] [java-time.api :as t] [medley.core :as m] [metabase-enterprise.sso.api.interface :as sso.i] [metabase-enterprise.sso.integrations.sso-settings :as sso-settings] [metabase-enterprise.sso.integrations.sso-utils :as sso-utils] [metabase.api.common :as api] [metabase.api.session :as api.session] [metabase.integrations.common :as integrations.common] [metabase.public-settings :as public-settings] [metabase.public-settings.premium-features :as premium-features] [metabase.request.core :as request] [metabase.util :as u] [metabase.util.i18n :refer [trs tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.urls :as urls] [ring.util.response :as response] [saml20-clj.core :as saml] [toucan2.core :as t2]) (:import (java.util Base64))) |
(set! *warn-on-reflection* true) | |
Translate a user's group names to a set of MB group IDs using the configured mappings | (defn- group-names->ids [group-names] (->> (cond-> group-names (string? group-names) vector) (map keyword) (mapcat (sso-settings/saml-group-mappings)) set)) |
Returns the set of all MB group IDs that have configured mappings | (defn- all-mapped-group-ids [] (-> (sso-settings/saml-group-mappings) vals flatten set)) |
Sync a user's groups based on mappings configured in the SAML settings | (defn sync-groups! [user group-names] (when (sso-settings/saml-group-sync) (when group-names (integrations.common/sync-group-memberships! user (group-names->ids group-names) (all-mapped-group-ids))))) |
(mu/defn- fetch-or-create-user! :- [:maybe [:map [:id uuid?]]] "Returns a Session for the given `email`. Will create the user if needed." [{:keys [first-name last-name email group-names user-attributes device-info]}] (when-not (sso-settings/saml-enabled) (throw (IllegalArgumentException. (tru "Can't create new SAML user when SAML is not enabled")))) (when-not email (throw (ex-info (str (tru "Invalid SAML configuration: could not find user email.") " " (tru "We tried looking for {0}, but couldn't find the attribute." (sso-settings/saml-attribute-email)) " " (tru "Please make sure your SAML IdP is properly configured.")) {:status-code 400, :user-attributes (keys user-attributes)}))) (let [new-user {:first_name first-name :last_name last-name :email email :sso_source :saml :login_attributes user-attributes}] (when-let [user (or (sso-utils/fetch-and-update-login-attributes! new-user) (sso-utils/check-user-provisioning :saml) (sso-utils/create-new-sso-user! new-user))] (sync-groups! user group-names) (api.session/create-session! :sso user device-info)))) | |
SAML route supporting functions | |
(defn- acs-url [] (str (public-settings/site-url) "/auth/sso")) | |
(defn- sp-cert-keystore-details [] (when-let [path (sso-settings/saml-keystore-path)] (when-let [password (sso-settings/saml-keystore-password)] (when-let [key-name (sso-settings/saml-keystore-alias)] {:filename path :password password :alias key-name})))) | |
(defn- check-saml-enabled [] (api/check (sso-settings/saml-enabled) [400 (tru "SAML has not been enabled and/or configured")])) | |
(defmethod sso.i/sso-get :saml ;; Initial call that will result in a redirect to the IDP along with information about how the IDP can authenticate ;; and redirect them back to us [req] (premium-features/assert-has-feature :sso-saml (tru "SAML-based authentication")) (check-saml-enabled) (let [redirect (get-in req [:params :redirect]) redirect-url (if (nil? redirect) (do (log/warn "Warning: expected `redirect` param, but none is present") (public-settings/site-url)) (if (sso-utils/relative-uri? redirect) (str (public-settings/site-url) redirect) redirect))] (sso-utils/check-sso-redirect redirect-url) (try (let [idp-url (sso-settings/saml-identity-provider-uri) saml-request (saml/request {:request-id (str "id-" (random-uuid)) :sp-name (sso-settings/saml-application-name) :issuer (sso-settings/saml-application-name) :acs-url (acs-url) :idp-url idp-url :credential (sp-cert-keystore-details)}) relay-state (saml/str->base64 redirect-url)] (saml/idp-redirect-response saml-request idp-url relay-state)) (catch Throwable e (let [msg (trs "Error generating SAML request")] (log/error e msg) (throw (ex-info msg {:status-code 500} e))))))) | |
(defn- validate-response [response] (let [idp-cert (or (sso-settings/saml-identity-provider-certificate) (throw (ex-info (str (tru "Unable to log in: SAML IdP certificate is not set.")) {:status-code 500})))] (try (saml/validate response idp-cert (sp-cert-keystore-details) {:acs-url (acs-url) :issuer (sso-settings/saml-identity-provider-issuer)}) (catch Throwable e (log/error e "SAML response validation failed") (throw (ex-info (tru "Unable to log in: SAML response validation failed") {:status-code 401} e)))))) | |
(defn- xml-string->saml-response [xml-string] (validate-response (saml/->Response xml-string))) | |
For some reason all of the user attributes coming back from the saml library are wrapped in a list, instead of 'Ryan', it's ('Ryan'). This function discards the list if there's just a single item in it. | (defn- unwrap-user-attributes [m] (m/map-vals (fn [maybe-coll] (if (and (coll? maybe-coll) (= 1 (count maybe-coll))) (first maybe-coll) maybe-coll)) m)) |
(defn- saml-response->attributes [saml-response] (let [assertions (saml/assertions saml-response) attrs (-> assertions first :attrs unwrap-user-attributes)] (when-not attrs (throw (ex-info (str (tru "Unable to log in: SAML info does not contain user attributes.")) {:status-code 401}))) attrs)) | |
(defn- base64-decode [^String s] (when (u/base64-string? s) (codecs/bytes->str (.decode (Base64/getMimeDecoder) s)))) | |
(defmethod sso.i/sso-post :saml ;; Does the verification of the IDP's response and 'logs the user in'. The attributes are available in the response: ;; `(get-in saml-info [:assertions :attrs]) [{:keys [params], :as request}] (premium-features/assert-has-feature :sso-saml (tru "SAML-based authentication")) (check-saml-enabled) (let [continue-url (u/ignore-exceptions (when-let [s (some-> (:RelayState params) base64-decode)] (when-not (str/blank? s) s)))] (sso-utils/check-sso-redirect continue-url) (let [xml-string (str/trim (base64-decode (:SAMLResponse params))) saml-response (xml-string->saml-response xml-string) attrs (saml-response->attributes saml-response) email (get attrs (sso-settings/saml-attribute-email)) first-name (get attrs (sso-settings/saml-attribute-firstname)) last-name (get attrs (sso-settings/saml-attribute-lastname)) groups (get attrs (sso-settings/saml-attribute-group)) session (fetch-or-create-user! {:first-name first-name :last-name last-name :email email :group-names groups :user-attributes attrs :device-info (request/device-info request)}) response (response/redirect (or continue-url (public-settings/site-url)))] (request/set-session-cookies request response session (t/zoned-date-time (t/zone-id "GMT")))))) | |
(def ^:private saml2-success-status "urn:oasis:names:tc:SAML:2.0:status:Success") | |
(mu/defn slo-success? :- :boolean "Given a slo request saml response, return true if the response is successful." [xml-str] (let [*success? (atom false)] (walk/postwalk (fn [x] (when (and (map? x) (= (:tag x) :samlp:StatusCode) (= (get-in x [:attrs :Value]) saml2-success-status)) (reset! *success? true)) x) (xml/parse-str xml-str {:namespace-aware false})) @*success?)) | |
(defmethod sso.i/sso-handle-slo :saml [{:keys [cookies params]}] (if (sso-settings/saml-slo-enabled) (let [xml-str (base64-decode (:SAMLResponse params)) success? (slo-success? xml-str)] (if-let [metabase-session-id (and success? (get-in cookies [request/metabase-session-cookie :value]))] (do (t2/delete! :model/Session :id metabase-session-id) (request/clear-session-cookie (response/redirect (urls/site-url)))) {:status 500 :body "SAML logout failed."})) (log/warn "SAML SLO is not enabled, not continuing Single Log Out flow."))) | |