From 7015c8eae96883bb40e9fa112453ad16f6f32554 Mon Sep 17 00:00:00 2001
From: William Turner <william.turner@aero.bombardier.com>
Date: Wed, 29 Mar 2017 14:25:11 -0400
Subject: [PATCH] Adds settings validation and connection testing

---
 src/metabase/api/ldap.clj                | 74 +++++++++++++++++++++++-
 src/metabase/api/session.clj             |  2 +-
 src/metabase/integrations/ldap.clj       | 65 ++++++++++++++++-----
 test/metabase/integrations/ldap_test.clj | 39 +++++++++++--
 test/metabase/test/integrations/ldap.clj |  7 +--
 5 files changed, 158 insertions(+), 29 deletions(-)

diff --git a/src/metabase/api/ldap.clj b/src/metabase/api/ldap.clj
index 73e109aa279..3e15fa26846 100644
--- a/src/metabase/api/ldap.clj
+++ b/src/metabase/api/ldap.clj
@@ -1,16 +1,84 @@
 (ns metabase.api.ldap
   "/api/ldap endpoints"
-  (:require [compojure.core :refer [PUT]]
+  (:require [clojure.tools.logging :as log]
+            [clojure.set :as set]
+            [compojure.core :refer [PUT]]
             [metabase.api.common :refer :all]
+            [metabase.config :as config]
+            [metabase.integrations.ldap :as ldap]
             [metabase.models.setting :as setting]
             [metabase.util.schema :as su]))
 
+(def ^:private ^:const mb-settings->ldap-details
+  {:ldap-enabled             :enabled
+   :ldap-host                :host
+   :ldap-port                :port
+   :ldap-bind-dn             :bind-dn
+   :ldap-password            :password
+   :ldap-security            :security
+   :ldap-base                :base
+   :ldap-user-filter         :user-filter
+   :ldap-attribute-email     :attribute-email
+   :ldap-attribute-firstname :attribute-firstname
+   :ldap-attribute-lastname  :attribute-lastname})
+
+(defn- humanize-error-messages
+  "Convert raw error message responses from our LDAP tests into our normal api error response structure."
+  [{:keys [status message]}]
+  (println message)
+  (when (not= :SUCCESS status)
+    (log/warn "Problem connecting to LDAP server:" message)
+    (let [conn-error     {:errors {:ldap-host "Wrong host or port"
+                                   :ldap-port "Wrong host or port"}}
+          security-error {:errors {:ldap-port     "Wrong port or security setting"
+                                   :ldap-security "Wrong port or security setting"}}
+          creds-error    {:errors {:ldap-bind-dn  "Wrong bind DN or password"
+                                   :ldap-password "Wrong bind DN or password"}}
+          base-error     {:errors {:ldap-base "Search base does not exist or is unreadable"}}]
+      (condp re-matches message
+        #".*UnknownHostException.*"
+        conn-error
+
+        #".*ConnectException.*"
+        conn-error
+
+        #".*SocketException.*"
+        security-error
+
+        #"^80090308:.*"
+        creds-error
+
+        #"^Unable to bind as user .*"
+        creds-error
+
+        #"(?s)^0000202B:.*"
+        base-error
+
+        #"^Search base does not exist .*"
+        base-error
+
+        ;; everything else :(
+        #"(?s).*"
+        {:message "Sorry, something went wrong. Please try again."}))))
+
 (defendpoint PUT "/settings"
   "Update LDAP related settings. You must be a superuser to do this."
   [:as {settings :body}]
   {settings su/Map}
   (check-superuser)
-  (setting/set-many! settings)
-  {:ok true})
+  (let [ldap-settings (select-keys settings (keys mb-settings->ldap-details))
+        ldap-details  (-> (set/rename-keys ldap-settings mb-settings->ldap-details)
+                          (assoc :port (Integer/parseInt (:ldap-port settings))))
+        results       (if (or config/is-test? (not (:ldap-enabled settings)))
+                        ;; for unit testing or disabled status just respond with a success message
+                        {:status :SUCCESS}
+                        ;; in normal conditions, validate connection
+                        (ldap/test-ldap-connection ldap-details))]
+    (if (= :SUCCESS (:status results))
+      ;; test was good, save our settings
+      (setting/set-many! ldap-settings)
+      ;; test failed, return result message
+      {:status 500
+       :body   (humanize-error-messages results)})))
 
 (define-routes)
diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj
index 6513b5a1aa5..3fa8592d55f 100644
--- a/src/metabase/api/session.clj
+++ b/src/metabase/api/session.clj
@@ -64,7 +64,7 @@
             (throw (ex-info "Password did not match stored password." {:status-code 400
                                                                        :errors      {:password "did not match stored password"}}))))
         (catch com.unboundid.util.LDAPSDKException e
-          (log/error (u/format-color 'red "Unexpected LDAP error, will fallback to local authentication") (.getMessage e)))))
+          (log/error (u/format-color 'red "Problem connecting to LDAP server, will fallback to local authentication") (.getMessage e)))))
 
     ;; Then try local authentication
     (when-let [user (db/select-one [User :id :password_salt :password :last_login], :email username, :is_active true)]
diff --git a/src/metabase/integrations/ldap.clj b/src/metabase/integrations/ldap.clj
index 8d716b18792..9b210bcafaf 100644
--- a/src/metabase/integrations/ldap.clj
+++ b/src/metabase/integrations/ldap.clj
@@ -9,7 +9,7 @@
   :default false)
 
 (defsetting ldap-host
-  "LDAP server hostname.")
+  "Server hostname.")
 
 (defsetting ldap-port
   "Server port, usually 389 or 636 if SSL is used."
@@ -57,19 +57,49 @@
        (boolean (ldap-password))
        (boolean (ldap-base))))
 
+(defn- details->ldap-options [{:keys [host port bind-dn password security]}]
+  {:host      (str host ":" port)
+   :bind-dn   bind-dn
+   :password  password
+   :ssl?      (= security "ssl")
+   :startTLS? (= security "starttls")})
+
+(defn- settings->ldap-options []
+  (details->ldap-options {:host      (ldap-host)
+                          :port      (ldap-port)
+                          :bind-dn   (ldap-bind-dn)
+                          :password  (ldap-password)
+                          :security  (ldap-security)}))
+
+(defn test-ldap-connection
+  "Test the connection to an LDAP server to determine if we can find the search base.
+
+   Takes in a dictionary of properties such as:
+       {:host     \"localhost\"
+        :port     389
+        :bind-dn  \"cn=Directory Manager\"
+        :password \"password\"
+        :security \"none\"
+        :base     \"ou=people,dc=metabase,dc=com\"}"
+  [{:keys [base], :as details}]
+  (try
+    (with-open [conn (ldap/connect (details->ldap-options details))]
+      (if-let [_ (ldap/get conn base)]
+        {:status  :SUCCESS}
+        {:status  :ERROR
+         :message "Search base does not exist or is unreadable"}))
+    (catch com.unboundid.util.LDAPSDKException e
+      {:status  :ERROR
+       :message (.getMessage e)})))
+
 (defn- get-ldap-connection []
-  (ldap/connect {:host      (str (ldap-host) ":" (ldap-port))
-                 :bind-dn   (ldap-bind-dn)
-                 :password  (ldap-password)
-                 :ssl?      (= (ldap-security) "ssl")
-                 :startTLS? (= (ldap-security) "starttls")}))
+  "Connects to LDAP with the currently set settings and returns the connection."
+  (ldap/connect (settings->ldap-options)))
 
 (defn- with-connection [f & args]
   "Applies `f` with a connection pool followed by `args`"
-  (let [conn (get-ldap-connection)]
-    (try
-      (apply f conn args)
-      (finally (ldap/close conn)))))
+  (with-open [conn (get-ldap-connection)]
+    (apply f conn args)))
 
 (defn- escape-value [value]
   "Escapes a value for use in an LDAP filter expression."
@@ -87,11 +117,16 @@
                                                          :filter     (s/replace (ldap-user-filter) "{login}" (escape-value username))
                                                          :attributes [:dn :distinguishedName :membderOf fname-attr lname-attr email-attr]
                                                          :size-limit 1})]
