Skip to content
Snippets Groups Projects
Commit 0c17600c authored by Cam Saul's avatar Cam Saul
Browse files

Add HttpOnly, SameSite, and Secure directives to session cookies

parent 08c52552
No related branches found
No related tags found
No related merge requests found
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
[metabase.api.common :as api] [metabase.api.common :as api]
[metabase.email.messages :as email] [metabase.email.messages :as email]
[metabase.integrations.ldap :as ldap] [metabase.integrations.ldap :as ldap]
[metabase.middleware.session :as mw.session]
[metabase.models [metabase.models
[session :refer [Session]] [session :refer [Session]]
[setting :refer [defsetting]] [setting :refer [defsetting]]
...@@ -23,19 +24,21 @@ ...@@ -23,19 +24,21 @@
[schema :as su]] [schema :as su]]
[schema.core :as s] [schema.core :as s]
[throttle.core :as throttle] [throttle.core :as throttle]
[toucan.db :as db])) [toucan.db :as db])
(:import com.unboundid.util.LDAPSDKException
java.util.UUID))
(defn- create-session! (s/defn ^:private create-session! :- UUID
"Generate a new `Session` for a given `User`. Returns the newly generated session ID." "Generate a new `Session` for a given `User`. Returns the newly generated session ID."
[user] [user :- {:id su/IntGreaterThanZero
{:pre [(map? user) (integer? (:id user)) (contains? user :last_login)] :last_login s/Any
:post [(string? %)]} s/Keyword s/Any}]
(u/prog1 (str (java.util.UUID/randomUUID)) (u/prog1 (UUID/randomUUID)
(db/insert! Session (db/insert! Session
:id <> :id (str <>)
:user_id (:id user)) :user_id (:id user))
(events/publish-event! :user-login (events/publish-event! :user-login
{:user_id (:id user), :session_id <>, :first_login (not (boolean (:last_login user)))}))) {:user_id (:id user), :session_id (str <>), :first_login (nil? (:last_login user))})))
;;; ## API Endpoints ;;; ## API Endpoints
...@@ -47,7 +50,7 @@ ...@@ -47,7 +50,7 @@
(def ^:private password-fail-message (tru "Password did not match stored password.")) (def ^:private password-fail-message (tru "Password did not match stored password."))
(def ^:private password-fail-snippet (tru "did not match stored password")) (def ^:private password-fail-snippet (tru "did not match stored password"))
(defn- ldap-login (s/defn ^:private ldap-login :- (s/maybe UUID)
"If LDAP is enabled and a matching user exists return a new Session for them, or `nil` if they couldn't be "If LDAP is enabled and a matching user exists return a new Session for them, or `nil` if they couldn't be
authenticated." authenticated."
[username password] [username password]
...@@ -61,18 +64,16 @@ ...@@ -61,18 +64,16 @@
{:status-code 400 {:status-code 400
:errors {:password password-fail-snippet}}))) :errors {:password password-fail-snippet}})))
;; password is ok, return new session ;; password is ok, return new session
{:id (create-session! (ldap/fetch-or-create-user! user-info password))}) (create-session! (ldap/fetch-or-create-user! user-info password)))
(catch com.unboundid.util.LDAPSDKException e (catch LDAPSDKException e
(log/error (log/error e (trs "Problem connecting to LDAP server, will fall back to local authentication"))))))
(u/format-color 'red
(trs "Problem connecting to LDAP server, will fall back to local authentication: {0}" (.getMessage e))))))))
(defn- email-login (s/defn ^:private email-login :- (s/maybe UUID)
"Find a matching `User` if one exists and return a new Session for them, or `nil` if they couldn't be authenticated." "Find a matching `User` if one exists and return a new Session for them, or `nil` if they couldn't be authenticated."
[username password] [username password]
(when-let [user (db/select-one [User :id :password_salt :password :last_login], :email username, :is_active true)] (when-let [user (db/select-one [User :id :password_salt :password :last_login], :email username, :is_active true)]
(when (pass/verify-password password (:password_salt user) (:password user)) (when (pass/verify-password password (:password_salt user) (:password user))
{:id (create-session! user)}))) (create-session! user))))
(def ^:private throttling-disabled? (config/config-bool :mb-disable-session-throttle)) (def ^:private throttling-disabled? (config/config-bool :mb-disable-session-throttle))
...@@ -82,6 +83,20 @@ ...@@ -82,6 +83,20 @@
(when-not throttling-disabled? (when-not throttling-disabled?
(throttle/check throttler throttle-key))) (throttle/check throttler throttle-key)))
(s/defn ^:private login :- UUID
"Attempt to login with different avaialable methods with `username` and `password`, returning new Session ID or
throwing an Exception if login could not be completed."
[username :- su/NonBlankString, password :- su/NonBlankString]
;; Primitive "strategy implementation", should be reworked for modular providers in #3210
(or (ldap-login username password) ; First try LDAP if it's enabled
(email-login username password) ; Then try local authentication
;; If nothing succeeded complain about it
;; Don't leak whether the account doesn't exist or the password was incorrect
(throw
(ui18n/ex-info password-fail-message
{:status-code 400
:errors {:password password-fail-snippet}}))))
(api/defendpoint POST "/" (api/defendpoint POST "/"
"Login." "Login."
[:as {{:keys [username password]} :body, remote-address :remote-addr}] [:as {{:keys [username password]} :body, remote-address :remote-addr}]
...@@ -89,23 +104,17 @@ ...@@ -89,23 +104,17 @@
password su/NonBlankString} password su/NonBlankString}
(throttle-check (login-throttlers :ip-address) remote-address) (throttle-check (login-throttlers :ip-address) remote-address)
(throttle-check (login-throttlers :username) username) (throttle-check (login-throttlers :username) username)
;; Primitive "strategy implementation", should be reworked for modular providers in #3210 (let [session-id (login username password)
(or (ldap-login username password) ; First try LDAP if it's enabled response {:id session-id}]
(email-login username password) ; Then try local authentication (mw.session/set-session-cookie response session-id)))
;; If nothing succeeded complain about it
;; Don't leak whether the account doesn't exist or the password was incorrect
(throw (ui18n/ex-info password-fail-message
{:status-code 400
:errors {:password password-fail-snippet}}))))
(api/defendpoint DELETE "/" (api/defendpoint DELETE "/"
"Logout." "Logout."
[session_id] [:as {:keys [metabase-session-id]}]
{session_id su/NonBlankString} (api/check-exists? Session metabase-session-id)
(api/check-exists? Session session_id) (db/delete! Session :id metabase-session-id)
(db/delete! Session :id session_id) (mw.session/clear-session-cookie api/generic-204-no-content))
api/generic-204-no-content)
;; Reset tokens: We need some way to match a plaintext token with the a user since the token stored in the DB is ;; Reset tokens: We need some way to match a plaintext token with the a user since the token stored in the DB is
;; hashed. So we'll make the plaintext token in the format USER-ID_RANDOM-UUID, e.g. ;; hashed. So we'll make the plaintext token in the format USER-ID_RANDOM-UUID, e.g.
...@@ -231,12 +240,13 @@ ...@@ -231,12 +240,13 @@
;; things hairy and only enforce those for non-Google Auth users ;; things hairy and only enforce those for non-Google Auth users
(user/create-new-google-auth-user! new-user)) (user/create-new-google-auth-user! new-user))
(defn- google-auth-fetch-or-create-user! [first-name last-name email] (s/defn ^:private google-auth-fetch-or-create-user! :- (s/maybe UUID)
(if-let [user (or (db/select-one [User :id :last_login] :email email) [first-name last-name email]
(google-auth-create-new-user! {:first_name first-name (when-let [user (or (db/select-one [User :id :last_login] :email email)
:last_name last-name (google-auth-create-new-user! {:first_name first-name
:email email}))] :last_name last-name
{:id (create-session! user)})) :email email}))]
(create-session! user)))
(api/defendpoint POST "/google_auth" (api/defendpoint POST "/google_auth"
"Login with Google Auth." "Login with Google Auth."
...@@ -246,7 +256,9 @@ ...@@ -246,7 +256,9 @@
;; Verify the token is valid with Google ;; Verify the token is valid with Google
(let [{:keys [given_name family_name email]} (google-auth-token-info token)] (let [{:keys [given_name family_name email]} (google-auth-token-info token)]
(log/info (trs "Successfully authenticated Google Auth token for: {0} {1}" given_name family_name)) (log/info (trs "Successfully authenticated Google Auth token for: {0} {1}" given_name family_name))
(google-auth-fetch-or-create-user! given_name family_name email))) (let [session-id (api/check-500 (google-auth-fetch-or-create-user! given_name family_name email))
response {:id session-id}]
(mw.session/set-session-cookie response session-id))))
(api/define-routes) (api/define-routes)
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
[common :as api] [common :as api]
[database :as database-api :refer [DBEngineString]]] [database :as database-api :refer [DBEngineString]]]
[metabase.integrations.slack :as slack] [metabase.integrations.slack :as slack]
[metabase.middleware.session :as mw.session]
[metabase.models [metabase.models
[database :refer [Database]] [database :refer [Database]]
[session :refer [Session]] [session :refer [Session]]
...@@ -18,7 +19,8 @@ ...@@ -18,7 +19,8 @@
[i18n :refer [tru]] [i18n :refer [tru]]
[schema :as su]] [schema :as su]]
[schema.core :as s] [schema.core :as s]
[toucan.db :as db])) [toucan.db :as db])
(:import java.util.UUID))
(def ^:private SetupToken (def ^:private SetupToken
"Schema for a string that matches the instance setup token." "Schema for a string that matches the instance setup token."
...@@ -42,12 +44,12 @@ ...@@ -42,12 +44,12 @@
allow_tracking (s/maybe (s/cond-pre s/Bool su/BooleanString)) allow_tracking (s/maybe (s/cond-pre s/Bool su/BooleanString))
schedules (s/maybe database-api/ExpandedSchedulesMap)} schedules (s/maybe database-api/ExpandedSchedulesMap)}
;; Now create the user ;; Now create the user
(let [session-id (str (java.util.UUID/randomUUID)) (let [session-id (UUID/randomUUID)
new-user (db/insert! User new-user (db/insert! User
:email email :email email
:first_name first_name :first_name first_name
:last_name last_name :last_name last_name
:password (str (java.util.UUID/randomUUID)) :password (str (UUID/randomUUID))
:is_superuser true)] :is_superuser true)]
;; this results in a second db call, but it avoids redundant password code so figure it's worth it ;; this results in a second db call, but it avoids redundant password code so figure it's worth it
(user/set-password! (:id new-user) password) (user/set-password! (:id new-user) password)
...@@ -75,12 +77,12 @@ ...@@ -75,12 +77,12 @@
(setup/clear-token!) (setup/clear-token!)
;; then we create a session right away because we want our new user logged in to continue the setup process ;; then we create a session right away because we want our new user logged in to continue the setup process
(db/insert! Session (db/insert! Session
:id session-id :id (str session-id)
:user_id (:id new-user)) :user_id (:id new-user))
;; notify that we've got a new user in the system AND that this user logged in ;; notify that we've got a new user in the system AND that this user logged in
(events/publish-event! :user-create {:user_id (:id new-user)}) (events/publish-event! :user-create {:user_id (:id new-user)})
(events/publish-event! :user-login {:user_id (:id new-user), :session_id session-id, :first_login true}) (events/publish-event! :user-login {:user_id (:id new-user), :session_id (str session-id), :first_login true})
{:id session-id})) (mw.session/set-session-cookie {:id (str session-id)} session-id)))
(api/defendpoint POST "/validate" (api/defendpoint POST "/validate"
......
...@@ -237,7 +237,8 @@ ...@@ -237,7 +237,8 @@
(let [reset-token (user/set-password-reset-token! id) (let [reset-token (user/set-password-reset-token! id)
;; NOTE: the new user join url is just a password reset with an indicator that this is a first time user ;; NOTE: the new user join url is just a password reset with an indicator that this is a first time user
join-url (str (user/form-password-reset-url reset-token) "#new")] join-url (str (user/form-password-reset-url reset-token) "#new")]
(email/send-new-user-email! user @api/*current-user* join-url)))) (email/send-new-user-email! user @api/*current-user* join-url)))
{:success true})
(api/define-routes) (api/define-routes)
...@@ -2,16 +2,46 @@ ...@@ -2,16 +2,46 @@
"Ring middleware related to session (binding current user and permissions)." "Ring middleware related to session (binding current user and permissions)."
(:require [metabase (:require [metabase
[config :as config] [config :as config]
[db :as mdb]] [db :as mdb]
[public-settings :as public-settings]]
[metabase.api.common :refer [*current-user* *current-user-id* *current-user-permissions-set* *is-superuser?*]] [metabase.api.common :refer [*current-user* *current-user-id* *current-user-permissions-set* *is-superuser?*]]
[metabase.core.initialization-status :as init-status] [metabase.core.initialization-status :as init-status]
[metabase.models [metabase.models
[session :refer [Session]] [session :refer [Session]]
[user :as user :refer [User]]] [user :as user :refer [User]]]
[toucan.db :as db])) [ring.util.response :as resp]
[schema.core :as s]
(def ^:private ^:const ^String metabase-session-cookie "metabase.SESSION_ID") [toucan.db :as db])
(def ^:private ^:const ^String metabase-session-header "x-metabase-session") (:import java.net.URL
java.util.UUID))
(def ^:private ^String metabase-session-cookie "metabase.SESSION_ID")
(def ^:private ^String metabase-session-header "x-metabase-session")
(s/defn set-session-cookie
"Add a `Set-Cookie` header to `response` to persist the Metabase session."
[response, session-id :- UUID]
(if-not (and (map? response) (:body response))
(recur {:body response, :status 200} session-id)
(resp/set-cookie
response
metabase-session-cookie
(str session-id)
(merge
{:same-site :lax
:http-only true
:path "/api"
:max-age (config/config-int :max-session-age)}
;; If Metabase is running over HTTPS (hopefully always except for local dev instances) then make sure to make this
;; cookie HTTPS-only
(when (some-> (public-settings/site-url) URL. .getProtocol (= "https"))
{:secure true})))))
(defn clear-session-cookie
"Add a header to `response` to clear the current Metabase session cookie."
[response]
(println "response:" response "->" (resp/set-cookie response nil {:expires "01 Jan 1970 00:00:00 GMT"})) ; NOCOMMIT
#_(resp/set-cookie response nil {:expires "01 Jan 1970 00:00:00 GMT"}))
(defn- wrap-session-id* [{:keys [cookies headers] :as request}] (defn- wrap-session-id* [{:keys [cookies headers] :as request}]
(if-let [session-id (or (get-in cookies [metabase-session-cookie :value]) (if-let [session-id (or (get-in cookies [metabase-session-cookie :value])
......
...@@ -2,12 +2,14 @@ ...@@ -2,12 +2,14 @@
"HTTP client for making API calls against the Metabase API. For test/REPL purposes." "HTTP client for making API calls against the Metabase API. For test/REPL purposes."
(:require [cheshire.core :as json] (:require [cheshire.core :as json]
[clj-http.client :as client] [clj-http.client :as client]
[clojure.string :as s] [clojure.string :as str]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[metabase [metabase
[config :as config] [config :as config]
[util :as u]] [util :as u]]
[metabase.util.date :as du])) [metabase.middleware.session :as mw.session]
[metabase.util.date :as du]
[schema.core :as s]))
;;; build-url ;;; build-url
...@@ -22,7 +24,7 @@ ...@@ -22,7 +24,7 @@
[url url-param-kwargs] [url url-param-kwargs]
{:pre [(string? url) (u/maybe? map? url-param-kwargs)]} {:pre [(string? url) (u/maybe? map? url-param-kwargs)]}
(str *url-prefix* url (when (seq url-param-kwargs) (str *url-prefix* url (when (seq url-param-kwargs)
(str "?" (s/join \& (for [[k v] url-param-kwargs] (str "?" (str/join \& (for [[k v] url-param-kwargs]
(str (if (keyword? k) (name k) k) (str (if (keyword? k) (name k) k)
\= \=
(if (keyword? v) (name v) v)))))))) (if (keyword? v) (name v) v))))))))
...@@ -56,7 +58,7 @@ ...@@ -56,7 +58,7 @@
(try (try
(auto-deserialize-dates (json/parse-string body keyword)) (auto-deserialize-dates (json/parse-string body keyword))
(catch Throwable _ (catch Throwable _
(when-not (s/blank? body) (when-not (str/blank? body)
body))))) body)))))
...@@ -64,15 +66,14 @@ ...@@ -64,15 +66,14 @@
(declare client) (declare client)
(defn authenticate (s/defn authenticate
"Authenticate a test user with USERNAME and PASSWORD, returning their Metabase Session token; "Authenticate a test user with USERNAME and PASSWORD, returning their Metabase Session token;
or throw an Exception if that fails." or throw an Exception if that fails."
[{:keys [username password], :as credentials}] [credentials :- {:username s/Str, :password s/Str}]
{:pre [(string? username) (string? password)]}
(try (try
(:id (client :post 200 "session" credentials)) (:id (client :post 200 "session" credentials))
(catch Throwable e (catch Throwable e
(println "Failed to authenticate with username:" username "and password:" password ":" (.getMessage e))))) (println "Failed to authenticate with credentials" credentials e))))
;;; client ;;; client
...@@ -80,10 +81,11 @@ ...@@ -80,10 +81,11 @@
(defn- build-request-map [credentials http-body] (defn- build-request-map [credentials http-body]
(merge (merge
{:accept :json {:accept :json
:headers {"X-METABASE-SESSION" (when credentials :headers {@#'mw.session/metabase-session-header
(if (map? credentials) (when credentials
(authenticate credentials) (if (map? credentials)
credentials))} (authenticate credentials)
credentials))}
:content-type :json} :content-type :json}
(when (seq http-body) (when (seq http-body)
{:body (json/generate-string http-body)}))) {:body (json/generate-string http-body)})))
...@@ -119,7 +121,7 @@ ...@@ -119,7 +121,7 @@
(let [request-map (merge (build-request-map credentials http-body) request-options) (let [request-map (merge (build-request-map credentials http-body) request-options)
request-fn (method->request-fn method) request-fn (method->request-fn method)
url (build-url url url-param-kwargs) url (build-url url url-param-kwargs)
method-name (s/upper-case (name method)) method-name (str/upper-case (name method))
;; Now perform the HTTP request ;; Now perform the HTTP request
{:keys [status body] :as resp} (try (request-fn url request-map) {:keys [status body] :as resp} (try (request-fn url request-map)
(catch clojure.lang.ExceptionInfo e (catch clojure.lang.ExceptionInfo e
......
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