diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index ecc1829f2f37f29c6b523bff6c193f65646f58fe..618cadd91551c7f2bfd48d2fb745864a4d601a67 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -13,6 +13,7 @@ [metabase.api.common :as api] [metabase.email.messages :as email] [metabase.integrations.ldap :as ldap] + [metabase.middleware.session :as mw.session] [metabase.models [session :refer [Session]] [setting :refer [defsetting]] @@ -23,19 +24,21 @@ [schema :as su]] [schema.core :as s] [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." - [user] - {:pre [(map? user) (integer? (:id user)) (contains? user :last_login)] - :post [(string? %)]} - (u/prog1 (str (java.util.UUID/randomUUID)) + [user :- {:id su/IntGreaterThanZero + :last_login s/Any + s/Keyword s/Any}] + (u/prog1 (UUID/randomUUID) (db/insert! Session - :id <> + :id (str <>) :user_id (:id user)) (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 @@ -47,7 +50,7 @@ (def ^:private password-fail-message (tru "Password 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 authenticated." [username password] @@ -61,18 +64,16 @@ {:status-code 400 :errors {:password password-fail-snippet}}))) ;; password is ok, return new session - {:id (create-session! (ldap/fetch-or-create-user! user-info password))}) - (catch com.unboundid.util.LDAPSDKException e - (log/error - (u/format-color 'red - (trs "Problem connecting to LDAP server, will fall back to local authentication: {0}" (.getMessage e)))))))) + (create-session! (ldap/fetch-or-create-user! user-info password))) + (catch LDAPSDKException e + (log/error e (trs "Problem connecting to LDAP server, will fall back to local authentication")))))) -(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." [username password] (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)) - {:id (create-session! user)}))) + (create-session! user)))) (def ^:private throttling-disabled? (config/config-bool :mb-disable-session-throttle)) @@ -82,6 +83,20 @@ (when-not throttling-disabled? (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 "/" "Login." [:as {{:keys [username password]} :body, remote-address :remote-addr}] @@ -89,23 +104,17 @@ password su/NonBlankString} (throttle-check (login-throttlers :ip-address) remote-address) (throttle-check (login-throttlers :username) username) - ;; 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}})))) + (let [session-id (login username password) + response {:id session-id}] + (mw.session/set-session-cookie response session-id))) (api/defendpoint DELETE "/" "Logout." - [session_id] - {session_id su/NonBlankString} - (api/check-exists? Session session_id) - (db/delete! Session :id session_id) - api/generic-204-no-content) + [:as {:keys [metabase-session-id]}] + (api/check-exists? Session metabase-session-id) + (db/delete! Session :id metabase-session-id) + (mw.session/clear-session-cookie 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 ;; hashed. So we'll make the plaintext token in the format USER-ID_RANDOM-UUID, e.g. @@ -231,12 +240,13 @@ ;; things hairy and only enforce those for non-Google Auth users (user/create-new-google-auth-user! new-user)) -(defn- google-auth-fetch-or-create-user! [first-name last-name email] - (if-let [user (or (db/select-one [User :id :last_login] :email email) - (google-auth-create-new-user! {:first_name first-name - :last_name last-name - :email email}))] - {:id (create-session! user)})) +(s/defn ^:private google-auth-fetch-or-create-user! :- (s/maybe UUID) + [first-name last-name email] + (when-let [user (or (db/select-one [User :id :last_login] :email email) + (google-auth-create-new-user! {:first_name first-name + :last_name last-name + :email email}))] + (create-session! user))) (api/defendpoint POST "/google_auth" "Login with Google Auth." @@ -246,7 +256,9 @@ ;; Verify the token is valid with Google (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)) - (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) diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index bdb50027f84be58db833a36e47d6ebf3f5022475..a76b677f6c89744801b3051b74625952882b37af 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -10,6 +10,7 @@ [common :as api] [database :as database-api :refer [DBEngineString]]] [metabase.integrations.slack :as slack] + [metabase.middleware.session :as mw.session] [metabase.models [database :refer [Database]] [session :refer [Session]] @@ -18,7 +19,8 @@ [i18n :refer [tru]] [schema :as su]] [schema.core :as s] - [toucan.db :as db])) + [toucan.db :as db]) + (:import java.util.UUID)) (def ^:private SetupToken "Schema for a string that matches the instance setup token." @@ -42,12 +44,12 @@ allow_tracking (s/maybe (s/cond-pre s/Bool su/BooleanString)) schedules (s/maybe database-api/ExpandedSchedulesMap)} ;; Now create the user - (let [session-id (str (java.util.UUID/randomUUID)) + (let [session-id (UUID/randomUUID) new-user (db/insert! User :email email :first_name first_name :last_name last_name - :password (str (java.util.UUID/randomUUID)) + :password (str (UUID/randomUUID)) :is_superuser true)] ;; 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) @@ -75,12 +77,12 @@ (setup/clear-token!) ;; then we create a session right away because we want our new user logged in to continue the setup process (db/insert! Session - :id session-id + :id (str session-id) :user_id (:id new-user)) ;; 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-login {:user_id (:id new-user), :session_id session-id, :first_login true}) - {:id session-id})) + (events/publish-event! :user-login {:user_id (:id new-user), :session_id (str session-id), :first_login true}) + (mw.session/set-session-cookie {:id (str session-id)} session-id))) (api/defendpoint POST "/validate" diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index 8285ce70bf03537f1ceb59d244984d84b345103d..d9816989b98fcdad74583f324e0e6728c300d4ee 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -237,7 +237,8 @@ (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 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) diff --git a/src/metabase/middleware/session.clj b/src/metabase/middleware/session.clj index f35f9cac0dcf17a8689e81cda642a3f8dee6b071..c47a2c5418d0d09851a51ce3fdf3404081af4df9 100644 --- a/src/metabase/middleware/session.clj +++ b/src/metabase/middleware/session.clj @@ -2,16 +2,46 @@ "Ring middleware related to session (binding current user and permissions)." (:require [metabase [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.core.initialization-status :as init-status] [metabase.models [session :refer [Session]] [user :as user :refer [User]]] - [toucan.db :as db])) - -(def ^:private ^:const ^String metabase-session-cookie "metabase.SESSION_ID") -(def ^:private ^:const ^String metabase-session-header "x-metabase-session") + [ring.util.response :as resp] + [schema.core :as s] + [toucan.db :as db]) + (: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}] (if-let [session-id (or (get-in cookies [metabase-session-cookie :value]) diff --git a/test/metabase/http_client.clj b/test/metabase/http_client.clj index 09b79f6df0a1d1323e2c177549238f47f92d47ab..6683792441936c70b6c7d122be1aa16ca3890749 100644 --- a/test/metabase/http_client.clj +++ b/test/metabase/http_client.clj @@ -2,12 +2,14 @@ "HTTP client for making API calls against the Metabase API. For test/REPL purposes." (:require [cheshire.core :as json] [clj-http.client :as client] - [clojure.string :as s] + [clojure.string :as str] [clojure.tools.logging :as log] [metabase [config :as config] [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 @@ -22,7 +24,7 @@ [url url-param-kwargs] {:pre [(string? url) (u/maybe? map? 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) \= (if (keyword? v) (name v) v)))))))) @@ -56,7 +58,7 @@ (try (auto-deserialize-dates (json/parse-string body keyword)) (catch Throwable _ - (when-not (s/blank? body) + (when-not (str/blank? body) body))))) @@ -64,15 +66,14 @@ (declare client) -(defn authenticate +(s/defn authenticate "Authenticate a test user with USERNAME and PASSWORD, returning their Metabase Session token; or throw an Exception if that fails." - [{:keys [username password], :as credentials}] - {:pre [(string? username) (string? password)]} + [credentials :- {:username s/Str, :password s/Str}] (try (:id (client :post 200 "session" credentials)) (catch Throwable e - (println "Failed to authenticate with username:" username "and password:" password ":" (.getMessage e))))) + (println "Failed to authenticate with credentials" credentials e)))) ;;; client @@ -80,10 +81,11 @@ (defn- build-request-map [credentials http-body] (merge {:accept :json - :headers {"X-METABASE-SESSION" (when credentials - (if (map? credentials) - (authenticate credentials) - credentials))} + :headers {@#'mw.session/metabase-session-header + (when credentials + (if (map? credentials) + (authenticate credentials) + credentials))} :content-type :json} (when (seq http-body) {:body (json/generate-string http-body)}))) @@ -119,7 +121,7 @@ (let [request-map (merge (build-request-map credentials http-body) request-options) request-fn (method->request-fn method) 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 {:keys [status body] :as resp} (try (request-fn url request-map) (catch clojure.lang.ExceptionInfo e