-        {:dn         (or (:dn result) (:distinguishedName result))
-         :first-name (get result fname-attr)
-         :last-name  (get result lname-attr)
-         :email      (get result email-attr)
-         :groups     (or (:membderOf result) [])}))))
+        (let [dn    (or (:dn result) (:distinguishedName result))
+              fname (get result fname-attr)
+              lname (get result lname-attr)
+              email (get result email-attr)]
+          (when-not (or (empty? dn) (empty? fname) (empty? lname) (empty? email))
+            {:dn         dn
+             :first-name fname
+             :last-name  lname
+             :email      email
+             :groups     (or (:membderOf result) [])}))))))
 
 (defn verify-password
   "Verifies if the password supplied is valid for the supplied `user-info` (from `find-user`) or DN."
diff --git a/test/metabase/integrations/ldap_test.clj b/test/metabase/integrations/ldap_test.clj
index 6587d0f8ca3..5b4090cd888 100644
--- a/test/metabase/integrations/ldap_test.clj
+++ b/test/metabase/integrations/ldap_test.clj
@@ -2,17 +2,48 @@
   (:require [expectations :refer :all]
             [metabase.integrations.ldap :as ldap]
             (metabase.test [util :refer [resolve-private-vars]])
-            (metabase.test.integrations [ldap :refer [expect-with-ldap-server]])))
+            (metabase.test.integrations [ldap :refer [expect-with-ldap-server get-ldap-port]])))
 
