From 45423ccaa2a1ffb6d436f285fe479e0c925fc4ca Mon Sep 17 00:00:00 2001
From: Cam Saul <1455846+camsaul@users.noreply.github.com>
Date: Wed, 26 Oct 2022 16:40:58 -0700
Subject: [PATCH] Config from file: fix User upsert not hashing password
 (#26105)

* Config from file: fix User upsert not hashing password

* Move metabase.config.file code => enterprise directory

* Support setting Settings in config.yml

* Setting name has to be a keyword since that's how YAML gets parsed

* Test fix :wrench:

* Fix Kondo error

* Fix Kondo errors

* Remove unused namespace
---
 .../config_from_file/users_test.clj           | 38 ++++++---
 src/metabase/models/user.clj                  | 81 ++++++++++---------
 test/metabase/integrations/google_test.clj    | 17 ++--
 test/metabase/models/user_test.clj            | 30 +++++++
 4 files changed, 109 insertions(+), 57 deletions(-)

diff --git a/enterprise/backend/test/metabase_enterprise/config_from_file/users_test.clj b/enterprise/backend/test/metabase_enterprise/config_from_file/users_test.clj
index 60fc6c8c76b..44524094ef1 100644
--- a/enterprise/backend/test/metabase_enterprise/config_from_file/users_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/config_from_file/users_test.clj
@@ -25,19 +25,31 @@
         (is (= 1
                (db/count User :email "cam+config-file-test@metabase.com"))))
       (testing "upsert if User already exists"
-        (binding [config-from-file/*config* {:version 1
-                                             :config  {:users [{:first_name "Cam"
-                                                                :last_name  "Saul"
-                                                                :email      "cam+config-file-test@metabase.com"
-                                                                :password   "2cans"}]}}]
-          (is (= :ok
-                 (config-from-file/initialize!)))
-          (is (= 1
-                 (db/count User :email "cam+config-file-test@metabase.com")))
-          (is (partial= {:first_name "Cam"
-                         :last_name  "Saul"
-                         :email      "cam+config-file-test@metabase.com"}
-                        (db/select-one User :email "cam+config-file-test@metabase.com"))))))
+        (let [hashed-password          (fn [] (db/select-one-field :password User :email "cam+config-file-test@metabase.com"))
+              salt                     (fn [] (db/select-one-field :password_salt User :email "cam+config-file-test@metabase.com"))
+              original-hashed-password (hashed-password)]
+          (binding [config-from-file/*config* {:version 1
+                                               :config  {:users [{:first_name "Cam"
+                                                                  :last_name  "Saul"
+                                                                  :email      "cam+config-file-test@metabase.com"
+                                                                  :password   "2cans"}]}}]
+            (is (= :ok
+                   (config-from-file/initialize!)))
+            (is (= 1
+                   (db/count User :email "cam+config-file-test@metabase.com")))
+            (is (partial= {:first_name "Cam"
+                           :last_name  "Saul"
+                           :email      "cam+config-file-test@metabase.com"}
+                          (db/select-one User :email "cam+config-file-test@metabase.com")))
+            (testing "Password should be hashed, but it should be a NEW HASH"
+              (let [new-hashed-password (hashed-password)]
+                (is (not= original-hashed-password
+                          new-hashed-password))
+                (testing "Password should not be saved as plaintext"
+                  (is (not= "2cans"
+                            new-hashed-password)))
+                (testing "Password should work correctly"
+                  (is (u.password/verify-password "2cans" (salt) new-hashed-password)))))))))
     (finally
       (db/delete! User :email "cam+config-file-test@metabase.com"))))
 
diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj
index 1c7f85572ac..47aedf2161a 100644
--- a/src/metabase/models/user.clj
+++ b/src/metabase/models/user.clj
@@ -28,34 +28,41 @@
 
 (models/defmodel User :core_user)
 
+(def ^:private insert-default-values
+  {:date_joined  :%now
+   :last_login   nil
+   :is_active    true
+   :is_superuser false})
+
+(defn- hashed-password-values
+  "When User `:password` is specified for an `INSERT` or `UPDATE`, add a new `:password_salt`, and hash the password."
+  [{:keys [password], :as user}]
+  (when password
+    (assert (not (:password_salt user))
+            ;; this is dev-facing so it doesn't need to be i18n'ed
+            "Don't try to pass an encrypted password to insert! or update!. Password encryption is handled by pre- methods.")
+    (let [salt (str (random-uuid))]
+      {:password_salt salt
+       :password      (u.password/hash-bcrypt (str salt password))})))
+
 (defn- pre-insert [{:keys [email password reset_token locale], :as user}]
   ;; these assertions aren't meant to be user-facing, the API endpoints should be validation these as well.
   (assert (u/email? email))
   (assert ((every-pred string? (complement str/blank?)) password))
-  (assert (not (:password_salt user))
-          "Don't try to pass an encrypted password to (insert! User). Password encryption is handled by pre-insert.")
   (when locale
     (assert (i18n/available-locale? locale)))
-  (let [salt     (str (UUID/randomUUID))
-        defaults {:date_joined  :%now
-                  :last_login   nil
-                  :is_active    true
-                  :is_superuser false}]
-    ;; always salt + encrypt the password before putting new User in the DB
-    ;; TODO - we should do password encryption in pre-update too instead of in the session code
-    (merge
-     defaults
-     user
-     {:password_salt salt
-      :password      (u.password/hash-bcrypt (str salt password))}
-     ;; lower-case the email before saving
-     {:email (u/lower-case-en email)}
-     ;; if there's a reset token encrypt that as well
-     (when reset_token
-       {:reset_token (u.password/hash-bcrypt reset_token)})
-     ;; normalize the locale
-     (when locale
-       {:locale (i18n/normalized-locale-string locale)}))))
+  (merge
+   insert-default-values
+   user
+   (hashed-password-values user)
+   ;; lower-case the email before saving
+   {:email (u/lower-case-en email)}
+   ;; if there's a reset token encrypt that as well
+   (when reset_token
+     {:reset_token (u.password/hash-bcrypt reset_token)})
+   ;; normalize the locale
+   (when locale
+     {:locale (i18n/normalized-locale-string locale)})))
 
 (defn- post-insert [{user-id :id, superuser? :is_superuser, :as user}]
   (u/prog1 user
@@ -84,9 +91,8 @@
         (db/insert! PermissionsGroupMembership
           :group_id (u/the-id (perms-group/admin))
           :user_id  id)
-        ;; don't use `delete!` here because that does the opposite and tries to update this user
-        ;; which leads to a stack overflow of calls between the two
-        ;; TODO - could we fix this issue by using `post-delete!`?
+        ;; don't use [[db/delete!]] here because that does the opposite and tries to update this user which leads to a
+        ;; stack overflow of calls between the two. TODO - could we fix this issue by using a `post-delete` method?
         (and (not superuser?)
              membership-exists?)
         (db/simple-delete! PermissionsGroupMembership
@@ -102,6 +108,7 @@
     (db/delete! 'PulseChannelRecipient :user_id id))
   ;; If we're setting the reset_token then encrypt it before it goes into the DB
   (cond-> user
+    true        (merge (hashed-password-values user))
     reset-token (update :reset_token u.password/hash-bcrypt)
     locale      (update :locale i18n/normalized-locale-string)
     email       (update :email u/lower-case-en)))
@@ -318,19 +325,21 @@
        (dissoc :password)
        (assoc :ldap_auth true))))
 
+;;; TODO -- it seems like maybe this should just be part of the [[pre-update]] logic whenever `:password` changes; then
+;;; we can remove this function altogether.
 (defn set-password!
-  "Updates the stored password for a specified `User` by hashing the password with a random salt."
+  "Update the stored password for a specified `User`; kill any existing Sessions and wipe any password reset tokens.
+
+  The password is automatically hashed with a random salt; this happens in [[hashed-password-values]] which is called
+  by [[pre-insert]] or [[pre-update]])"
   [user-id password]
-  (let [salt     (str (UUID/randomUUID))
-        password (u.password/hash-bcrypt (str salt password))]
-    ;; when changing/resetting the password, kill any existing sessions
-    (db/simple-delete! Session :user_id user-id)
-    ;; NOTE: any password change expires the password reset token
-    (db/update! User user-id
-      :password_salt   salt
-      :password        password
-      :reset_token     nil
-      :reset_triggered nil)))
+  ;; when changing/resetting the password, kill any existing sessions
+  (db/simple-delete! Session :user_id user-id)
+  ;; NOTE: any password change expires the password reset token
+  (db/update! User user-id
+    :password        password
+    :reset_token     nil
+    :reset_triggered nil))
 
 (defn set-password-reset-token!
   "Updates a given `User` and generates a password reset token for them to use. Returns the URL for password reset."
diff --git a/test/metabase/integrations/google_test.clj b/test/metabase/integrations/google_test.clj
index c8e1ea77d96..f0f8a0e0cfc 100644
--- a/test/metabase/integrations/google_test.clj
+++ b/test/metabase/integrations/google_test.clj
@@ -1,12 +1,13 @@
 (ns metabase.integrations.google-test
-  (:require [clojure.test :refer :all]
-            [metabase.email-test :as et]
-            [metabase.integrations.google :as google]
-            [metabase.integrations.google.interface :as google.i]
-            [metabase.models.user :refer [User]]
-            [metabase.public-settings.premium-features :as premium-features]
-            [metabase.test :as mt]
-            [toucan.db :as db]))
+  (:require
+   [clojure.test :refer :all]
+   [metabase.email-test :as et]
+   [metabase.integrations.google :as google]
+   [metabase.integrations.google.interface :as google.i]
+   [metabase.models.user :refer [User]]
+   [metabase.public-settings.premium-features :as premium-features]
+   [metabase.test :as mt]
+   [toucan.db :as db]))
 
 ;;; --------------------------------------------- google-auth-client-id ----------------------------------------------
 
diff --git a/test/metabase/models/user_test.clj b/test/metabase/models/user_test.clj
index b2eb0b6f4e0..67c2b2d2fae 100644
--- a/test/metabase/models/user_test.clj
+++ b/test/metabase/models/user_test.clj
@@ -4,6 +4,7 @@
    [clojure.string :as str]
    [clojure.test :refer :all]
    [metabase.http-client :as client]
+   [metabase.integrations.google]
    [metabase.models
     :refer [Collection
             Database
@@ -29,6 +30,10 @@
    [toucan.db :as db]
    [toucan.hydrate :refer [hydrate]]))
 
+(comment
+  ;; this has to be loaded for the Google Auth tests to work
+  metabase.integrations.google/keep-me)
+
 ;;; Tests for permissions-set
 
 (deftest check-test-users-have-valid-permissions-sets-test
@@ -476,3 +481,28 @@
       (is (= "e8d63472"
              (serdes.hash/raw-hash ["fred@flintston.es"])
              (serdes.hash/identity-hash user))))))
+
+(deftest hash-password-on-update-test
+  (testing "Setting `:password` with [[db/update!]] should hash the password, just like [[db/insert!]]"
+    (let [plaintext-password "password-1234"]
+      (mt/with-temp User [{user-id :id} {:password plaintext-password}]
+        (let [salt                     (fn [] (db/select-one-field :password_salt User :id user-id))
+              hashed-password          (fn [] (db/select-one-field :password User :id user-id))
+              original-hashed-password (hashed-password)]
+          (testing "sanity check: check that password can be verified"
+            (is (u.password/verify-password plaintext-password
+                                            (salt)
+                                            original-hashed-password)))
+          (is (= true
+                 (db/update! User user-id :password plaintext-password)))
+          (let [new-hashed-password (hashed-password)]
+            (testing "password should have been hashed"
+              (is (not= plaintext-password
+                        new-hashed-password)))
+            (testing "even tho the plaintext password is the same, hashed password should be different (different salts)"
+              (is (not= original-hashed-password
+                        new-hashed-password)))
+            (testing "salt should have been set; verify password was hashed correctly"
+              (is (u.password/verify-password plaintext-password
+                                              (salt)
+                                              new-hashed-password)))))))))
-- 
GitLab