Code and constants related to getting and setting cookies in Ring requests and responses. | (ns metabase.request.cookies (:require [java-time.api :as t] [metabase.config :as config] [metabase.models.setting :as setting :refer [defsetting]] [metabase.public-settings :as public-settings] [metabase.request.util :as request.util] [metabase.util :as u] [metabase.util.i18n :refer [deferred-tru tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] [ring.util.response :as response])) |
Key for the session cookie. | (def metabase-session-cookie "metabase.SESSION") |
Key for the embedded session cookie. | (def metabase-embedded-session-cookie "metabase.EMBEDDED_SESSION") |
Key for the session timeout cookie. (See #23349 for more on the purpose of this cookie.) | (def metabase-session-timeout-cookie "metabase.TIMEOUT") |
Header name for the anti-CSRF token we set for certain requests. | (def anti-csrf-token-header "x-metabase-anti-csrf-token") |
(defn- clear-cookie [response cookie-name] (response/set-cookie response cookie-name nil {:expires "Thu, 1 Jan 1970 00:00:00 GMT", :path "/"})) | |
You can't add a cookie (by setting the | (defn- wrap-body-if-needed [response] (if (and (map? response) (contains? response :body)) response {:body response, :status 200})) |
Add a header to | (defn clear-session-cookie [response] (reduce clear-cookie (wrap-body-if-needed response) [metabase-session-cookie metabase-embedded-session-cookie metabase-session-timeout-cookie])) |
(def ^:private possible-session-cookie-samesite-values #{:lax :none :strict nil}) | |
(defn- normalized-session-cookie-samesite [value] (some-> value name u/lower-case-en keyword)) | |
(defn- valid-session-cookie-samesite? [normalized-value] (contains? possible-session-cookie-samesite-values normalized-value)) | |
(defsetting session-cookie-samesite (deferred-tru "Value for the session cookie's `SameSite` directive.") :type :keyword :visibility :settings-manager :default :lax :getter (fn session-cookie-samesite-getter [] (let [value (normalized-session-cookie-samesite (setting/get-raw-value :session-cookie-samesite))] (if (valid-session-cookie-samesite? value) value (throw (ex-info "Invalid value for session cookie samesite" {:possible-values possible-session-cookie-samesite-values :session-cookie-samesite value}))))) :setter (fn session-cookie-samesite-setter [new-value] (let [normalized-value (normalized-session-cookie-samesite new-value)] (if (valid-session-cookie-samesite? normalized-value) (setting/set-value-of-type! :keyword :session-cookie-samesite normalized-value) (throw (ex-info (tru "Invalid value for session cookie samesite") {:possible-values possible-session-cookie-samesite-values :session-cookie-samesite normalized-value :http-status 400}))))) :doc "See [Embedding Metabase in a different domain](../embedding/interactive-embedding.md#embedding-metabase-in-a-different-domain). Read more about [interactive Embedding](../embedding/interactive-embedding.md). Learn more about [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite).") | |
The appropriate cookie attributes to persist a newly created Session to | (defmulti default-session-cookie-attributes {:arglists '([session-type request])} (fn [session-type _] session-type)) |
(defmethod default-session-cookie-attributes :default [session-type _] (throw (ex-info (str (tru "Invalid session-type.")) {:session-type session-type}))) | |
(defmethod default-session-cookie-attributes :normal [_ request] (merge {:same-site (session-cookie-samesite) ;; TODO - we should set `site-path` as well. Don't want to enable this yet so we don't end ;; up breaking things issue: https://github.com/metabase/metabase/issues/39346 :path "/" #_(site-path)} ;; If the authentication request request was made over HTTPS (hopefully always except for ;; local dev instances) add `Secure` attribute so the cookie is only sent over HTTPS. (when (request.util/https? request) {:secure true}))) | |
(defmethod default-session-cookie-attributes :full-app-embed [_ request] (merge {:path "/"} (when (request.util/https? request) ;; SameSite=None is required for cross-domain full-app embedding. This is safe because ;; security is provided via anti-CSRF token. Note that most browsers will only accept ;; SameSite=None with secure cookies, thus we are setting it only over HTTPS to prevent ;; the cookie from being rejected in case of same-domain embedding. {:same-site :none :secure true}))) | |
Returns nil if the [[session-timeout]] value is valid. Otherwise returns an error key. | (defn- check-session-timeout [timeout] (when (some? timeout) (let [{:keys [unit amount]} timeout units-in-24-hours (case unit "seconds" (* 60 60 24) "minutes" (* 60 24) "hours" 24) units-in-100-years (* units-in-24-hours 365.25 100)] (cond (not (pos? amount)) :amount-must-be-positive (>= amount units-in-100-years) :amount-must-be-less-than-100-years)))) |
(defsetting session-timeout ;; Should be in the form "{\"amount\":60,\"unit\":\"minutes\"}" where the unit is one of "seconds", "minutes" or "hours". ;; The amount is nillable. (deferred-tru "Time before inactive users are logged out. By default, sessions last indefinitely.") :encryption :no :type :json :default nil :getter (fn [] (let [value (setting/get-value-of-type :json :session-timeout)] (if-let [error-key (check-session-timeout value)] (do (log/warn (case error-key :amount-must-be-positive "Session timeout amount must be positive." :amount-must-be-less-than-100-years "Session timeout must be less than 100 years.")) nil) value))) :setter (fn [new-value] (when-let [error-key (check-session-timeout new-value)] (throw (ex-info (case error-key :amount-must-be-positive "Session timeout amount must be positive." :amount-must-be-less-than-100-years "Session timeout must be less than 100 years.") {:status-code 400}))) (setting/set-value-of-type! :json :session-timeout new-value)) :doc "Has to be in the JSON format `\"{\"amount\":120,\"unit\":\"minutes\"}\"` where the unit is one of \"seconds\", \"minutes\" or \"hours\".") | |
Convert the session-timeout setting value to seconds. | (defn session-timeout->seconds [{:keys [unit amount]}] (when amount (-> (case unit "seconds" amount "minutes" (* amount 60) "hours" (* amount 3600)) (max 60)))) ; Ensure a minimum of 60 seconds so a user can't lock themselves out |
Returns the number of seconds before a session times out. An alternative to calling | (defn session-timeout-seconds [] (session-timeout->seconds (session-timeout))) |
Add an appropriate timeout cookie to track whether the session should timeout or not, according to the [[session-timeout]] setting. If the session-timeout setting is on, the cookie has an appropriately timed expires attribute. If the session-timeout setting is off, the cookie has a max-age attribute, so it expires in the far future. | (defn set-session-timeout-cookie [response request session-type request-time] (let [response (wrap-body-if-needed response) timeout (session-timeout-seconds) cookie-options (merge (default-session-cookie-attributes session-type request) (if (some? timeout) {:expires (t/format :rfc-1123-date-time (t/plus request-time (t/seconds timeout)))} {:max-age (* 60 (config/config-int :max-session-age))}))] (-> response wrap-body-if-needed (response/set-cookie metabase-session-timeout-cookie "alive" cookie-options)))) |
Returns the appropriate cookie name for the session type. | (defn session-cookie-name [session-type] (case session-type :normal metabase-session-cookie :full-app-embed metabase-embedded-session-cookie)) |
Check if we should use permanent cookies for a given request, which are not cleared when a browser sesion ends. | (defn- use-permanent-cookies? [request] (if (public-settings/session-cookies) ;; Disallow permanent cookies if MB_SESSION_COOKIES is set false ;; Otherwise check whether the user selected "remember me" during login (get-in request [:body :remember]))) |
Add the appropriate cookies to the | (mu/defn set-session-cookies [request response {session-uuid :id session-type :type anti-csrf-token :anti_csrf_token :as _session-instance} :- [:map [:id [:or uuid? [:re u/uuid-regex]]]] request-time] (let [cookie-options (merge (default-session-cookie-attributes session-type request) {:http-only true} ;; If permanent cookies should be used, set the `Max-Age` directive; cookies with no ;; `Max-Age` and no `Expires` directives are session cookies, and are deleted when the ;; browser is closed. ;; See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_the_lifetime_of_a_cookie ;; max-session age-is in minutes; Max-Age= directive should be in seconds (when (use-permanent-cookies? request) {:max-age (* 60 (config/config-int :max-session-age))}))] (when (and (= (session-cookie-samesite) :none) (not (request.util/https? request))) (log/warn (str "Session cookie's SameSite is configured to \"None\", but site is served over an insecure connection." " Some browsers will reject cookies under these conditions." " https://www.chromestatus.com/feature/5633521622188032"))) (-> response wrap-body-if-needed (cond-> (= session-type :full-app-embed) (assoc-in [:headers anti-csrf-token-header] anti-csrf-token)) (set-session-timeout-cookie request session-type request-time) (response/set-cookie (session-cookie-name session-type) (str session-uuid) cookie-options)))) |