-(resolve-private-vars metabase.integrations.ldap escape-value get-ldap-connection)
+(resolve-private-vars metabase.integrations.ldap escape-value settings->ldap-options get-ldap-connection)
 
 
+(defn- get-ldap-details []
+  {:host     "localhost"
+   :port     (get-ldap-port)
+   :bind-dn  "cn=Directory Manager"
+   :password "password"
+   :security "none"
+   :base     "dc=metabase,dc=com"})
+
 ;; See test_resources/ldap.ldif for fixtures
 
 (expect
   "\\2AJohn \\28Dude\\29 Doe\\5C"
   (escape-value "*John (Dude) Doe\\"))
 
+;; The connection test should pass with valid settings
+(expect-with-ldap-server
+  {:status :SUCCESS}
+  (ldap/test-ldap-connection (get-ldap-details)))
+
+;; The connection test should fail with an invalid search base
+(expect-with-ldap-server
+  {:status  :ERROR
+   :message "Search base does not exist or is unreadable"}
+  (ldap/test-ldap-connection (assoc (get-ldap-details) :base "dc=example,dc=com")))
+
+;; The connection test should fail with an invalid bind DN
+(expect-with-ldap-server
+  {:status  :ERROR
+   :message "Unable to bind as user 'cn=Not Directory Manager' because no such entry exists in the server."}
+  (ldap/test-ldap-connection (assoc (get-ldap-details) :bind-dn "cn=Not Directory Manager")))
+
+;; The connection test should fail with an invalid bind password
+(expect-with-ldap-server
+  {:status  :ERROR
+   :message "Unable to bind as user 'cn=Directory Manager' because the provided password was incorrect."}
+  (ldap/test-ldap-connection (assoc (get-ldap-details) :password "wrong")))
+
 ;; Make sure the basic connection stuff works, this will throw otherwise
 (expect-with-ldap-server
   nil
@@ -31,7 +62,7 @@
 ;; Login with invalid DN should fail
 (expect-with-ldap-server
   false
-  (ldap/verify-password "cn=Nobody,ou=people,dc=metabase,dc=com" "password"))
+  (ldap/verify-password "cn=Nobody,ou=nowhere,dc=metabase,dc=com" "password"))
 
 ;; Login for regular users should also work
 (expect-with-ldap-server
@@ -52,7 +83,7 @@
    :groups     []}
   (ldap/find-user "sbrown20"))
 
-;; Find by email should also work (also given our test setup)
+;; Find by email should also work (also given our default settings and fixtures)
 (expect-with-ldap-server
   {:dn         "cn=Sally Brown,ou=people,dc=metabase,dc=com"
    :first-name "Sally"
diff --git a/test/metabase/test/integrations/ldap.clj b/test/metabase/test/integrations/ldap.clj
index b92e1bc8397..bd11be205fc 100644
--- a/test/metabase/test/integrations/ldap.clj
+++ b/test/metabase/test/integrations/ldap.clj
@@ -32,11 +32,6 @@
   []
   (.getListenPort *ldap-server*))
 
-(defn get-ldap-base
-  "Get the base DN for the bound in-memory LDAP testing server."
-  []
-  (.toNormalizedString (first (.getBaseDNs *ldap-server*))))
-
 (defn do-with-ldap-server
   "Bind `*ldap-server*` and the relevant settings to an in-memory LDAP testing server and executes `f`."
   [f]
@@ -47,7 +42,7 @@
                                          ldap-port     (str (get-ldap-port))
                                          ldap-bind-dn  "cn=Directory Manager"
                                          ldap-password "password"
-                                         ldap-base     (get-ldap-base)]
+                                         ldap-base     "dc=metabase,dc=com"]
         (f))
       (finally (.shutDown *ldap-server* true)))))
 
-- 
GitLab