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)))) |