Skip to content
Snippets Groups Projects
Unverified Commit 446a6ccf authored by metabase-bot[bot]'s avatar metabase-bot[bot] Committed by GitHub
Browse files

Saml2 slo (#39034) (#39588)


* wip

* SLO works with auth slo handler route

* move slo handling endpoint to /auth/sso/handle_slo

* fix slo redirect url

* SLO works, and the sso-handle-slo for saml is where it belongs

- a ton of cleanup

* fix api/session namespace + add docstrings

* cleaning up logout action

* add slo logout test along with slo response xml

* whitespace + linter

* add docstring

* update exclusions in deps.edn

* un-require metabase-enterprise ns from oss ns

* add docs for how to setup SLO to metabase docs

* docs: clarify that setting up SLO is optional

* move slo logout endpoint into ee code

- removes sso-info defenterprise since it is no longer needed

* use current version of saml20-clj

---------

Co-authored-by: default avatarbryan <bryan.maass@gmail.com>
Co-authored-by: default avatarNick Fitzpatrick <nickfitz.582@gmail.com>
parent c924d704
No related branches found
No related tags found
No related merge requests found
Showing
with 224 additions and 34 deletions
......@@ -2,7 +2,7 @@
:show-docs-arity-on-same-line? true
:project-specs [{:project-path "deps.edn"
:classpath-cmd ["clojure"
"-A:dev:ee:ee-dev:drivers:drivers-dev:build:build/release:build/all"
"-A:dev:ee:ee-dev:drivers:drivers-dev:build:build/all"
"-Spath"]}]
:clean {:ns-inner-blocks-indentation :keep}
:linters {:clojure-lsp/unused-public-var {:level :warning
......
......@@ -88,8 +88,9 @@
lambdaisland/uri {:mvn/version "1.19.155"} ; Used by openai-clojure
medley/medley {:mvn/version "1.4.0"} ; lightweight lib of useful functions
metabase/connection-pool {:mvn/version "1.2.0"} ; simple wrapper around C3P0. JDBC connection pools
metabase/saml20-clj {:mvn/version "2.2.4" ; EE SAML integration.
:exclusions [org.bouncycastle/bcpkix-jdk15on
metabase/saml20-clj {:mvn/version "2.2.7.170"
:exclusions [ ; EE SAML integration TODO: bump version when we release the library
org.bouncycastle/bcpkix-jdk15on
org.bouncycastle/bcprov-jdk15on
org.bouncycastle/bcpkix-jdk18on
org.bouncycastle/bcprov-jdk18on]}
......
......@@ -35,7 +35,10 @@ For more information, check out our guide for [authenticating with SAML](./authe
7. Configure the service provider (Metabase) from **Configure** > **Realm Settings**.
- From **Endpoints**, select “SAML 2.0 Identity Provider Metadata”.
- An XML file will open in a new tab.
8. From the XML file, note the following:
8. Configure the Single Logout service (if you intend to use Single Logout)
1. in **Clients** > **Valid post logout redirect URIs** put your server's uri (this usually matches **Valid redirect URIs**)
2. in **Clients** > **Advanced** > **Logout Service POST Binding URL** put your server URI appended with `/auth/sso/handle_slo`
9. From the XML file, note the following:
1. The URL that appears right after the following string:
```
md:SingleSignOnServiceBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location=
......
......@@ -8,7 +8,7 @@
<div style="width:1000px;">
<img src="../app/assets/img/disconnect.svg">
<h2>It looks like we weren't able to log you in.</h2>
<h2>It looks like we weren't able to log you {{logDirection}}.</h2>
<h2><tt>{{errorMessage}}</tt></h2>
......
......@@ -22,6 +22,11 @@
validate the POST from the SSO backend and successfully log the user into Metabase."
sso-backend)
(defmulti sso-handle-slo
"Multi-method for handling a SLO request from an SSO backend. An implementation of this method will need to validate
the SLO request and log the user out of Metabase."
sso-backend)
(defn- throw-not-configured-error []
(throw (ex-info (str (tru "SSO has not been enabled and/or configured"))
{:status-code 400})))
......@@ -33,3 +38,7 @@
(defmethod sso-post :default
[_]
(throw-not-configured-error))
(defmethod sso-handle-slo :default
[_]
(throw-not-configured-error))
......@@ -8,11 +8,17 @@
[metabase-enterprise.sso.api.interface :as sso.i]
[metabase-enterprise.sso.integrations.jwt]
[metabase-enterprise.sso.integrations.saml]
[metabase-enterprise.sso.integrations.sso-settings :as sso-settings]
[metabase.api.common :as api]
[metabase.util :as u]
[metabase.util.i18n :refer [trs]]
[metabase.util.log :as log]
[stencil.core :as stencil]))
[metabase.util.malli :as mu]
[metabase.util.urls :as urls]
[saml20-clj.core :as saml]
[saml20-clj.encode-decode :as encode-decode]
[stencil.core :as stencil]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
......@@ -20,6 +26,7 @@
(comment metabase-enterprise.sso.integrations.jwt/keep-me
metabase-enterprise.sso.integrations.saml/keep-me)
;; GET /auth/sso
(api/defendpoint GET "/"
"SSO entry-point for an SSO user that has not logged in yet"
[:as req]
......@@ -29,16 +36,19 @@
(log/error #_e (trs "Error returning SSO entry point"))
(throw e))))
(defn- sso-error-page [^Throwable e]
(mu/defn ^:private sso-error-page
[^Throwable e log-direction :- [:enum "in" "out"]]
{:status (get (ex-data e) :status-code 500)
:headers {"Content-Type" "text/html"}
:body (stencil/render-file "metabase_enterprise/sandbox/api/error_page"
(let [message (.getMessage e)
data (u/pprint-to-str (ex-data e))]
{:errorMessage message
:exceptionClass (.getName Exception)
:additionalData data}))})
(let [message (.getMessage e)
data (u/pprint-to-str (ex-data e))]
{:logDirection log-direction
:errorMessage message
:exceptionClass (.getName Exception)
:additionalData data}))})
;; POST /auth/sso
(api/defendpoint POST "/"
"Route the SSO backends call with successful login details"
[:as req]
......@@ -46,6 +56,43 @@
(sso.i/sso-post req)
(catch Throwable e
(log/error e (trs "Error logging in"))
(sso-error-page e))))
(sso-error-page e "in"))))
;; ------------------------------ Single Logout aka SLO ------------------------------
(def metabase-slo-redirect-url
"The url that the IdP should respond to. Not all IdPs support this, but it's a good idea to send it just in case."
"/auth/sso/handle_slo")
;; POST /auth/sso/logout
(api/defendpoint POST "/logout"
"Logout."
[:as {:keys [metabase-session-id]}]
(api/check-exists? :model/Session metabase-session-id)
(let [{:keys [email sso_source]}
(t2/query-one {:select [:u.email :u.sso_source]
:from [[:core_user :u]]
:join [[:core_session :session] [:= :u.id :session.user_id]]
:where [:= :session.id metabase-session-id]})]
{:saml-logout-url
(when (and (sso-settings/saml-enabled)
(= sso_source "saml"))
(saml/logout-redirect-location
:idp-url (sso-settings/saml-identity-provider-uri)
:issuer (sso-settings/saml-application-name)
:user-email email
:relay-state (encode-decode/str->base64
(str (urls/site-url) metabase-slo-redirect-url))))}))
;; POST /auth/sso/handle_slo
(api/defendpoint POST "/handle_slo"
"Handles client confirmation of saml logout via slo"
[:as req]
(try
(sso.i/sso-handle-slo req)
(catch Throwable e
(log/error e (trs "Error handling SLO"))
(sso-error-page e "out"))))
(api/define-routes)
(ns metabase-enterprise.sso.integrations.saml
"Implementation of the SAML backend for SSO.
The basic flow of of a SAML login is:
# The basic flow of of a SAML login is:
1. User attempts to access some `url` but is not authenticated
1. User attempts to access some `url` but is not authenticated.
2. User is redirected to `GET /auth/sso`
2. User is redirected to `GET /auth/sso`.
3. Metabase issues another redirect to the identity provider URI
3. Metabase issues another redirect to the identity provider URI.
4. User logs into their identity provider (i.e. Auth0)
4. User logs into their identity provider (i.e. Auth0).
5. Identity provider POSTs to Metabase with successful auth info
5. Identity provider POSTs to Metabase with successful auth info.
6. Metabase parses/validates the SAML response
6. Metabase parses/validates the SAML response.
7. Metabase inits the user session, responds with a redirect to back to the original `url`"
7. Metabase inits the user session, responds with a redirect to back to the original `url`.
# The basic flow of a SAML logout is:
1. A SSO SAML User clicks Sign Out.
2. Metabase issues a redirect to the client with a LogoutRequest to the identity provider.
3. Client forwards the request to the identity provider.
4. Identity provider logs the user out + redirects client back to Metabase with a LogoutResponse.
5. Metabase checks for successful LogoutResponse, clears the user's session, and responds to the client with a redirect to the home page."
(: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]
......@@ -34,9 +48,12 @@
[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]
[schema.core :as s])
[schema.core :as s]
[toucan2.core :as t2])
(:import
(java.net URI URISyntaxException)
(java.util Base64 UUID)))
......@@ -216,3 +233,28 @@
:device-info (request.u/device-info request)})
response (response/redirect (or continue-url (public-settings/site-url)))]
(mw.session/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) :StatusCode)
(= (get-in x [:attrs :Value]) saml2-success-status))
(reset! *success? true))
x)
(xml/parse-str xml-str))
@*success?))
(defmethod sso.i/sso-handle-slo :saml
[{:keys [cookies params]}]
(let [xml-str (base64-decode (:SAMLResponse params))
success? (slo-success? xml-str)]
(if-let [metabase-session-id (and success? (get-in cookies [mw.session/metabase-session-cookie :value]))]
(do (t2/delete! :model/Session :id metabase-session-id)
(mw.session/clear-session-cookie (response/redirect (urls/site-url))))
{:status 500 :body "SAML logout failed."})))
......@@ -102,7 +102,7 @@
(defn successful-login?
"Return true if the response indicates a successful user login"
[resp]
(string? (get-in resp [:cookies @#'mw.session/metabase-session-cookie :value])))
(string? (get-in resp [:cookies mw.session/metabase-session-cookie :value])))
(defn- do-with-some-validators-disabled
"The sample responses all have `InResponseTo=\"_1\"` and invalid assertion signatures (they were edited by hand) so
......@@ -273,6 +273,9 @@
(defn- new-user-with-groups-in-separate-attribute-nodes-saml-test-response []
(saml-response-from-file "test_resources/saml-test-response-new-user-with-groups-in-separate-attribute-nodes.xml"))
(defn- saml-slo-test-response []
(saml-response-from-file "test_resources/saml-slo-test-response.xml"))
(defn- saml-post-request-options [saml-response relay-state]
{:request-options {:content-type :x-www-form-urlencoded
:redirect-strategy :none
......@@ -650,3 +653,21 @@
(#'saml.mt/fetch-or-create-user! {:first-name "Test"
:last-name "user"
:email "test1234@metabsae.com"})))))))
(deftest logout-should-delete-session-test
(testing "Successful SAML SLO logouts should delete the user's session."
(let [session-id (str (random-uuid))]
(mt/with-temp [:model/User user {:email "saml_test@metabase.com" :sso_source "saml"}
:model/Session _ {:user_id (:id user) :id session-id}]
(with-saml-default-setup
(is (t2/exists? :model/Session :id session-id))
(let [req-options (-> (saml-post-request-options
(saml-slo-test-response)
(saml/str->base64 default-redirect-uri))
;; Client sends their session cookie during the SLO request redirect from the IDP.
(assoc-in [:request-options :cookies mw.session/metabase-session-cookie :value] session-id))
response (client-full-response :post 302 "/auth/sso/handle_slo" req-options)]
(is (str/blank? (get-in response [:cookies mw.session/metabase-session-cookie :value]))
"After a successful log-out, you don't have a session")
(is (not (t2/exists? :model/Session :id session-id))
"After a successful log-out, the session is deleted")))))))
......@@ -20,6 +20,7 @@ export const createMockUser = (opts?: Partial<User>): User => ({
date_joined: new Date().toISOString(),
first_login: new Date().toISOString(),
last_login: new Date().toISOString(),
sso_source: null,
...opts,
});
......
......@@ -30,6 +30,7 @@ export interface User extends BaseUser {
has_invited_second_user: boolean;
has_question_and_dashboard: boolean;
personal_collection_id: number;
sso_source: "saml" | null;
custom_homepage: {
dashboard_id: DashboardId;
} | null;
......
import { getIn } from "icepick";
import { push } from "react-router-redux";
import { deleteSession } from "metabase/lib/auth";
import { deleteSession, initiateSLO } from "metabase/lib/auth";
import { reload, isSmallScreen } from "metabase/lib/dom";
import { loadLocalization } from "metabase/lib/i18n";
import { createAsyncThunk } from "metabase/lib/redux";
......@@ -99,14 +99,32 @@ export const loginGoogle = createAsyncThunk(
export const LOGOUT = "metabase/auth/LOGOUT";
export const logout = createAsyncThunk(
LOGOUT,
async (redirectUrl: string | undefined, { dispatch, rejectWithValue }) => {
async (
redirectUrl: string | undefined,
{ dispatch, rejectWithValue, getState },
) => {
try {
await deleteSession();
dispatch(clearCurrentUser());
await dispatch(refreshLocale()).unwrap();
trackLogout();
dispatch(push(Urls.login(redirectUrl)));
reload(); // clears redux state and browser caches
const state = getState();
const user = getUser(state);
if (user?.sso_source === "saml") {
const { "saml-logout-url": samlLogoutUrl } = await initiateSLO();
dispatch(clearCurrentUser());
await dispatch(refreshLocale()).unwrap();
trackLogout();
if (samlLogoutUrl) {
window.location.href = samlLogoutUrl;
}
} else {
await deleteSession();
dispatch(clearCurrentUser());
await dispatch(refreshLocale()).unwrap();
trackLogout();
dispatch(push(Urls.login()));
reload(); // clears redux state and browser caches
}
} catch (error) {
return rejectWithValue(error);
}
......
......@@ -9,3 +9,13 @@ export const deleteSession = async () => {
}
}
};
export const initiateSLO = async () => {
try {
return await SessionApi.slo();
} catch (error) {
if (error.status !== 404) {
console.error("Problem clearing session", error);
}
}
};
......@@ -441,6 +441,7 @@ export const SessionApi = {
create: POST("/api/session"),
createWithGoogleAuth: POST("/api/session/google_auth"),
delete: DELETE("/api/session"),
slo: POST("/auth/sso/logout"),
properties: GET("/api/session/properties"),
forgot_password: POST("/api/session/forgot_password"),
reset_password: POST("/api/session/reset_password"),
......
......@@ -46,7 +46,8 @@
(:import
(java.util UUID)))
(def ^:private ^String metabase-session-cookie "metabase.SESSION")
(def ^String metabase-session-cookie
"Where the session cookie goes." "metabase.SESSION")
(def ^:private ^String metabase-embedded-session-cookie "metabase.EMBEDDED_SESSION")
(def ^:private ^String metabase-session-timeout-cookie "metabase.TIMEOUT")
(def ^:private ^String anti-csrf-token-header "x-metabase-anti-csrf-token")
......
......@@ -39,7 +39,7 @@
[:map
[:id ms/UUIDString]])
(def ^:private session-cookie @#'mw.session/metabase-session-cookie)
(def ^:private session-cookie mw.session/metabase-session-cookie)
(deftest login-test
(reset-throttlers!)
......
......@@ -31,7 +31,7 @@
(set! *warn-on-reflection* true)
(def ^:private session-cookie @#'mw.session/metabase-session-cookie)
(def ^:private session-cookie mw.session/metabase-session-cookie)
(def ^:private session-timeout-cookie @#'mw.session/metabase-session-timeout-cookie)
(def ^:private test-uuid #uuid "092797dd-a82a-4748-b393-697d7bb9ab65")
......
<?xml version="1.0" encoding="utf-8"?>
<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
Destination="http://localhost:3000/auth/sso/handle_slo"
ID="ID_34f419f0-a7c4-4a93-9bc0-e53efc65bbb3"
InResponseTo="id67c2efc8-c92d-4c65-8974-470953cb49dd"
IssueInstant="2024-02-22T18:32:52.132Z"
Version="2.0"
>
<Issuer>http://localhost:9090/realms/master</Issuer>
<dsig:Signature xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
<dsig:SignedInfo>
<dsig:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
<dsig:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<dsig:Reference URI="#ID_34f419f0-a7c4-4a93-9bc0-e53efc65bbb3">
<dsig:Transforms>
<dsig:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
<dsig:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
</dsig:Transforms>
<dsig:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<dsig:DigestValue>35Apmql7ucZdh7rVgwEwrmJQGDqqlHMiss2YV4q9Q5U=</dsig:DigestValue>
</dsig:Reference>
</dsig:SignedInfo>
<dsig:SignatureValue>oL0dRK4iXsRVFrW6lASYb2/ra4/0JMbhVEBQn9aVQHT+eBeSgpmYskEk6oeK4G1+dGFsSPgP8Sa6YAm8YyMyG2oqjrnCRAicJcJ0+tQIyztpK9lUNZeFkrig/tcVaRWu2Y1vyBAUeZRdhPH3HZN3AjDovJTL5ziwI+NkukGtt9xa4F+d/qHNbfM7IIe5mwHj2IWweeulIz8IxvsgiFAu4vWek+b75ykiNiLuJfEvJf+Gdt2IQHD1O8JftMI/RXK4mnjA8VHVYHsoTnWjhFLLCsIsVhAPoZeegp6e05ozPEt+MCCLQ76QF5HtjeeX6UjbIP7ADbohFOnO9tw7Vv9u/w==</dsig:SignatureValue>
<dsig:KeyInfo>
<dsig:X509Data>
<dsig:X509Certificate>MIICmzCCAYMCBgGNix9hpjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQwMjA4MjM0NjQ2WhcNMzQwMjA4MjM0ODI2WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCnd6BZgurzPoaBJPmbF/PqZynVsJ8srpdvIN/VKolZTavAl+vYq9L+a6NI21pV9K5rw8EwSm9uO+4tADoOP0av+Ku1r9DsuAY/NsOSdN0kRN32k3ADtaYsx4yIBQaNB6MLojM8XC6ajUvJj49zSoJhr7CGx1NF0zNdn9xzy+VmMANl+OVJ3Ek2QBmvsoO2sN3+iekZ92Te/q4iOFSzcG7nEN6APnH41oXsY2CWp8pqhOkntFKsYKJq8igY19AiZvmOdZ4X9YNoRoSlXppX6G1RV3ns0Ko6JsBjFgyMf46WMNPQyKubPictTbJ5mYoWqVAG34rR5WGV+fa1TxbB4cN3AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHfHB9nvMfvJuDFX6quGzEFKBvYISDI6PQoUsQXtKJTh15OoT/CoVk3oF2mIqphAv1robCFsujC/QeUKQvrSuDVq1j6Bd0dtvEL3baq33UQq1eDsF94aK/SBG7JFPC2tHr2WDhK57NQM0O4NMGvXA2Leq1f9KCoyWv1pxp9VDT5Gl7meKwWcTvHHyV4G1i0YPax/8cXEkPSOtK4ZRPOH2e3Vffa5pvDyCHCAMV3AUDiUc7u75A3ozSUtMFVuBn6PyV9O/LKPTf45h1zGdgPDH8DyqJSyr5Q2viB8AqjIg1rdSX9++QoDOjP4PxOaZ2waYbwEHI6H3qzw3yNgou2KOQM=</dsig:X509Certificate>
</dsig:X509Data>
</dsig:KeyInfo>
</dsig:Signature>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
</samlp:LogoutResponse>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment