diff --git a/CHANGES.md b/CHANGES.md index 0bfdb3e46..a8a3b9473 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,26 @@ ## :rocket: Next +### :boom: Breaking changes & Deprecations + +- The `PENPOT_LOGIN_WITH_LDAP` environment variable is finally removed (after + many version with deprecation). It is replaced with the + `enable-login-with-ldap` flag. +- The `PENPOT_LDAP_ATTRS_PHOTO` finally removed, it was unused for many + versions. +- If you are using social login (google, github, gitlab or generic OIDC) you + will need to ensure to add the following flags respectivelly to let them + enabled: `enable-login-with-google`, `enable-login-with-github`, + `enable-login-with-gitlab` and `enable-login-with-oidc`. If not, they will + remain disabled after application start independently if you set the client-id + and client-sectet options. +- The `PENPOT_REGISTRATION_ENABLED` is finally removed in favour of + `-registration` flag. +- The OIDC providers are now initialized synchronously, and if you are using the + discovery mechanism of the generic OIDC integration, the start time of the + application will depend on how fast the OIDC provider responds to the + discovery http request. + ### :sparkles: New features - Allow for nested and rotated boards inside other boards and groups [Taiga #2874](https://tree.taiga.io/project/penpot/us/2874?milestone=319982) diff --git a/backend/src/app/auth/ldap.clj b/backend/src/app/auth/ldap.clj new file mode 100644 index 000000000..f5042e6b1 --- /dev/null +++ b/backend/src/app/auth/ldap.clj @@ -0,0 +1,137 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.auth.ldap + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.spec :as us] + [app.config :as cf] + [clj-ldap.client :as ldap] + [clojure.spec.alpha :as s] + [clojure.string] + [integrant.core :as ig])) + +(defn- prepare-params + [cfg] + {:ssl? (:ssl cfg) + :startTLS? (:tls cfg) + :bind-dn (:bind-dn cfg) + :password (:bind-password cfg) + :host {:address (:host cfg) + :port (:port cfg)}}) + +(defn- connect + "Connects to the LDAP provider and returns a connection. An + exception is raised if no connection is possible." + ^java.lang.AutoCloseable + [cfg] + (try + (-> cfg prepare-params ldap/connect) + (catch Throwable cause + (ex/raise :type :restriction + :code :unable-to-connect-to-ldap + :hint "unable to connect to ldap server" + :cause cause)))) + +(defn- replace-several [s & {:as replacements}] + (reduce-kv clojure.string/replace s replacements)) + +(defn- search-user + [{:keys [conn attrs base-dn] :as cfg} email] + (let [query (replace-several (:query cfg) ":username" email) + params {:filter query + :sizelimit 1 + :attributes attrs}] + (first (ldap/search conn base-dn params)))) + +(defn- retrieve-user + [{:keys [conn] :as cfg} {:keys [email password]}] + (when-let [{:keys [dn] :as user} (search-user cfg email)] + (when (ldap/bind? conn dn password) + {:fullname (get user (-> cfg :attrs-fullname keyword)) + :email email + :backend "ldap"}))) + +(s/def ::fullname ::us/not-empty-string) +(s/def ::email ::us/email) +(s/def ::backend ::us/not-empty-string) + +(s/def ::info-data + (s/keys :req-un [::fullname ::email ::backend])) + +(defn authenticate + [cfg params] + (with-open [conn (connect cfg)] + (when-let [user (-> (assoc cfg :conn conn) + (retrieve-user params))] + (when-not (s/valid? ::info-data user) + (let [explain (s/explain-str ::info-data user)] + (l/warn ::l/raw (str "invalid response from ldap, looks like ldap is not configured correctly\n" explain)) + (ex/raise :type :restriction + :code :wrong-ldap-response + :explain explain))) + user))) + +(defn- try-connectivity + [cfg] + ;; If we have ldap parameters, try to establish connection + (when (and (:bind-dn cfg) + (:bind-password cfg) + (:host cfg) + (:port cfg)) + (try + (with-open [_ (connect cfg)] + (l/info :hint "provider initialized" + :provider "ldap" + :host (:host cfg) + :port (:port cfg) + :tls? (:tls cfg) + :ssl? (:ssl cfg) + :bind-dn (:bind-dn cfg) + :base-dn (:base-dn cfg) + :query (:query cfg)) + cfg) + (catch Throwable cause + (l/error :hint "unable to connect to LDAP server (LDAP auth provider disabled)" + :host (:host cfg) :port (:port cfg) :cause cause) + nil)))) + +(defn- prepare-attributes + [cfg] + (assoc cfg :attrs [(:attrs-username cfg) + (:attrs-email cfg) + (:attrs-fullname cfg)])) + +(defmethod ig/init-key ::provider + [_ cfg] + (when (:enabled? cfg) + (some-> cfg try-connectivity prepare-attributes))) + +(s/def ::enabled? ::us/boolean) +(s/def ::host ::cf/ldap-host) +(s/def ::port ::cf/ldap-port) +(s/def ::ssl ::cf/ldap-ssl) +(s/def ::tls ::cf/ldap-starttls) +(s/def ::query ::cf/ldap-user-query) +(s/def ::base-dn ::cf/ldap-base-dn) +(s/def ::bind-dn ::cf/ldap-bind-dn) +(s/def ::bind-password ::cf/ldap-bind-password) +(s/def ::attrs-email ::cf/ldap-attrs-email) +(s/def ::attrs-fullname ::cf/ldap-attrs-fullname) +(s/def ::attrs-username ::cf/ldap-attrs-username) + +(defmethod ig/pre-init-spec ::provider + [_] + (s/keys :opt-un [::host ::port + ::ssl ::tls + ::enabled? + ::bind-dn + ::bind-password + ::query + ::attrs-email + ::attrs-username + ::attrs-fullname])) diff --git a/backend/src/app/http/oauth.clj b/backend/src/app/auth/oidc.clj similarity index 52% rename from backend/src/app/http/oauth.clj rename to backend/src/app/auth/oidc.clj index 869134f18..39572bb18 100644 --- a/backend/src/app/http/oauth.clj +++ b/backend/src/app/auth/oidc.clj @@ -4,19 +4,23 @@ ;; ;; Copyright (c) UXBOX Labs SL -(ns app.http.oauth +(ns app.auth.oidc + "OIDC client implementation." (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] [app.common.uri :as u] [app.config :as cf] [app.db :as db] + [app.http.middleware :as hmw] [app.loggers.audit :as audit] [app.rpc.queries.profile :as profile] [app.util.json :as json] [app.util.time :as dt] + [app.worker :as wrk] [clojure.set :as set] [clojure.spec.alpha :as s] [cuerdas.core :as str] @@ -25,6 +29,218 @@ [promesa.exec :as px] [yetti.response :as yrs])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- obfuscate-string + [s] + (if (< (count s) 10) + (apply str (take (count s) (repeat "*"))) + (str (subs s 0 5) + (apply str (take (- (count s) 5) (repeat "*")))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; OIDC PROVIDER (GENERIC) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- discover-oidc-config + [{:keys [http-client]} {:keys [base-uri] :as opts}] + (let [discovery-uri (u/join base-uri ".well-known/openid-configuration") + response (ex/try (http-client {:method :get :uri (str discovery-uri)} {:sync? true}))] + (cond + (ex/exception? response) + (do + (l/warn :hint "unable to discover oidc configuration" + :discover-uri (str discovery-uri) + :cause response) + nil) + + (= 200 (:status response)) + (let [data (json/read (:body response))] + {:token-uri (get data :token_endpoint) + :auth-uri (get data :authorization_endpoint) + :user-uri (get data :userinfo_endpoint)}) + + :else + (do + (l/warn :hint "unable to discover OIDC configuration" + :uri (str discovery-uri) + :response-status-code (:status response)) + nil)))) + +(defn- prepare-oidc-opts + [cfg] + (let [opts {:base-uri (:base-uri cfg) + :client-id (:client-id cfg) + :client-secret (:client-secret cfg) + :token-uri (:token-uri cfg) + :auth-uri (:auth-uri cfg) + :user-uri (:user-uri cfg) + :scopes (:scopes cfg #{"openid" "profile" "email"}) + :roles-attr (:roles-attr cfg) + :roles (:roles cfg) + :name "oidc"} + + opts (d/without-nils opts)] + + (when (and (string? (:base-uri opts)) + (string? (:client-id opts)) + (string? (:client-secret opts))) + (if (and (string? (:token-uri opts)) + (string? (:user-uri opts)) + (string? (:auth-uri opts))) + opts + (some-> (discover-oidc-config cfg opts) + (merge opts {:discover? true})))))) + +(defmethod ig/prep-key ::generic-provider + [_ cfg] + (d/without-nils cfg)) + +(defmethod ig/init-key ::generic-provider + [_ cfg] + (when (:enabled? cfg) + (if-let [opts (prepare-oidc-opts cfg)] + (do + (l/info :hint "provider initialized" + :provider :oidc + :method (if (:discover? opts) "discover" "manual") + :client-id (:client-id opts) + :client-secret (obfuscate-string (:client-secret opts)) + :scopes (str/join "," (:scopes opts)) + :auth-uri (:auth-uri opts) + :user-uri (:user-uri opts) + :token-uri (:token-uri opts) + :roles-attr (:roles-attr opts) + :roles (:roles opts)) + opts) + (do + (l/warn :hint "unable to initialize auth provider, missing configuration" :provider :oidc) + nil)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GOOGLE AUTH PROVIDER +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defmethod ig/prep-key ::google-provider + [_ cfg] + (d/without-nils cfg)) + +(defmethod ig/init-key ::google-provider + [_ cfg] + (let [opts {:client-id (:client-id cfg) + :client-secret (:client-secret cfg) + :scopes #{"openid" "email" "profile"} + :auth-uri "https://accounts.google.com/o/oauth2/v2/auth" + :token-uri "https://oauth2.googleapis.com/token" + :user-uri "https://openidconnect.googleapis.com/v1/userinfo" + :name "google"}] + + (when (:enabled? cfg) + (if (and (string? (:client-id opts)) + (string? (:client-secret opts))) + (do + (l/info :hint "provider initialized" + :provider :google + :client-id (:client-id opts) + :client-secret (obfuscate-string (:client-secret opts))) + opts) + + (do + (l/warn :hint "unable to initialize auth provider, missing configuration" :provider :google) + nil))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GITHUB AUTH PROVIDER +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- retrieve-github-email + [{:keys [http-client]} tdata info] + (or (some-> info :email p/resolved) + (-> (http-client {:uri "https://api.github.com/user/emails" + :headers {"Authorization" (dm/str (:type tdata) " " (:token tdata))} + :timeout 6000 + :method :get}) + (p/then (fn [{:keys [status body] :as response}] + (when-not (s/int-in-range? 200 300 status) + (ex/raise :type :internal + :code :unable-to-retrieve-github-emails + :hint "unable to retrieve github emails" + :http-status status + :http-body body)) + (->> response :body json/read (filter :primary) first :email)))))) + +(defmethod ig/prep-key ::github-provider + [_ cfg] + (d/without-nils cfg)) + +(defmethod ig/init-key ::github-provider + [_ cfg] + (let [opts {:client-id (:client-id cfg) + :client-secret (:client-secret cfg) + :scopes #{"read:user" "user:email"} + :auth-uri "https://github.com/login/oauth/authorize" + :token-uri "https://github.com/login/oauth/access_token" + :user-uri "https://api.github.com/user" + :name "github" + + ;; Additional hooks for provider specific way of + ;; retrieve emails. + :get-email-fn (partial retrieve-github-email cfg)}] + + (when (:enabled? cfg) + (if (and (string? (:client-id opts)) + (string? (:client-secret opts))) + (do + (l/info :hint "provider initialized" + :provider :github + :client-id (:client-id opts) + :client-secret (obfuscate-string (:client-secret opts))) + opts) + + (do + (l/warn :hint "unable to initialize auth provider, missing configuration" :provider :github) + nil))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GITLAB AUTH PROVIDER +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defmethod ig/prep-key ::gitlab-provider + [_ cfg] + (d/without-nils cfg)) + +(defmethod ig/init-key ::gitlab-provider + [_ cfg] + (let [base (:base-uri cfg "https://gitlab.com") + opts {:base-uri base + :client-id (:client-id cfg) + :client-secret (:client-secret cfg) + :scopes #{"openid" "profile" "email"} + :auth-uri (str base "/oauth/authorize") + :token-uri (str base "/oauth/token") + :user-uri (str base "/oauth/userinfo") + :name "gitlab"}] + (when (:enabled? cfg) + (if (and (string? (:client-id opts)) + (string? (:client-secret opts))) + (do + (l/info :hint "provider initialized" + :provider :gitlab + :base-uri base + :client-id (:client-id opts) + :client-secret (obfuscate-string (:client-secret opts))) + opts) + + (do + (l/warn :hint "unable to initialize auth provider, missing configuration" :provider :gitlab) + nil))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HANDLERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn- build-redirect-uri [{:keys [provider] :as cfg}] (let [public (u/uri (:public-uri cfg))] @@ -81,47 +297,35 @@ :timeout 6000 :method :get})) - (retrieve-emails [] - (if (some? (:emails-uri provider)) - (http-client {:uri (:emails-uri provider) - :headers {"Authorization" (str (:type tdata) " " (:token tdata))} - :timeout 6000 - :method :get}) - (p/resolved {:status 200}))) - - (validate-response [[retrieve-res emails-res]] - (when-not (s/int-in-range? 200 300 (:status retrieve-res)) + (validate-response [response] + (when-not (s/int-in-range? 200 300 (:status response)) (ex/raise :type :internal :code :unable-to-retrieve-user-info :hint "unable to retrieve user info" - :http-status (:status retrieve-res) - :http-body (:body retrieve-res))) - (when-not (s/int-in-range? 200 300 (:status emails-res)) - (ex/raise :type :internal - :code :unable-to-retrieve-user-info - :hint "unable to retrieve user info" - :http-status (:status emails-res) - :http-body (:body emails-res))) - [retrieve-res emails-res]) + :http-status (:status response) + :http-body (:body response))) + response) (get-email [info] - (let [attr-kw (cf/get :oidc-email-attr :email)] - (get info attr-kw))) + ;; Allow providers hook into this for custom email + ;; retrieval method. + (if-let [get-email-fn (:get-email-fn provider)] + (get-email-fn tdata info) + (let [attr-kw (cf/get :oidc-email-attr :email)] + (get info attr-kw)))) (get-name [info] (let [attr-kw (cf/get :oidc-name-attr :name)] (get info attr-kw))) - (process-response [[retrieve-res emails-res]] - (let [info (json/read (:body retrieve-res)) - email (if (some? (:extract-email-callback provider)) - ((:extract-email-callback provider) emails-res) - (get-email info))] + (process-response [response] + (p/let [info (-> response :body json/read) + email (get-email info)] {:backend (:name provider) :email email :fullname (or (get-name info) email) - :props (->> (dissoc info :name :email) - (qualify-props provider))})) + :props (->> (dissoc info :name :email) + (qualify-props provider))})) (validate-info [info] (when-not (s/valid? ::info info) @@ -133,10 +337,10 @@ :info info)) info)] - (-> (p/all [(retrieve) (retrieve-emails)]) - (p/then' validate-response) - (p/then' process-response) - (p/then' validate-info)))) + (-> (retrieve) + (p/then validate-response) + (p/then process-response) + (p/then validate-info)))) (s/def ::backend ::us/not-empty-string) (s/def ::email ::us/not-empty-string) @@ -195,8 +399,6 @@ (p/then' validate-oidc) (p/then' (partial post-process state)))))) -;; --- HTTP HANDLERS - (defn- retrieve-profile [{:keys [pool executor] :as cfg} info] (px/with-dispatch executor @@ -256,21 +458,18 @@ (redirect-response uri)))) (defn- auth-handler - [{:keys [tokens] :as cfg} {:keys [params] :as request} respond raise] - (try - (let [props (audit/extract-utm-params params) - state (tokens :generate - {:iss :oauth - :invitation-token (:invitation-token params) - :props props - :exp (dt/in-future "15m")}) - uri (build-auth-uri cfg state)] - (respond (yrs/response 200 {:redirect-uri uri}))) - (catch Throwable cause - (raise cause)))) + [{:keys [tokens] :as cfg} {:keys [params] :as request}] + (let [props (audit/extract-utm-params params) + state (tokens :generate + {:iss :oauth + :invitation-token (:invitation-token params) + :props props + :exp (dt/in-future "15m")}) + uri (build-auth-uri cfg state)] + (yrs/response 200 {:redirect-uri uri}))) (defn- callback-handler - [cfg request respond _] + [cfg request] (letfn [(process-request [] (p/let [info (retrieve-info cfg request) profile (retrieve-profile cfg info)] @@ -278,182 +477,62 @@ (handle-error [cause] (l/error :hint "error on oauth process" :cause cause) - (respond (generate-error-redirect cfg cause)))] + (generate-error-redirect cfg cause))] (-> (process-request) - (p/then respond) (p/catch handle-error)))) -;; --- INIT - -(declare initialize) +(def provider-lookup + {:compile + (fn [& _] + (fn [handler] + (fn [{:keys [providers] :as cfg} request] + (let [provider (some-> request :path-params :provider keyword)] + (if-let [provider (get providers provider)] + (handler (assoc cfg :provider provider) request) + (ex/raise :type :restriction + :code :provider-not-configured + :provider provider + :hint "provider not configured"))))))}) (s/def ::public-uri ::us/not-empty-string) +(s/def ::http-client fn?) (s/def ::session map?) (s/def ::tokens fn?) -(s/def ::rpc map?) +(s/def ::providers map?) -(defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::public-uri ::session ::tokens ::rpc ::db/pool])) +(defmethod ig/pre-init-spec ::routes + [_] + (s/keys :req-un [::public-uri + ::session + ::tokens + ::http-client + ::providers + ::db/pool + ::wrk/executor])) -(defn wrap-handler - [cfg handler] - (fn [request respond raise] - (let [provider (get-in request [:path-params :provider]) - provider (get-in @cfg [:providers provider])] - (if provider - (handler (assoc @cfg :provider provider) - request - respond - raise) - (raise - (ex/error - :type :not-found - :provider provider - :hint "provider not configured")))))) +(defmethod ig/init-key ::routes + [_ {:keys [executor session] :as cfg}] + (let [cfg (update cfg :provider d/without-nils)] + ["" {:middleware [[(:middleware session)] + [hmw/with-promise-async executor] + [hmw/with-config cfg] + [provider-lookup] + ]} + ;; We maintain the both URI prefixes for backward compatibility. -(defmethod ig/init-key ::handler - [_ cfg] - (let [cfg (initialize cfg)] - {:handler (wrap-handler cfg auth-handler) - :callback-handler (wrap-handler cfg callback-handler)})) + ["/auth/oauth" + ["/:provider" + {:handler auth-handler + :allowed-methods #{:post}}] + ["/:provider/callback" + {:handler callback-handler + :allowed-methods #{:get}}]] -(defn- discover-oidc-config - [{:keys [http-client]} {:keys [base-uri] :as opts}] - - (let [discovery-uri (u/join base-uri ".well-known/openid-configuration") - response (ex/try (http-client {:method :get :uri (str discovery-uri)} {:sync? true}))] - (cond - (ex/exception? response) - (do - (l/warn :hint "unable to discover oidc configuration" - :discover-uri (str discovery-uri) - :cause response) - nil) - - (= 200 (:status response)) - (let [data (json/read (:body response))] - {:token-uri (get data :token_endpoint) - :auth-uri (get data :authorization_endpoint) - :user-uri (get data :userinfo_endpoint)}) - - :else - (do - (l/warn :hint "unable to discover OIDC configuration" - :uri (str discovery-uri) - :response-status-code (:status response)) - nil)))) - -(defn- obfuscate-string - [s] - (if (< (count s) 10) - (apply str (take (count s) (repeat "*"))) - (str (subs s 0 5) - (apply str (take (- (count s) 5) (repeat "*")))))) - -(defn- initialize-oidc-provider - [cfg] - (let [opts {:base-uri (cf/get :oidc-base-uri) - :client-id (cf/get :oidc-client-id) - :client-secret (cf/get :oidc-client-secret) - :token-uri (cf/get :oidc-token-uri) - :auth-uri (cf/get :oidc-auth-uri) - :user-uri (cf/get :oidc-user-uri) - :scopes (cf/get :oidc-scopes #{"openid" "profile" "email"}) - :roles-attr (cf/get :oidc-roles-attr) - :roles (cf/get :oidc-roles) - :name "oidc"}] - - (if (and (string? (:base-uri opts)) - (string? (:client-id opts)) - (string? (:client-secret opts))) - (do - (l/debug :hint "initialize oidc provider" :name "generic-oidc" - :opts (update opts :client-secret obfuscate-string)) - (if (and (string? (:token-uri opts)) - (string? (:user-uri opts)) - (string? (:auth-uri opts))) - (do - (l/debug :hint "initialized with user provided configuration") - (assoc-in cfg [:providers "oidc"] opts)) - (do - (l/debug :hint "trying to discover oidc provider configuration using BASE_URI") - (if-let [opts' (discover-oidc-config cfg opts)] - (do - (l/debug :hint "discovered opts" :additional-opts opts') - (assoc-in cfg [:providers "oidc"] (merge opts opts'))) - - cfg)))) - cfg))) - -(defn- initialize-google-provider - [cfg] - (let [opts {:client-id (cf/get :google-client-id) - :client-secret (cf/get :google-client-secret) - :scopes #{"openid" "email" "profile"} - :auth-uri "https://accounts.google.com/o/oauth2/v2/auth" - :token-uri "https://oauth2.googleapis.com/token" - :user-uri "https://openidconnect.googleapis.com/v1/userinfo" - :name "google"}] - (if (and (string? (:client-id opts)) - (string? (:client-secret opts))) - (do - (l/info :action "initialize" :provider "google" - :opts (pr-str (update opts :client-secret obfuscate-string))) - (assoc-in cfg [:providers "google"] opts)) - cfg))) - -(defn extract-github-email - [response] - (let [emails (json/read (:body response)) - primary-email (->> emails - (filter #(:primary %)) - first)] - (:email primary-email))) - -(defn- initialize-github-provider - [cfg] - (let [opts {:client-id (cf/get :github-client-id) - :client-secret (cf/get :github-client-secret) - :scopes #{"read:user" "user:email"} - :auth-uri "https://github.com/login/oauth/authorize" - :token-uri "https://github.com/login/oauth/access_token" - :emails-uri "https://api.github.com/user/emails" - :extract-email-callback extract-github-email - :user-uri "https://api.github.com/user" - :name "github"}] - (if (and (string? (:client-id opts)) - (string? (:client-secret opts))) - (do - (l/info :action "initialize" :provider "github" - :opts (pr-str (update opts :client-secret obfuscate-string))) - (assoc-in cfg [:providers "github"] opts)) - cfg))) - -(defn- initialize-gitlab-provider - [cfg] - (let [base (cf/get :gitlab-base-uri "https://gitlab.com") - opts {:base-uri base - :client-id (cf/get :gitlab-client-id) - :client-secret (cf/get :gitlab-client-secret) - :scopes #{"openid" "profile" "email"} - :auth-uri (str base "/oauth/authorize") - :token-uri (str base "/oauth/token") - :user-uri (str base "/oauth/userinfo") - :name "gitlab"}] - (if (and (string? (:client-id opts)) - (string? (:client-secret opts))) - (do - (l/info :action "initialize" :provider "gitlab" - :opts (pr-str (update opts :client-secret obfuscate-string))) - (assoc-in cfg [:providers "gitlab"] opts)) - cfg))) - -(defn- initialize - [cfg] - (let [cfg (agent cfg :error-mode :continue)] - (send-off cfg initialize-google-provider) - (send-off cfg initialize-gitlab-provider) - (send-off cfg initialize-github-provider) - (send-off cfg initialize-oidc-provider) - cfg)) + ["/auth/oidc" + ["/:provider" + {:handler auth-handler + :allowed-methods #{:post}}] + ["/:provider/callback" + {:handler callback-handler + :allowed-methods #{:get}}]]])) diff --git a/backend/src/app/cli/manage.clj b/backend/src/app/cli/manage.clj index dad68fc6b..ba0abae85 100644 --- a/backend/src/app/cli/manage.clj +++ b/backend/src/app/cli/manage.clj @@ -10,6 +10,7 @@ [app.common.logging :as l] [app.db :as db] [app.main :as main] + [app.rpc.commands.auth :as cmd.auth] [app.rpc.mutations.profile :as profile] [app.rpc.queries.profile :refer [retrieve-profile-data-by-email]] [clojure.string :as str] @@ -54,13 +55,13 @@ :type :password}))] (try (db/with-atomic [conn (:app.db/pool system)] - (->> (profile/create-profile conn + (->> (cmd.auth/create-profile conn {:fullname fullname :email email :password password :is-active true :is-demo false}) - (profile/create-profile-relations conn))) + (cmd.auth/create-profile-relations conn))) (when (pos? (:verbosity options)) (println "User created successfully.")) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 6acb96cf4..79972b2c9 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -79,7 +79,6 @@ :ldap-attrs-username "uid" :ldap-attrs-email "mail" :ldap-attrs-fullname "cn" - :ldap-attrs-photo "jpegPhoto" ;; a server prop key where initial project is stored. :initial-project-skey "initial-project"}) @@ -149,7 +148,6 @@ (s/def ::initial-project-skey ::us/string) (s/def ::ldap-attrs-email ::us/string) (s/def ::ldap-attrs-fullname ::us/string) -(s/def ::ldap-attrs-photo ::us/string) (s/def ::ldap-attrs-username ::us/string) (s/def ::ldap-base-dn ::us/string) (s/def ::ldap-bind-dn ::us/string) @@ -256,7 +254,6 @@ ::initial-project-skey ::ldap-attrs-email ::ldap-attrs-fullname - ::ldap-attrs-photo ::ldap-attrs-username ::ldap-base-dn ::ldap-bind-dn diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 56984d8c5..7bef64e78 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -9,7 +9,6 @@ [app.common.data :as d] [app.common.logging :as l] [app.common.transit :as t] - [app.http.doc :as doc] [app.http.errors :as errors] [app.http.middleware :as middleware] [app.metrics :as mtx] @@ -67,8 +66,10 @@ :xnio/worker-threads (:worker-threads cfg) :xnio/dispatch (:executor cfg) :ring/async true} + handler (if (some? router) (wrap-router router) + handler) server (yt/server handler (d/without-nils options))] (assoc cfg :server (yt/start! server)))) @@ -113,7 +114,6 @@ ;; HTTP ROUTER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(s/def ::rpc map?) (s/def ::oauth map?) (s/def ::storage map?) (s/def ::assets map?) @@ -122,15 +122,27 @@ (s/def ::audit-handler fn?) (s/def ::awsns-handler fn?) (s/def ::session map?) -(s/def ::debug-routes vector?) +(s/def ::rpc-routes (s/nilable vector?)) +(s/def ::debug-routes (s/nilable vector?)) +(s/def ::oidc-routes (s/nilable vector?)) +(s/def ::doc-routes (s/nilable vector?)) (defmethod ig/pre-init-spec ::router [_] - (s/keys :req-un [::rpc ::mtx/metrics ::ws ::oauth ::storage ::assets - ::session ::feedback ::awsns-handler ::debug-routes - ::audit-handler])) + (s/keys :req-un [::mtx/metrics + ::ws + ::storage + ::assets + ::session + ::feedback + ::awsns-handler + ::debug-routes + ::oidc-routes + ::audit-handler + ::rpc-routes + ::doc-routes])) (defmethod ig/init-key ::router - [_ {:keys [ws session rpc oauth metrics assets feedback debug-routes] :as cfg}] + [_ {:keys [ws session metrics assets feedback] :as cfg}] (rr/router [["" {:middleware [[middleware/server-timing] [middleware/format-response] @@ -145,7 +157,7 @@ ["/by-file-media-id/:id" {:handler (:file-objects-handler assets)}] ["/by-file-media-id/:id/thumbnail" {:handler (:file-thumbnails-handler assets)}]] - debug-routes + (:debug-routes cfg) ["/webhooks" ["/sns" {:handler (:awsns-handler cfg) @@ -156,22 +168,12 @@ :allowed-methods #{:get}}] ["/api" {:middleware [[middleware/cors] - (:middleware session)]} - ["/_doc" {:handler (doc/handler rpc) - :allowed-methods #{:get}}] - ["/feedback" {:handler feedback - :allowed-methods #{:post}}] - - ["/auth/oauth/:provider" {:handler (:handler oauth) - :allowed-methods #{:post}}] - ["/auth/oauth/:provider/callback" {:handler (:callback-handler oauth) - :allowed-methods #{:get}}] - + [(:middleware session)]]} ["/audit/events" {:handler (:audit-handler cfg) :allowed-methods #{:post}}] + ["/feedback" {:handler feedback + :allowed-methods #{:post}}] + (:doc-routes cfg) + (:oidc-routes cfg) + (:rpc-routes cfg)]]])) - ["/rpc" - ["/command/:command" {:handler (:command-handler rpc)}] - ["/query/:type" {:handler (:query-handler rpc)}] - ["/mutation/:type" {:handler (:mutation-handler rpc) - :allowed-methods #{:post}}]]]]])) diff --git a/backend/src/app/http/doc.clj b/backend/src/app/http/doc.clj index d079d8930..5addbb9a6 100644 --- a/backend/src/app/http/doc.clj +++ b/backend/src/app/http/doc.clj @@ -9,14 +9,16 @@ (:require [app.common.data :as d] [app.config :as cf] + [app.rpc :as-alias rpc] [app.util.services :as sv] [app.util.template :as tmpl] [clojure.java.io :as io] [clojure.spec.alpha :as s] + [integrant.core :as ig] [pretty-spec.core :as ps] [yetti.response :as yrs])) -(defn get-spec-str +(defn- get-spec-str [k] (with-out-str (ps/pprint (s/form k) @@ -24,8 +26,8 @@ "clojure.core.specs.alpha" "score" "clojure.core" nil}}))) -(defn prepare-context - [rpc] +(defn- prepare-context + [methods] (letfn [(gen-doc [type [name f]] (let [mdata (meta f)] ;; (prn name mdata) @@ -38,22 +40,32 @@ {:command-methods (into [] (map (partial gen-doc :command)) - (->> rpc :methods :command (sort-by first))) + (->> methods :commands (sort-by first))) :query-methods (into [] (map (partial gen-doc :query)) - (->> rpc :methods :query (sort-by first))) + (->> methods :queries (sort-by first))) :mutation-methods (into [] (map (partial gen-doc :mutation)) - (->> rpc :methods :mutation (sort-by first)))})) + (->> methods :mutations (sort-by first)))})) -(defn handler - [rpc] - (let [context (prepare-context rpc)] - (if (contains? cf/flags :backend-api-doc) +(defn- handler + [methods] + (if (contains? cf/flags :backend-api-doc) + (let [context (prepare-context methods)] (fn [_ respond _] (respond (yrs/response 200 (-> (io/resource "api-doc.tmpl") - (tmpl/render context))))) - (fn [_ respond _] - (respond (yrs/response 404)))))) + (tmpl/render context)))))) + (fn [_ respond _] + (respond (yrs/response 404))))) + + +(defmethod ig/pre-init-spec ::routes [_] + (s/keys :req-un [::rpc/methods])) + +(defmethod ig/init-key ::routes + [_ {:keys [methods] :as cfg}] + ["/_doc" {:handler (handler methods) + :allowed-methods #{:get}}]) + diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index f7533306e..5118dc5e9 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -143,13 +143,11 @@ (defn handle [cause request] - (cond (or (instance? java.util.concurrent.CompletionException cause) (instance? java.util.concurrent.ExecutionException cause)) (handle-exception (.getCause ^Throwable cause) request) - (ex/wrapped? cause) (let [context (meta cause) cause (deref cause)] diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index f0be700a8..a6a6057e6 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -201,6 +201,7 @@ (fn [handler executor] (fn [request respond raise] (-> (px/submit! executor #(handler request)) + (p/bind p/wrap) (p/then respond) (p/catch raise)))))}) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 529bfc660..4041340df 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -6,6 +6,7 @@ (ns app.main (:require + [app.auth.oidc] [app.common.logging :as l] [app.config :as cf] [app.util.time :as dt] @@ -90,6 +91,9 @@ :app.http/session {:store (ig/ref :app.http.session/store)} + :app.http.doc/routes + {:methods (ig/ref :app.rpc/methods)} + :app.http.session/store {:pool (ig/ref :app.db/pool) :tokens (ig/ref :app.tokens/tokens) @@ -123,20 +127,81 @@ :max-body-size (cf/get :http-server-max-body-size) :max-multipart-body-size (cf/get :http-server-max-multipart-body-size)} + :app.auth.ldap/provider + {:host (cf/get :ldap-host) + :port (cf/get :ldap-port) + :ssl (cf/get :ldap-ssl) + :tls (cf/get :ldap-starttls) + :query (cf/get :ldap-user-query) + :attrs-email (cf/get :ldap-attrs-email) + :attrs-fullname (cf/get :ldap-attrs-fullname) + :attrs-username (cf/get :ldap-attrs-username) + :base-dn (cf/get :ldap-base-dn) + :bind-dn (cf/get :ldap-bind-dn) + :bind-password (cf/get :ldap-bind-password) + :enabled? (contains? cf/flags :login-with-ldap)} + + :app.auth.oidc/google-provider + {:enabled? (contains? cf/flags :login-with-google) + :client-id (cf/get :google-client-id) + :client-secret (cf/get :google-client-secret)} + + :app.auth.oidc/github-provider + {:enabled? (contains? cf/flags :login-with-github) + :http-client (ig/ref :app.http/client) + :client-id (cf/get :github-client-id) + :client-secret (cf/get :github-client-secret)} + + :app.auth.oidc/gitlab-provider + {:enabled? (contains? cf/flags :login-with-gitlab) + :base-uri (cf/get :gitlab-base-uri "https://gitlab.com") + :client-id (cf/get :gitlab-client-id) + :client-secret (cf/get :gitlab-client-secret)} + + :app.auth.oidc/generic-provider + {:enabled? (contains? cf/flags :login-with-oidc) + :http-client (ig/ref :app.http/client) + + :client-id (cf/get :oidc-client-id) + :client-secret (cf/get :oidc-client-secret) + + :base-uri (cf/get :oidc-base-uri) + + :token-uri (cf/get :oidc-token-uri) + :auth-uri (cf/get :oidc-auth-uri) + :user-uri (cf/get :oidc-user-uri) + + :scopes (cf/get :oidc-scopes) + :roles-attr (cf/get :oidc-roles-attr) + :roles (cf/get :oidc-roles)} + + :app.auth.oidc/routes + {:providers {:google (ig/ref :app.auth.oidc/google-provider) + :github (ig/ref :app.auth.oidc/github-provider) + :gitlab (ig/ref :app.auth.oidc/gitlab-provider) + :oidc (ig/ref :app.auth.oidc/generic-provider)} + :tokens (ig/ref :app.tokens/tokens) + :http-client (ig/ref :app.http/client) + :pool (ig/ref :app.db/pool) + :session (ig/ref :app.http/session) + :public-uri (cf/get :public-uri) + :executor (ig/ref [::default :app.worker/executor])} + :app.http/router {:assets (ig/ref :app.http.assets/handlers) :feedback (ig/ref :app.http.feedback/handler) :session (ig/ref :app.http/session) :awsns-handler (ig/ref :app.http.awsns/handler) - :oauth (ig/ref :app.http.oauth/handler) :debug-routes (ig/ref :app.http.debug/routes) + :oidc-routes (ig/ref :app.auth.oidc/routes) :ws (ig/ref :app.http.websocket/handler) :metrics (ig/ref :app.metrics/metrics) :public-uri (cf/get :public-uri) :storage (ig/ref :app.storage/storage) :tokens (ig/ref :app.tokens/tokens) :audit-handler (ig/ref :app.loggers.audit/http-handler) - :rpc (ig/ref :app.rpc/rpc) + :rpc-routes (ig/ref :app.rpc/routes) + :doc-routes (ig/ref :app.http.doc/routes) :executor (ig/ref [::default :app.worker/executor])} :app.http.debug/routes @@ -162,17 +227,7 @@ {:pool (ig/ref :app.db/pool) :executor (ig/ref [::default :app.worker/executor])} - :app.http.oauth/handler - {:rpc (ig/ref :app.rpc/rpc) - :session (ig/ref :app.http/session) - :pool (ig/ref :app.db/pool) - :tokens (ig/ref :app.tokens/tokens) - :audit (ig/ref :app.loggers.audit/collector) - :executor (ig/ref [::default :app.worker/executor]) - :http-client (ig/ref :app.http/client) - :public-uri (cf/get :public-uri)} - - :app.rpc/rpc + :app.rpc/methods {:pool (ig/ref :app.db/pool) :session (ig/ref :app.http/session) :tokens (ig/ref :app.tokens/tokens) @@ -181,9 +236,13 @@ :msgbus (ig/ref :app.msgbus/msgbus) :public-uri (cf/get :public-uri) :audit (ig/ref :app.loggers.audit/collector) + :ldap (ig/ref :app.auth.ldap/provider) :http-client (ig/ref :app.http/client) :executors (ig/ref :app.worker/executors)} + :app.rpc/routes + {:methods (ig/ref :app.rpc/methods)} + :app.worker/worker {:executor (ig/ref [::worker :app.worker/executor]) :tasks (ig/ref :app.worker/registry) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index a1c758f69..bfe570d7e 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -223,15 +223,13 @@ (defn- resolve-mutation-methods [cfg] (let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)] - (->> (sv/scan-ns 'app.rpc.mutations.demo - 'app.rpc.mutations.media + (->> (sv/scan-ns 'app.rpc.mutations.media 'app.rpc.mutations.profile 'app.rpc.mutations.files 'app.rpc.mutations.comments 'app.rpc.mutations.projects 'app.rpc.mutations.teams 'app.rpc.mutations.management - 'app.rpc.mutations.ldap 'app.rpc.mutations.fonts 'app.rpc.mutations.share-link 'app.rpc.mutations.verify-token) @@ -241,26 +239,65 @@ (defn- resolve-command-methods [cfg] (let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)] - (->> (sv/scan-ns 'app.rpc.commands.binfile) + (->> (sv/scan-ns 'app.rpc.commands.binfile + 'app.rpc.commands.auth + 'app.rpc.commands.ldap + 'app.rpc.commands.demo) (map (partial process-method cfg)) (into {})))) -(s/def ::storage some?) -(s/def ::session map?) -(s/def ::tokens fn?) (s/def ::audit (s/nilable fn?)) (s/def ::executors (s/map-of keyword? ::wrk/executor)) +(s/def ::executors map?) +(s/def ::http-client fn?) +(s/def ::ldap (s/nilable map?)) +(s/def ::msgbus fn?) +(s/def ::public-uri ::us/not-empty-string) +(s/def ::session map?) +(s/def ::storage some?) +(s/def ::tokens fn?) -(defmethod ig/pre-init-spec ::rpc [_] - (s/keys :req-un [::storage ::session ::tokens ::audit - ::executors ::mtx/metrics ::db/pool])) +(defmethod ig/pre-init-spec ::methods [_] + (s/keys :req-un [::storage + ::session + ::tokens + ::audit + ::executors + ::public-uri + ::msgbus + ::http-client + ::mtx/metrics + ::db/pool + ::ldap])) -(defmethod ig/init-key ::rpc +(defmethod ig/init-key ::methods [_ cfg] - (let [mq (resolve-query-methods cfg) - mm (resolve-mutation-methods cfg) - cm (resolve-command-methods cfg)] - {:methods {:query mq :mutation mm :command cm} - :command-handler (partial rpc-command-handler cm) - :query-handler (partial rpc-query-handler mq) - :mutation-handler (partial rpc-mutation-handler mm)})) + {:mutations (resolve-mutation-methods cfg) + :queries (resolve-query-methods cfg) + :commands (resolve-command-methods cfg)}) + +(s/def ::mutations + (s/map-of keyword? fn?)) + +(s/def ::queries + (s/map-of keyword? fn?)) + +(s/def ::commands + (s/map-of keyword? fn?)) + +(s/def ::methods + (s/keys :req-un [::mutations + ::queries + ::commands])) + +(defmethod ig/pre-init-spec ::routes [_] + (s/keys :req-un [::methods])) + +(defmethod ig/init-key ::routes + [_ {:keys [methods] :as cfg}] + [["/rpc" + ["/command/:command" {:handler (partial rpc-command-handler (:commands methods))}] + ["/query/:type" {:handler (partial rpc-query-handler (:queries methods))}] + ["/mutation/:type" {:handler (partial rpc-mutation-handler (:mutations methods)) + :allowed-methods #{:post}}]]]) + diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj new file mode 100644 index 000000000..8d1a3bb18 --- /dev/null +++ b/backend/src/app/rpc/commands/auth.clj @@ -0,0 +1,416 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.rpc.commands.auth + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.emails :as eml] + [app.loggers.audit :as audit] + [app.rpc.mutations.teams :as teams] + [app.rpc.queries.profile :as profile] + [app.rpc.rlimit :as rlimit] + [app.util.services :as sv] + [app.util.time :as dt] + [buddy.hashers :as hashers] + [clojure.spec.alpha :as s] + [cuerdas.core :as str])) + +(s/def ::email ::us/email) +(s/def ::fullname ::us/not-empty-string) +(s/def ::lang ::us/string) +(s/def ::path ::us/string) +(s/def ::profile-id ::us/uuid) +(s/def ::password ::us/not-empty-string) +(s/def ::old-password ::us/not-empty-string) +(s/def ::theme ::us/string) +(s/def ::invitation-token ::us/not-empty-string) +(s/def ::token ::us/not-empty-string) + +;; ---- HELPERS + +(defn derive-password + [password] + (hashers/derive password + {:alg :argon2id + :memory 16384 + :iterations 20 + :parallelism 2})) + +(defn verify-password + [attempt password] + (try + (hashers/verify attempt password) + (catch Exception _e + {:update false + :valid false}))) + +(defn email-domain-in-whitelist? + "Returns true if email's domain is in the given whitelist or if + given whitelist is an empty string." + [domains email] + (if (or (empty? domains) + (nil? domains)) + true + (let [[_ candidate] (-> (str/lower email) + (str/split #"@" 2))] + (contains? domains candidate)))) + +(def ^:private sql:profile-existence + "select exists (select * from profile + where email = ? + and deleted_at is null) as val") + +(defn check-profile-existence! + [conn {:keys [email] :as params}] + (let [email (str/lower email) + result (db/exec-one! conn [sql:profile-existence email])] + (when (:val result) + (ex/raise :type :validation + :code :email-already-exists)) + params)) + +;; ---- COMMAND: login with password + +(defn login-with-password + [{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}] + + (when-not (contains? cf/flags :login) + (ex/raise :type :restriction + :code :login-disabled + :hint "login is disabled in this instance")) + + (letfn [(check-password [profile password] + (when (= (:password profile) "!") + (ex/raise :type :validation + :code :account-without-password + :hint "the current account does not have password")) + (:valid (verify-password password (:password profile)))) + + (validate-profile [profile] + (when-not (:is-active profile) + (ex/raise :type :validation + :code :wrong-credentials)) + (when-not profile + (ex/raise :type :validation + :code :wrong-credentials)) + (when-not (check-password profile password) + (ex/raise :type :validation + :code :wrong-credentials)) + profile)] + + (db/with-atomic [conn pool] + (let [profile (->> (profile/retrieve-profile-data-by-email conn email) + (validate-profile) + (profile/strip-private-attrs) + (profile/populate-additional-data conn) + (profile/decode-profile-row)) + + invitation (when-let [token (:invitation-token params)] + (tokens :verify {:token token :iss :team-invitation})) + + ;; If invitation member-id does not matches the profile-id, we just proceed to ignore the + ;; invitation because invitations matches exactly; and user can't loging with other email and + ;; accept invitation with other email + response (if (and (some? invitation) (= (:id profile) (:member-id invitation))) + {:invitation-token (:invitation-token params)} + profile)] + + (with-meta response + {:transform-response ((:create session) (:id profile)) + ::audit/props (audit/profile->props profile) + ::audit/profile-id (:id profile)}))))) + +(s/def ::login-with-password + (s/keys :req-un [::email ::password] + :opt-un [::invitation-token])) + +(sv/defmethod ::login-with-password + "Performs authentication using penpot password." + {:auth false ::rlimit/permits (cf/get :rlimit-password)} + [cfg params] + (login-with-password cfg params)) + +;; ---- COMMAND: Logout + +(s/def ::logout + (s/keys :opt-un [::profile-id])) + +(sv/defmethod ::logout + "Clears the authentication cookie and logout the current session." + {:auth false} + [{:keys [session] :as cfg} _] + (with-meta {} + {:transform-response (:delete session)})) + +;; ---- COMMAND: Recover Profile + +(defn recover-profile + [{:keys [pool tokens] :as cfg} {:keys [token password]}] + (letfn [(validate-token [token] + (let [tdata (tokens :verify {:token token :iss :password-recovery})] + (:profile-id tdata))) + + (update-password [conn profile-id] + (let [pwd (derive-password password)] + (db/update! conn :profile {:password pwd} {:id profile-id})))] + + (db/with-atomic [conn pool] + (->> (validate-token token) + (update-password conn)) + nil))) + +(s/def ::token ::us/not-empty-string) +(s/def ::recover-profile + (s/keys :req-un [::token ::password])) + +(sv/defmethod ::recover-profile + {:auth false ::rlimit/permits (cf/get :rlimit-password)} + [cfg params] + (recover-profile cfg params)) + +;; ---- COMMAND: Prepare Register + +(defn prepare-register + [{:keys [pool tokens] :as cfg} params] + (when-not (contains? cf/flags :registration) + (if-not (contains? params :invitation-token) + (ex/raise :type :restriction + :code :registration-disabled) + (let [invitation (tokens :verify {:token (:invitation-token params) :iss :team-invitation})] + (when-not (= (:email params) (:member-email invitation)) + (ex/raise :type :restriction + :code :email-does-not-match-invitation + :hint "email should match the invitation"))))) + + (when-let [domains (cf/get :registration-domain-whitelist)] + (when-not (email-domain-in-whitelist? domains (:email params)) + (ex/raise :type :validation + :code :email-domain-is-not-allowed))) + + ;; Don't allow proceed in preparing registration if the profile is + ;; already reported as spammer. + (when (eml/has-bounce-reports? pool (:email params)) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email has one or many bounces reported")) + + (check-profile-existence! pool params) + + (when (= (str/lower (:email params)) + (str/lower (:password params))) + (ex/raise :type :validation + :code :email-as-password + :hint "you can't use your email as password")) + + (let [params {:email (:email params) + :password (:password params) + :invitation-token (:invitation-token params) + :backend "penpot" + :iss :prepared-register + :exp (dt/in-future "48h")} + + token (tokens :generate params)] + (with-meta {:token token} + {::audit/profile-id uuid/zero}))) + +(s/def ::prepare-register-profile + (s/keys :req-un [::email ::password] + :opt-un [::invitation-token])) + +(sv/defmethod ::prepare-register-profile {:auth false} + [cfg params] + (prepare-register cfg params)) + +;; ---- COMMAND: Register Profile + +(defn create-profile + "Create the profile entry on the database with limited input filling + all the other fields with defaults." + [conn params] + (let [id (or (:id params) (uuid/next)) + + props (-> (audit/extract-utm-params params) + (merge (:props params)) + (db/tjson)) + + password (if-let [password (:password params)] + (derive-password password) + "!") + + locale (:locale params) + locale (when (and (string? locale) (not (str/blank? locale))) + locale) + + backend (:backend params "penpot") + is-demo (:is-demo params false) + is-muted (:is-muted params false) + is-active (:is-active params false) + email (str/lower (:email params)) + + params {:id id + :fullname (:fullname params) + :email email + :auth-backend backend + :lang locale + :password password + :deleted-at (:deleted-at params) + :props props + :is-active is-active + :is-muted is-muted + :is-demo is-demo}] + (try + (-> (db/insert! conn :profile params) + (profile/decode-profile-row)) + (catch org.postgresql.util.PSQLException e + (let [state (.getSQLState e)] + (if (not= state "23505") + (throw e) + (ex/raise :type :validation + :code :email-already-exists + :cause e))))))) + +(defn create-profile-relations + [conn profile] + (let [team (teams/create-team conn {:profile-id (:id profile) + :name "Default" + :is-default true})] + (-> profile + (profile/strip-private-attrs) + (assoc :default-team-id (:id team)) + (assoc :default-project-id (:default-project-id team))))) + +(defn register-profile + [{:keys [conn tokens session] :as cfg} {:keys [token] :as params}] + (let [claims (tokens :verify {:token token :iss :prepared-register}) + params (merge params claims)] + (check-profile-existence! conn params) + (let [is-active (or (:is-active params) + (contains? cf/flags :insecure-register)) + profile (->> (assoc params :is-active is-active) + (create-profile conn) + (create-profile-relations conn) + (profile/decode-profile-row)) + invitation (when-let [token (:invitation-token params)] + (tokens :verify {:token token :iss :team-invitation}))] + (cond + ;; If invitation token comes in params, this is because the user comes from team-invitation process; + ;; in this case, regenerate token and send back to the user a new invitation token (and mark current + ;; session as logged). This happens only if the invitation email matches with the register email. + (and (some? invitation) (= (:email profile) (:member-email invitation))) + (let [claims (assoc invitation :member-id (:id profile)) + token (tokens :generate claims) + resp {:invitation-token token}] + (with-meta resp + {:transform-response ((:create session) (:id profile)) + ::audit/replace-props (audit/profile->props profile) + ::audit/profile-id (:id profile)})) + + ;; If auth backend is different from "penpot" means user is + ;; registering using third party auth mechanism; in this case + ;; we need to mark this session as logged. + (not= "penpot" (:auth-backend profile)) + (with-meta (profile/strip-private-attrs profile) + {:transform-response ((:create session) (:id profile)) + ::audit/replace-props (audit/profile->props profile) + ::audit/profile-id (:id profile)}) + + ;; If the `:enable-insecure-register` flag is set, we proceed + ;; to sign in the user directly, without email verification. + (true? is-active) + (with-meta (profile/strip-private-attrs profile) + {:transform-response ((:create session) (:id profile)) + ::audit/replace-props (audit/profile->props profile) + ::audit/profile-id (:id profile)}) + + ;; In all other cases, send a verification email. + :else + (let [vtoken (tokens :generate + {:iss :verify-email + :exp (dt/in-future "48h") + :profile-id (:id profile) + :email (:email profile)}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] + (eml/send! {::eml/conn conn + ::eml/factory eml/register + :public-uri (:public-uri cfg) + :to (:email profile) + :name (:fullname profile) + :token vtoken + :extra-data ptoken}) + + (with-meta profile + {::audit/replace-props (audit/profile->props profile) + ::audit/profile-id (:id profile)})))))) + +(s/def ::register-profile + (s/keys :req-un [::token ::fullname])) + +(sv/defmethod ::register-profile + {:auth false ::rlimit/permits (cf/get :rlimit-password)} + [{:keys [pool] :as cfg} params] + (db/with-atomic [conn pool] + (-> (assoc cfg :conn conn) + (register-profile params)))) + +;; ---- COMMAND: Request Profile Recovery + +(defn request-profile-recovery + [{:keys [pool tokens] :as cfg} {:keys [email] :as params}] + (letfn [(create-recovery-token [{:keys [id] :as profile}] + (let [token (tokens :generate + {:iss :password-recovery + :exp (dt/in-future "15m") + :profile-id id})] + (assoc profile :token token))) + + (send-email-notification [conn profile] + (let [ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] + (eml/send! {::eml/conn conn + ::eml/factory eml/password-recovery + :public-uri (:public-uri cfg) + :to (:email profile) + :token (:token profile) + :name (:fullname profile) + :extra-data ptoken}) + nil))] + + (db/with-atomic [conn pool] + (when-let [profile (profile/retrieve-profile-data-by-email conn email)] + (when-not (eml/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) + + (when-not (:is-active profile) + (ex/raise :type :validation + :code :profile-not-verified + :hint "the user need to validate profile before recover password")) + + (when (eml/has-bounce-reports? conn (:email profile)) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + + (->> profile + (create-recovery-token) + (send-email-notification conn)))))) + +(s/def ::request-profile-recovery + (s/keys :req-un [::email])) + +(sv/defmethod ::request-profile-recovery {:auth false} + [cfg params] + (request-profile-recovery cfg params)) + + diff --git a/backend/src/app/rpc/mutations/demo.clj b/backend/src/app/rpc/commands/demo.clj similarity index 90% rename from backend/src/app/rpc/mutations/demo.clj rename to backend/src/app/rpc/commands/demo.clj index 12786757f..3ec8f37ae 100644 --- a/backend/src/app/rpc/mutations/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -4,7 +4,7 @@ ;; ;; Copyright (c) UXBOX Labs SL -(ns app.rpc.mutations.demo +(ns app.rpc.commands.demo "A demo specific mutations." (:require [app.common.exceptions :as ex] @@ -12,7 +12,7 @@ [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] - [app.rpc.mutations.profile :as profile] + [app.rpc.commands.auth :as cmd.auth] [app.util.services :as sv] [app.util.time :as dt] [buddy.core.codecs :as bc] @@ -45,8 +45,8 @@ :hint "Demo users are disabled by config.")) (db/with-atomic [conn pool] - (->> (#'profile/create-profile conn params) - (#'profile/create-profile-relations conn)) + (->> (cmd.auth/create-profile conn params) + (cmd.auth/create-profile-relations conn)) (with-meta {:email email :password password} diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj new file mode 100644 index 000000000..d5012058e --- /dev/null +++ b/backend/src/app/rpc/commands/ldap.clj @@ -0,0 +1,75 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.rpc.commands.ldap + (:require + [app.auth.ldap :as ldap] + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.db :as db] + [app.loggers.audit :as-alias audit] + [app.rpc.commands.auth :as cmd.auth] + [app.rpc.queries.profile :as profile] + [app.util.services :as sv] + [clojure.spec.alpha :as s])) + +;; --- COMMAND: login-with-ldap + +(declare login-or-register) + +(s/def ::email ::us/email) +(s/def ::password ::us/string) +(s/def ::invitation-token ::us/string) + +(s/def ::login-with-ldap + (s/keys :req-un [::email ::password] + :opt-un [::invitation-token])) + +(sv/defmethod ::login-with-ldap {:auth false} + [{:keys [session tokens ldap] :as cfg} params] + (when-not ldap + (ex/raise :type :restriction + :code :ldap-not-initialized + :hide "ldap auth provider is not initialized")) + + (let [info (ldap/authenticate ldap params)] + (when-not info + (ex/raise :type :validation + :code :wrong-credentials)) + + (let [profile (login-or-register cfg info)] + (if-let [token (:invitation-token params)] + ;; If invitation token comes in params, this is because the + ;; user comes from team-invitation process; in this case, + ;; regenerate token and send back to the user a new invitation + ;; token (and mark current session as logged). + (let [claims (tokens :verify {:token token :iss :team-invitation}) + claims (assoc claims + :member-id (:id profile) + :member-email (:email profile)) + token (tokens :generate claims)] + (with-meta {:invitation-token token} + {:transform-response ((:create session) (:id profile)) + ::audit/props (:props profile) + ::audit/profile-id (:id profile)})) + + (with-meta profile + {:transform-response ((:create session) (:id profile)) + ::audit/props (:props profile) + ::audit/profile-id (:id profile)}))))) + +(defn- login-or-register + [{:keys [pool] :as cfg} info] + (db/with-atomic [conn pool] + (or (some->> (:email info) + (profile/retrieve-profile-data-by-email conn) + (profile/populate-additional-data conn) + (profile/decode-profile-row)) + (->> (assoc info :is-active true :is-demo false) + (cmd.auth/create-profile conn) + (cmd.auth/create-profile-relations conn) + (profile/strip-private-attrs))))) + diff --git a/backend/src/app/rpc/mutations/ldap.clj b/backend/src/app/rpc/mutations/ldap.clj deleted file mode 100644 index 8ff5c2305..000000000 --- a/backend/src/app/rpc/mutations/ldap.clj +++ /dev/null @@ -1,141 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) UXBOX Labs SL - -(ns app.rpc.mutations.ldap - (:require - [app.common.exceptions :as ex] - [app.common.logging :as l] - [app.common.spec :as us] - [app.config :as cfg] - [app.db :as db] - [app.loggers.audit :as audit] - [app.rpc.mutations.profile :as profile-m] - [app.rpc.queries.profile :as profile-q] - [app.util.services :as sv] - [clj-ldap.client :as ldap] - [clojure.spec.alpha :as s] - [clojure.string])) - - -(s/def ::fullname ::us/not-empty-string) -(s/def ::email ::us/email) -(s/def ::backend ::us/not-empty-string) - -(s/def ::info-data - (s/keys :req-un [::fullname ::email ::backend])) - -(defn connect - ^java.lang.AutoCloseable - [] - (let [params {:ssl? (cfg/get :ldap-ssl) - :startTLS? (cfg/get :ldap-starttls) - :bind-dn (cfg/get :ldap-bind-dn) - :password (cfg/get :ldap-bind-password) - :host {:address (cfg/get :ldap-host) - :port (cfg/get :ldap-port)}}] - (try - (ldap/connect params) - (catch Exception e - (ex/raise :type :restriction - :code :ldap-disabled - :hint "ldap disabled or unable to connect" - :cause e))))) - -;; --- Mutation: login-with-ldap - -(declare authenticate) -(declare login-or-register) - -(s/def ::email ::us/email) -(s/def ::password ::us/string) -(s/def ::invitation-token ::us/string) - -(s/def ::login-with-ldap - (s/keys :req-un [::email ::password] - :opt-un [::invitation-token])) - -(sv/defmethod ::login-with-ldap {:auth false} - [{:keys [pool session tokens] :as cfg} params] - (db/with-atomic [conn pool] - (let [info (authenticate params) - cfg (assoc cfg :conn conn)] - - (when-not info - (ex/raise :type :validation - :code :wrong-credentials)) - - (when-not (s/valid? ::info-data info) - (let [explain (s/explain-str ::info-data info)] - (l/warn ::l/raw (str "invalid response from ldap, looks like ldap is not configured correctly\n" explain)) - (ex/raise :type :restriction - :code :wrong-ldap-response - :reason explain))) - - (let [profile (login-or-register cfg {:email (:email info) - :backend (:backend info) - :fullname (:fullname info)})] - (if-let [token (:invitation-token params)] - ;; If invitation token comes in params, this is because the - ;; user comes from team-invitation process; in this case, - ;; regenerate token and send back to the user a new invitation - ;; token (and mark current session as logged). - (let [claims (tokens :verify {:token token :iss :team-invitation}) - claims (assoc claims - :member-id (:id profile) - :member-email (:email profile)) - token (tokens :generate claims)] - (with-meta {:invitation-token token} - {:transform-response ((:create session) (:id profile)) - ::audit/props (:props profile) - ::audit/profile-id (:id profile)})) - - (with-meta profile - {:transform-response ((:create session) (:id profile)) - ::audit/props (:props profile) - ::audit/profile-id (:id profile)})))))) - -(defn- replace-several [s & {:as replacements}] - (reduce-kv clojure.string/replace s replacements)) - -(defn- get-ldap-user - [cpool {:keys [email] :as params}] - (let [query (-> (cfg/get :ldap-user-query) - (replace-several ":username" email)) - - attrs [(cfg/get :ldap-attrs-username) - (cfg/get :ldap-attrs-email) - (cfg/get :ldap-attrs-photo) - (cfg/get :ldap-attrs-fullname)] - - base-dn (cfg/get :ldap-base-dn) - params {:filter query - :sizelimit 1 - :attributes attrs}] - (first (ldap/search cpool base-dn params)))) - -(defn- authenticate - [{:keys [password email] :as params}] - (with-open [conn (connect)] - (when-let [{:keys [dn] :as luser} (get-ldap-user conn params)] - (when (ldap/bind? conn dn password) - {:photo (get luser (keyword (cfg/get :ldap-attrs-photo))) - :fullname (get luser (keyword (cfg/get :ldap-attrs-fullname))) - :email email - :backend "ldap"})))) - -(defn- login-or-register - [{:keys [conn] :as cfg} info] - (or (some->> (:email info) - (profile-q/retrieve-profile-data-by-email conn) - (profile-q/populate-additional-data conn) - (profile-q/decode-profile-row)) - (let [params (-> info - (assoc :is-active true) - (assoc :is-demo false))] - (->> params - (profile-m/create-profile conn) - (profile-m/create-profile-relations conn) - (profile-q/strip-private-attrs))))) diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj index e82e59a8c..dea366f5d 100644 --- a/backend/src/app/rpc/mutations/profile.clj +++ b/backend/src/app/rpc/mutations/profile.clj @@ -9,19 +9,18 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.spec :as us] - [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.emails :as eml] [app.loggers.audit :as audit] [app.media :as media] + [app.rpc.commands.auth :as cmd.auth] [app.rpc.mutations.teams :as teams] [app.rpc.queries.profile :as profile] [app.rpc.rlimit :as rlimit] [app.storage :as sto] [app.util.services :as sv] [app.util.time :as dt] - [buddy.hashers :as hashers] [clojure.spec.alpha :as s] [cuerdas.core :as str] [promesa.core :as p] @@ -37,310 +36,6 @@ (s/def ::password ::us/not-empty-string) (s/def ::old-password ::us/not-empty-string) (s/def ::theme ::us/string) -(s/def ::invitation-token ::us/not-empty-string) - -(declare check-profile-existence!) -(declare create-profile) -(declare create-profile-relations) -(declare register-profile) - -(defn email-domain-in-whitelist? - "Returns true if email's domain is in the given whitelist or if - given whitelist is an empty string." - [domains email] - (if (or (empty? domains) - (nil? domains)) - true - (let [[_ candidate] (-> (str/lower email) - (str/split #"@" 2))] - (contains? domains candidate)))) - -(def ^:private sql:profile-existence - "select exists (select * from profile - where email = ? - and deleted_at is null) as val") - -(defn check-profile-existence! - [conn {:keys [email] :as params}] - (let [email (str/lower email) - result (db/exec-one! conn [sql:profile-existence email])] - (when (:val result) - (ex/raise :type :validation - :code :email-already-exists)) - params)) - -(defn derive-password - [password] - (hashers/derive password - {:alg :argon2id - :memory 16384 - :iterations 20 - :parallelism 2})) - -(defn verify-password - [attempt password] - (try - (hashers/verify attempt password) - (catch Exception _e - {:update false - :valid false}))) - -(defn decode-profile-row - [{:keys [props] :as profile}] - (cond-> profile - (db/pgobject? props "jsonb") - (assoc :props (db/decode-transit-pgobject props)))) - -;; --- MUTATION: Prepare Register - -(s/def ::prepare-register-profile - (s/keys :req-un [::email ::password] - :opt-un [::invitation-token])) - -(sv/defmethod ::prepare-register-profile {:auth false} - [{:keys [pool tokens] :as cfg} params] - (when-not (contains? cf/flags :registration) - (if-not (contains? params :invitation-token) - (ex/raise :type :restriction - :code :registration-disabled) - (let [invitation (tokens :verify {:token (:invitation-token params) :iss :team-invitation})] - (when-not (= (:email params) (:member-email invitation)) - (ex/raise :type :restriction - :code :email-does-not-match-invitation - :hint "email should match the invitation"))))) - - (when-let [domains (cf/get :registration-domain-whitelist)] - (when-not (email-domain-in-whitelist? domains (:email params)) - (ex/raise :type :validation - :code :email-domain-is-not-allowed))) - - ;; Don't allow proceed in preparing registration if the profile is - ;; already reported as spammer. - (when (eml/has-bounce-reports? pool (:email params)) - (ex/raise :type :validation - :code :email-has-permanent-bounces - :hint "looks like the email has one or many bounces reported")) - - (check-profile-existence! pool params) - - (when (= (str/lower (:email params)) - (str/lower (:password params))) - (ex/raise :type :validation - :code :email-as-password - :hint "you can't use your email as password")) - - (let [params {:email (:email params) - :password (:password params) - :invitation-token (:invitation-token params) - :backend "penpot" - :iss :prepared-register - :exp (dt/in-future "48h")} - - token (tokens :generate params)] - (with-meta {:token token} - {::audit/profile-id uuid/zero}))) - -;; --- MUTATION: Register Profile - -(s/def ::token ::us/not-empty-string) -(s/def ::register-profile - (s/keys :req-un [::token ::fullname])) - -(sv/defmethod ::register-profile - {:auth false ::rlimit/permits (cf/get :rlimit-password)} - [{:keys [pool] :as cfg} params] - (db/with-atomic [conn pool] - (-> (assoc cfg :conn conn) - (register-profile params)))) - -(defn register-profile - [{:keys [conn tokens session] :as cfg} {:keys [token] :as params}] - (let [claims (tokens :verify {:token token :iss :prepared-register}) - params (merge params claims)] - (check-profile-existence! conn params) - (let [is-active (or (:is-active params) - (contains? cf/flags :insecure-register)) - profile (->> (assoc params :is-active is-active) - (create-profile conn) - (create-profile-relations conn) - (decode-profile-row)) - invitation (when-let [token (:invitation-token params)] - (tokens :verify {:token token :iss :team-invitation}))] - (cond - ;; If invitation token comes in params, this is because the user comes from team-invitation process; - ;; in this case, regenerate token and send back to the user a new invitation token (and mark current - ;; session as logged). This happens only if the invitation email matches with the register email. - (and (some? invitation) (= (:email profile) (:member-email invitation))) - (let [claims (assoc invitation :member-id (:id profile)) - token (tokens :generate claims) - resp {:invitation-token token}] - (with-meta resp - {:transform-response ((:create session) (:id profile)) - ::audit/replace-props (audit/profile->props profile) - ::audit/profile-id (:id profile)})) - - ;; If auth backend is different from "penpot" means user is - ;; registering using third party auth mechanism; in this case - ;; we need to mark this session as logged. - (not= "penpot" (:auth-backend profile)) - (with-meta (profile/strip-private-attrs profile) - {:transform-response ((:create session) (:id profile)) - ::audit/replace-props (audit/profile->props profile) - ::audit/profile-id (:id profile)}) - - ;; If the `:enable-insecure-register` flag is set, we proceed - ;; to sign in the user directly, without email verification. - (true? is-active) - (with-meta (profile/strip-private-attrs profile) - {:transform-response ((:create session) (:id profile)) - ::audit/replace-props (audit/profile->props profile) - ::audit/profile-id (:id profile)}) - - ;; In all other cases, send a verification email. - :else - (let [vtoken (tokens :generate - {:iss :verify-email - :exp (dt/in-future "48h") - :profile-id (:id profile) - :email (:email profile)}) - ptoken (tokens :generate-predefined - {:iss :profile-identity - :profile-id (:id profile)})] - (eml/send! {::eml/conn conn - ::eml/factory eml/register - :public-uri (:public-uri cfg) - :to (:email profile) - :name (:fullname profile) - :token vtoken - :extra-data ptoken}) - - (with-meta profile - {::audit/replace-props (audit/profile->props profile) - ::audit/profile-id (:id profile)})))))) - -(defn create-profile - "Create the profile entry on the database with limited input filling - all the other fields with defaults." - [conn params] - (let [id (or (:id params) (uuid/next)) - - props (-> (audit/extract-utm-params params) - (merge (:props params)) - (db/tjson)) - - password (if-let [password (:password params)] - (derive-password password) - "!") - - locale (:locale params) - locale (when (and (string? locale) (not (str/blank? locale))) - locale) - - backend (:backend params "penpot") - is-demo (:is-demo params false) - is-muted (:is-muted params false) - is-active (:is-active params false) - email (str/lower (:email params)) - - params {:id id - :fullname (:fullname params) - :email email - :auth-backend backend - :lang locale - :password password - :deleted-at (:deleted-at params) - :props props - :is-active is-active - :is-muted is-muted - :is-demo is-demo}] - (try - (-> (db/insert! conn :profile params) - (decode-profile-row)) - (catch org.postgresql.util.PSQLException e - (let [state (.getSQLState e)] - (if (not= state "23505") - (throw e) - (ex/raise :type :validation - :code :email-already-exists - :cause e))))))) - -(defn create-profile-relations - [conn profile] - (let [team (teams/create-team conn {:profile-id (:id profile) - :name "Default" - :is-default true})] - (-> profile - (profile/strip-private-attrs) - (assoc :default-team-id (:id team)) - (assoc :default-project-id (:default-project-id team))))) - -;; --- MUTATION: Login - -(s/def ::email ::us/email) -(s/def ::scope ::us/string) - -(s/def ::login - (s/keys :req-un [::email ::password] - :opt-un [::scope ::invitation-token])) - -(sv/defmethod ::login - {:auth false ::rlimit/permits (cf/get :rlimit-password)} - [{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}] - - (when-not (contains? cf/flags :login) - (ex/raise :type :restriction - :code :login-disabled - :hint "login is disabled in this instance")) - - (letfn [(check-password [profile password] - (when (= (:password profile) "!") - (ex/raise :type :validation - :code :account-without-password)) - (:valid (verify-password password (:password profile)))) - - (validate-profile [profile] - (when-not (:is-active profile) - (ex/raise :type :validation - :code :wrong-credentials)) - (when-not profile - (ex/raise :type :validation - :code :wrong-credentials)) - (when-not (check-password profile password) - (ex/raise :type :validation - :code :wrong-credentials)) - profile)] - - (db/with-atomic [conn pool] - (let [profile (->> (profile/retrieve-profile-data-by-email conn email) - (validate-profile) - (profile/strip-private-attrs) - (profile/populate-additional-data conn) - (decode-profile-row)) - - invitation (when-let [token (:invitation-token params)] - (tokens :verify {:token token :iss :team-invitation})) - - ;; If invitation member-id does not matches the profile-id, we just proceed to ignore the - ;; invitation because invitations matches exactly; and user can't loging with other email and - ;; accept invitation with other email - response (if (and (some? invitation) (= (:id profile) (:member-id invitation))) - {:invitation-token (:invitation-token params)} - profile)] - - (with-meta response - {:transform-response ((:create session) (:id profile)) - ::audit/props (audit/profile->props profile) - ::audit/profile-id (:id profile)}))))) - -;; --- MUTATION: Logout - -(s/def ::logout - (s/keys :opt-un [::profile-id])) - -(sv/defmethod ::logout {:auth false} - [{:keys [session] :as cfg} _] - (with-meta {} - {:transform-response (:delete session)})) ;; --- MUTATION: Update Profile (own) @@ -414,7 +109,7 @@ (defn- validate-password! [conn {:keys [profile-id old-password] :as params}] (let [profile (db/get-by-id conn :profile profile-id)] - (when-not (:valid (verify-password old-password (:password profile))) + (when-not (:valid (cmd.auth/verify-password old-password (:password profile))) (ex/raise :type :validation :code :old-password-not-match)) profile)) @@ -422,7 +117,7 @@ (defn update-profile-password! [conn {:keys [id password] :as profile}] (db/update! conn :profile - {:password (derive-password password)} + {:password (cmd.auth/derive-password password)} {:id id})) ;; --- MUTATION: Update Photo @@ -481,7 +176,7 @@ (defn- change-email-immediately [{:keys [conn]} {:keys [profile email] :as params}] (when (not= email (:email profile)) - (check-profile-existence! conn params)) + (cmd.auth/check-profile-existence! conn params)) (db/update! conn :profile {:email email} {:id (:id profile)}) @@ -499,7 +194,7 @@ :profile-id (:id profile)})] (when (not= email (:email profile)) - (check-profile-existence! conn params)) + (cmd.auth/check-profile-existence! conn params)) (when-not (eml/allow-send-emails? conn profile) (ex/raise :type :validation @@ -526,76 +221,6 @@ [conn id] (db/get-by-id conn :profile id {:for-update true})) -;; --- MUTATION: Request Profile Recovery - -(s/def ::request-profile-recovery - (s/keys :req-un [::email])) - -(sv/defmethod ::request-profile-recovery {:auth false} - [{:keys [pool tokens] :as cfg} {:keys [email] :as params}] - (letfn [(create-recovery-token [{:keys [id] :as profile}] - (let [token (tokens :generate - {:iss :password-recovery - :exp (dt/in-future "15m") - :profile-id id})] - (assoc profile :token token))) - - (send-email-notification [conn profile] - (let [ptoken (tokens :generate-predefined - {:iss :profile-identity - :profile-id (:id profile)})] - (eml/send! {::eml/conn conn - ::eml/factory eml/password-recovery - :public-uri (:public-uri cfg) - :to (:email profile) - :token (:token profile) - :name (:fullname profile) - :extra-data ptoken}) - nil))] - - (db/with-atomic [conn pool] - (when-let [profile (profile/retrieve-profile-data-by-email conn email)] - (when-not (eml/allow-send-emails? conn profile) - (ex/raise :type :validation - :code :profile-is-muted - :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) - - (when-not (:is-active profile) - (ex/raise :type :validation - :code :profile-not-verified - :hint "the user need to validate profile before recover password")) - - (when (eml/has-bounce-reports? conn (:email profile)) - (ex/raise :type :validation - :code :email-has-permanent-bounces - :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) - - (->> profile - (create-recovery-token) - (send-email-notification conn)))))) - - -;; --- MUTATION: Recover Profile - -(s/def ::token ::us/not-empty-string) -(s/def ::recover-profile - (s/keys :req-un [::token ::password])) - -(sv/defmethod ::recover-profile - {:auth false ::rlimit/permits (cf/get :rlimit-password)} - [{:keys [pool tokens] :as cfg} {:keys [token password]}] - (letfn [(validate-token [token] - (let [tdata (tokens :verify {:token token :iss :password-recovery})] - (:profile-id tdata))) - - (update-password [conn profile-id] - (let [pwd (derive-password password)] - (db/update! conn :profile {:password pwd} {:id profile-id})))] - - (db/with-atomic [conn pool] - (->> (validate-token token) - (update-password conn)) - nil))) ;; --- MUTATION: Update Profile Props @@ -668,3 +293,61 @@ :code :owner-teams-with-people :hint "The user need to transfer ownership of owned teams." :context {:teams (mapv :team-id rows)})))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; DEPRECATED METHODS (TO BE REMOVED ON 1.16.x) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; --- MUTATION: Login + +(s/def ::login ::cmd.auth/login-with-password) + +(sv/defmethod ::login + {:auth false ::rlimit/permits (cf/get :rlimit-password)} + [cfg params] + (cmd.auth/login-with-password cfg params)) + +;; --- MUTATION: Logout + +(s/def ::logout ::cmd.auth/logout) + +(sv/defmethod ::logout {:auth false} + [{:keys [session] :as cfg} _] + (with-meta {} + {:transform-response (:delete session)})) + +;; --- MUTATION: Recover Profile + +(s/def ::recover-profile ::cmd.auth/recover-profile) + +(sv/defmethod ::recover-profile + {:auth false ::rlimit/permits (cf/get :rlimit-password)} + [cfg params] + (cmd.auth/recover-profile cfg params)) + +;; --- MUTATION: Prepare Register + +(s/def ::prepare-register-profile ::cmd.auth/prepare-register-profile) + +(sv/defmethod ::prepare-register-profile {:auth false} + [cfg params] + (cmd.auth/prepare-register cfg params)) + +;; --- MUTATION: Register Profile + +(s/def ::register-profile ::cmd.auth/register-profile) + +(sv/defmethod ::register-profile + {:auth false ::rlimit/permits (cf/get :rlimit-password)} + [{:keys [pool] :as cfg} params] + (db/with-atomic [conn pool] + (-> (assoc cfg :conn conn) + (cmd.auth/register-profile params)))) + +;; --- MUTATION: Request Profile Recovery + +(s/def ::request-profile-recovery ::cmd.auth/request-profile-recovery) + +(sv/defmethod ::request-profile-recovery {:auth false} + [cfg params] + (cmd.auth/request-profile-recovery cfg params)) diff --git a/backend/src/app/srepl/dev.clj b/backend/src/app/srepl/dev.clj index d8d243296..61ec418f5 100644 --- a/backend/src/app/srepl/dev.clj +++ b/backend/src/app/srepl/dev.clj @@ -3,7 +3,7 @@ (:require [app.db :as db] [app.config :as cfg] - [app.rpc.mutations.profile :refer [derive-password]] + [app.rpc.commands.auth :refer [derive-password]] [app.main :refer [system]])) (defn reset-passwords diff --git a/backend/test/app/services_media_test.clj b/backend/test/app/services_media_test.clj index d0ce566b0..aa5f9f5ca 100644 --- a/backend/test/app/services_media_test.clj +++ b/backend/test/app/services_media_test.clj @@ -46,7 +46,13 @@ (t/is (sto/storage-object? mobj1)) (t/is (sto/storage-object? mobj2)) (t/is (= 122785 (:size mobj1))) - (t/is (= 3303 (:size mobj2))))) + + ;; This is because in ubuntu 21.04 generates different + ;; thumbnail that in ubuntu 22.04. This hack should be removed + ;; when we all use the ubuntu 22.04 devenv image. + (t/is (or + (= 3302 (:size mobj2)) + (= 3303 (:size mobj2)))))) )) (t/deftest media-object-upload diff --git a/backend/test/app/services_profile_test.clj b/backend/test/app/services_profile_test.clj index c87e7cfa8..f9c4bd10a 100644 --- a/backend/test/app/services_profile_test.clj +++ b/backend/test/app/services_profile_test.clj @@ -10,6 +10,7 @@ [app.config :as cf] [app.db :as db] [app.rpc.mutations.profile :as profile] + [app.rpc.commands.auth :as cauth] [app.test-helpers :as th] [app.util.time :as dt] [clojure.java.io :as io] @@ -27,11 +28,10 @@ ;; Test with wrong credentials (t/deftest profile-login-failed-1 (let [profile (th/create-profile* 1) - data {::th/type :login + data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "foobar" - :scope "foobar"} - out (th/mutation! data)] + :password "foobar"} + out (th/command! data)] #_(th/print-result! out) (let [error (:error out)] @@ -42,11 +42,10 @@ ;; Test with good credentials but profile not activated. (t/deftest profile-login-failed-2 (let [profile (th/create-profile* 1) - data {::th/type :login + data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "123123" - :scope "foobar"} - out (th/mutation! data)] + :password "123123"} + out (th/command! data)] ;; (th/print-result! out) (let [error (:error out)] (t/is (th/ex-info? error)) @@ -58,8 +57,7 @@ (let [profile (th/create-profile* 1 {:is-active true}) data {::th/type :login :email "profile1.test@nodomain.com" - :password "123123" - :scope "foobar"} + :password "123123"} out (th/mutation! data)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -161,11 +159,11 @@ (t/deftest registration-domain-whitelist (let [whitelist #{"gmail.com" "hey.com" "ya.ru"}] (t/testing "allowed email domain" - (t/is (true? (profile/email-domain-in-whitelist? whitelist "username@ya.ru"))) - (t/is (true? (profile/email-domain-in-whitelist? #{} "username@somedomain.com")))) + (t/is (true? (cauth/email-domain-in-whitelist? whitelist "username@ya.ru"))) + (t/is (true? (cauth/email-domain-in-whitelist? #{} "username@somedomain.com")))) (t/testing "not allowed email domain" - (t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) + (t/is (false? (cauth/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) (t/deftest prepare-register-and-register-profile (let [data {::th/type :prepare-register-profile diff --git a/backend/test/app/test_helpers.clj b/backend/test/app/test_helpers.clj index f94e60701..8849ac3cc 100644 --- a/backend/test/app/test_helpers.clj +++ b/backend/test/app/test_helpers.clj @@ -9,14 +9,15 @@ [app.common.data :as d] [app.common.flags :as flags] [app.common.pages :as cp] + [app.common.pprint :as pp] [app.common.spec :as us] [app.common.uuid :as uuid] - [app.common.pprint :as pp] [app.config :as cf] [app.db :as db] [app.main :as main] [app.media] [app.migrations] + [app.rpc.commands.auth :as cmd.auth] [app.rpc.mutations.files :as files] [app.rpc.mutations.profile :as profile] [app.rpc.mutations.projects :as projects] @@ -31,8 +32,8 @@ [expound.alpha :as expound] [integrant.core :as ig] [mockery.core :as mk] - [yetti.request :as yrq] - [promesa.core :as p]) + [promesa.core :as p] + [yetti.request :as yrq]) (:import org.postgresql.ds.PGSimpleDataSource)) (def ^:dynamic *system* nil) @@ -59,10 +60,12 @@ :app.http/router :app.http.awsns/handler :app.http.session/updater - :app.http.oauth/google - :app.http.oauth/gitlab - :app.http.oauth/github - :app.http.oauth/all + :app.auth.oidc/google-provider + :app.auth.oidc/gitlab-provider + :app.auth.oidc/github-provider + :app.auth.oidc/generic-provider + :app.auth.oidc/routes + ;; :app.auth.ldap/provider :app.worker/executors-monitor :app.http.oauth/handler :app.notifications/handler @@ -81,9 +84,9 @@ (try (binding [*system* system *pool* (:app.db/pool system)] - (mk/with-mocks [mock1 {:target 'app.rpc.mutations.profile/derive-password + (mk/with-mocks [mock1 {:target 'app.rpc.commands.auth/derive-password :return identity} - mock2 {:target 'app.rpc.mutations.profile/verify-password + mock2 {:target 'app.rpc.commands.auth/verify-password :return (fn [a b] {:valid (= a b)})}] (next))) (finally @@ -140,8 +143,8 @@ :is-demo false} params)] (->> params - (#'profile/create-profile conn) - (#'profile/create-profile-relations conn))))) + (cmd.auth/create-profile conn) + (cmd.auth/create-profile-relations conn))))) (defn create-project* ([i params] (create-project* *pool* i params)) @@ -267,17 +270,21 @@ {:error (handle-error e#) :result nil}))) +(defn command! + [{:keys [::type] :as data}] + (let [method-fn (get-in *system* [:app.rpc/methods :commands type])] + ;; (app.common.pprint/pprint (:app.rpc/methods *system*)) + (try-on! (method-fn (dissoc data ::type))))) + (defn mutation! [{:keys [::type] :as data}] - (let [method-fn (get-in *system* [:app.rpc/rpc :methods :mutation type])] - (try-on! - (method-fn (dissoc data ::type))))) + (let [method-fn (get-in *system* [:app.rpc/methods :mutations type])] + (try-on! (method-fn (dissoc data ::type))))) (defn query! [{:keys [::type] :as data}] - (let [method-fn (get-in *system* [:app.rpc/rpc :methods :query type])] - (try-on! - (method-fn (dissoc data ::type))))) + (let [method-fn (get-in *system* [:app.rpc/methods :queries type])] + (try-on! (method-fn (dissoc data ::type))))) ;; --- UTILS diff --git a/docker/images/config.env b/docker/images/config.env index eccfbe0bf..cc880d70f 100644 --- a/docker/images/config.env +++ b/docker/images/config.env @@ -97,4 +97,3 @@ PENPOT_SMTP_DEFAULT_REPLY_TO=no-reply@example.com # PENPOT_LDAP_ATTRS_USERNAME=uid # PENPOT_LDAP_ATTRS_EMAIL=mail # PENPOT_LDAP_ATTRS_FULLNAME=cn -# PENPOT_LDAP_ATTRS_PHOTO=jpegPhoto diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 270b7e26a..1e349e586 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -4,69 +4,10 @@ log() { echo "[$(date +%Y-%m-%dT%H:%M:%S%:z)] $*" } - ######################################### ## App Frontend config ######################################### -update_google_client_id() { - if [ -n "$PENPOT_GOOGLE_CLIENT_ID" ]; then - log "Updating Google Client Id: $PENPOT_GOOGLE_CLIENT_ID" - sed -i \ - -e "s|^//var penpotGoogleClientID = \".*\";|var penpotGoogleClientID = \"$PENPOT_GOOGLE_CLIENT_ID\";|g" \ - "$1" - fi -} - - -update_gitlab_client_id() { - if [ -n "$PENPOT_GITLAB_CLIENT_ID" ]; then - log "Updating GitLab Client Id: $PENPOT_GITLAB_CLIENT_ID" - sed -i \ - -e "s|^//var penpotGitlabClientID = \".*\";|var penpotGitlabClientID = \"$PENPOT_GITLAB_CLIENT_ID\";|g" \ - "$1" - fi -} - - -update_github_client_id() { - if [ -n "$PENPOT_GITHUB_CLIENT_ID" ]; then - log "Updating GitHub Client Id: $PENPOT_GITHUB_CLIENT_ID" - sed -i \ - -e "s|^//var penpotGithubClientID = \".*\";|var penpotGithubClientID = \"$PENPOT_GITHUB_CLIENT_ID\";|g" \ - "$1" - fi -} - -update_oidc_client_id() { - if [ -n "$PENPOT_OIDC_CLIENT_ID" ]; then - log "Updating Oidc Client Id: $PENPOT_OIDC_CLIENT_ID" - sed -i \ - -e "s|^//var penpotOIDCClientID = \".*\";|var penpotOIDCClientID = \"$PENPOT_OIDC_CLIENT_ID\";|g" \ - "$1" - fi -} - -# DEPRECATED -update_login_with_ldap() { - if [ -n "$PENPOT_LOGIN_WITH_LDAP" ]; then - log "Updating Login with LDAP: $PENPOT_LOGIN_WITH_LDAP" - sed -i \ - -e "s|^//var penpotLoginWithLDAP = .*;|var penpotLoginWithLDAP = $PENPOT_LOGIN_WITH_LDAP;|g" \ - "$1" - fi -} - -# DEPRECATED -update_registration_enabled() { - if [ -n "$PENPOT_REGISTRATION_ENABLED" ]; then - log "Updating Registration Enabled: $PENPOT_REGISTRATION_ENABLED" - sed -i \ - -e "s|^//var penpotRegistrationEnabled = .*;|var penpotRegistrationEnabled = $PENPOT_REGISTRATION_ENABLED;|g" \ - "$1" - fi -} - update_flags() { if [ -n "$PENPOT_FLAGS" ]; then sed -i \ @@ -75,11 +16,5 @@ update_flags() { fi } -update_google_client_id /var/www/app/js/config.js -update_gitlab_client_id /var/www/app/js/config.js -update_github_client_id /var/www/app/js/config.js -update_oidc_client_id /var/www/app/js/config.js -update_login_with_ldap /var/www/app/js/config.js -update_registration_enabled /var/www/app/js/config.js update_flags /var/www/app/js/config.js exec "$@"; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 7cf0ede1a..47906059f 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -80,10 +80,6 @@ (def default-theme "default") (def default-language "en") -(def google-client-id (obj/get global "penpotGoogleClientID" nil)) -(def gitlab-client-id (obj/get global "penpotGitlabClientID" nil)) -(def github-client-id (obj/get global "penpotGithubClientID" nil)) -(def oidc-client-id (obj/get global "penpotOIDCClientID" nil)) (def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js")) (def translations (obj/get global "penpotTranslations")) (def themes (obj/get global "penpotThemes")) @@ -100,14 +96,6 @@ (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI" nil)) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI" nil)) -;; maintain for backward compatibility -(let [login-with-ldap (obj/get global "penpotLoginWithLDAP" false) - registration (obj/get global "penpotRegistrationEnabled" true)] - (when login-with-ldap - (swap! flags conj :login-with-ldap)) - (when (false? registration) - (swap! flags disj :registration))) - (defn get-public-uri [] (let [uri (u/uri (or (obj/get global "penpotPublicURI") diff --git a/frontend/src/app/main/data/exports.cljs b/frontend/src/app/main/data/exports.cljs index 768108d8e..cb70f6362 100644 --- a/frontend/src/app/main/data/exports.cljs +++ b/frontend/src/app/main/data/exports.cljs @@ -145,7 +145,7 @@ ptk/WatchEvent (watch [_ _ _] (when (= status "ended") - (->> (rp/query! :exporter {:cmd :get-resource :blob? true :id resource-id}) + (->> (rp/command! :export {:cmd :get-resource :blob? true :id resource-id}) (rx/delay 500) (rx/map #(dom/trigger-download filename %))))))) @@ -165,9 +165,9 @@ :wait true}] (rx/concat (rx/of ::dwp/force-persist) - (->> (rp/query! :exporter params) + (->> (rp/command! :export params) (rx/mapcat (fn [{:keys [id filename]}] - (->> (rp/query! :exporter {:cmd :get-resource :blob? true :id id}) + (->> (rp/command! :export {:cmd :get-resource :blob? true :id id}) (rx/map (fn [data] (dom/trigger-download filename data) (clear-export-state uuid/zero)))))) @@ -213,7 +213,7 @@ ;; Launch the exportation process and stores the resource id ;; locally. - (->> (rp/query! :exporter params) + (->> (rp/command! :export params) (rx/map (fn [{:keys [id] :as resource}] (vreset! resource-id id) (initialize-export-status exports cmd resource)))) diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index eee3b49cc..9a98a17b9 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -206,7 +206,7 @@ ;; the returned profile is an NOT authenticated profile, we ;; proceed to logout and show an error message. - (->> (rp/mutation :login (d/without-nils params)) + (->> (rp/command :login-with-password (d/without-nils params)) (rx/merge-map (fn [data] (rx/merge (rx/of (fetch-profile)) @@ -292,7 +292,7 @@ (ptk/reify ::logout ptk/WatchEvent (watch [_ _ _] - (->> (rp/mutation :logout) + (->> (rp/command :logout) (rx/delay-at-least 300) (rx/catch (constantly (rx/of 1))) (rx/map #(logged-out params))))))) @@ -494,7 +494,7 @@ :or {on-error rx/throw on-success identity}} (meta data)] - (->> (rp/mutation :request-profile-recovery data) + (->> (rp/command :request-profile-recovery data) (rx/tap on-success) (rx/catch on-error)))))) @@ -513,7 +513,7 @@ (let [{:keys [on-error on-success] :or {on-error rx/throw on-success identity}} (meta data)] - (->> (rp/mutation :recover-profile data) + (->> (rp/command :recover-profile data) (rx/tap on-success) (rx/catch on-error)))))) @@ -524,7 +524,7 @@ (ptk/reify ::create-demo-profile ptk/WatchEvent (watch [_ _ _] - (->> (rp/mutation :create-demo-profile {}) + (->> (rp/command :create-demo-profile {}) (rx/map login))))) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index ee068f205..b1d7b581c 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -73,10 +73,22 @@ (rx/map http/conditional-decode-transit) (rx/mapcat handle-response))) +(defn- send-command! + "A simple helper for a common case of sending and receiving transit + data to the penpot mutation api." + [id params] + (->> (http/send! {:method :post + :uri (u/join base-uri "api/rpc/command/" (name id)) + :credentials "include" + :body (http/transit-data params)}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + (defn- dispatch [& args] (first args)) (defmulti query dispatch) (defmulti mutation dispatch) +(defmulti command dispatch) (defmethod query :default [id params] @@ -90,6 +102,10 @@ [id params] (send-mutation! id params)) +(defmethod command :default + [id params] + (send-command! id params)) + (defn query! ([id] (query id {})) ([id params] (query id params))) @@ -98,7 +114,11 @@ ([id] (mutation id {})) ([id params] (mutation id params))) -(defmethod mutation :login-with-oauth +(defn command! + ([id] (command id {})) + ([id params] (command id params))) + +(defmethod command :login-with-oidc [_ {:keys [provider] :as params}] (let [uri (u/join base-uri "api/auth/oauth/" (d/name provider)) params (dissoc params :provider)] @@ -109,7 +129,7 @@ (rx/map http/conditional-decode-transit) (rx/mapcat handle-response)))) -(defmethod mutation :send-feedback +(defmethod command :send-feedback [_ params] (->> (http/send! {:method :post :uri (u/join base-uri "api/feedback") @@ -128,7 +148,7 @@ (rx/map http/conditional-decode-transit) (rx/mapcat handle-response))) -(defmethod query :exporter +(defmethod command :export [_ params] (let [default {:wait false :blob? false}] (send-export (merge default params)))) diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index 8c9cc7b0a..1a1864a73 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -23,10 +23,11 @@ [rumext.alpha :as mf])) (def show-alt-login-buttons? - (or cf/google-client-id - cf/gitlab-client-id - cf/github-client-id - cf/oidc-client-id)) + (some (partial contains? @cf/flags) + [:login-with-google + :login-with-github + :login-with-gitlab + :login-with-oidc])) (s/def ::email ::us/email) (s/def ::password ::us/not-empty-string) @@ -36,19 +37,27 @@ (s/keys :req-un [::email ::password] :opt-un [::invitation-token])) -(defn- login-with-oauth +(defn- login-with-oidc [event provider params] (dom/prevent-default event) - (->> (rp/mutation! :login-with-oauth (assoc params :provider provider)) + (->> (rp/command! :login-with-oidc (assoc params :provider provider)) (rx/subs (fn [{:keys [redirect-uri] :as rsp}] - (.replace js/location redirect-uri))))) + (.replace js/location redirect-uri)) + (fn [{:keys [type code] :as error}] + (cond + (and (= type :restriction) + (= code :provider-not-configured)) + (st/emit! (dm/error (tr "errors.auth-provider-not-configured"))) + + :else + (st/emit! (dm/error (tr "errors.generic")))))))) (defn- login-with-ldap [event params] (dom/prevent-default event) (dom/stop-propagation event) (let [{:keys [on-error]} (meta params)] - (->> (rp/mutation! :login-with-ldap params) + (->> (rp/command! :login-with-ldap params) (rx/subs (fn [profile] (if-let [token (:invitation-token profile)] (st/emit! (rt/nav :auth-verify-token {} {:token token})) @@ -56,11 +65,15 @@ (fn [{:keys [type code] :as error}] (cond (and (= type :restriction) - (= code :ldap-disabled)) + (= code :ldap-not-initialized)) (st/emit! (dm/error (tr "errors.ldap-disabled"))) (fn? on-error) - (on-error error))))))) + (on-error error) + + :else + (st/emit! (dm/error (tr "errors.generic"))))))))) + (mf/defc login-form [{:keys [params] :as props}] @@ -134,35 +147,35 @@ (mf/defc login-buttons [{:keys [params] :as props}] [:div.auth-buttons - (when cf/google-client-id + (when (contains? @cf/flags :login-with-google) [:a.btn-primary.btn-large.btn-google-auth - {:on-click #(login-with-oauth % :google params)} + {:on-click #(login-with-oidc % :google params)} [:span.logo i/brand-google] (tr "auth.login-with-google-submit")]) - (when cf/github-client-id + (when (contains? @cf/flags :login-with-github) [:a.btn-primary.btn-large.btn-github-auth - {:on-click #(login-with-oauth % :github params)} + {:on-click #(login-with-oidc % :github params)} [:span.logo i/brand-github] (tr "auth.login-with-github-submit")]) - (when cf/gitlab-client-id + (when (contains? @cf/flags :login-with-gitlab) [:a.btn-primary.btn-large.btn-gitlab-auth - {:on-click #(login-with-oauth % :gitlab params)} + {:on-click #(login-with-oidc % :gitlab params)} [:span.logo i/brand-gitlab] (tr "auth.login-with-gitlab-submit")]) - (when cf/oidc-client-id + (when (contains? @cf/flags :login-with-oidc) [:a.btn-primary.btn-large.btn-github-auth - {:on-click #(login-with-oauth % :oidc params)} + {:on-click #(login-with-oidc % :oidc params)} [:span.logo i/brand-openid] (tr "auth.login-with-oidc-submit")])]) (mf/defc login-button-oidc [{:keys [params] :as props}] - (when cf/oidc-client-id + (when (contains? @cf/flags :login-with-oidc) [:div.link-entry.link-oidc - [:a {:on-click #(login-with-oauth % :oidc params)} + [:a {:on-click #(login-with-oidc % :oidc params)} (tr "auth.login-with-oidc-submit")]])) (mf/defc login-page diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 739b7ad1d..c721f4389 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -84,7 +84,7 @@ (fn [form _event] (reset! submitted? true) (let [cdata (:clean-data @form)] - (->> (rp/mutation :prepare-register-profile cdata) + (->> (rp/command :prepare-register-profile cdata) (rx/map #(merge % params)) (rx/finalize #(reset! submitted? false)) (rx/subs (partial handle-prepare-register-success form) @@ -207,7 +207,7 @@ (fn [form _event] (reset! submitted? true) (let [params (:clean-data @form)] - (->> (rp/mutation :register-profile params) + (->> (rp/command :register-profile params) (rx/finalize #(reset! submitted? false)) (rx/subs (partial handle-register-success form) (partial handle-register-error form)))))) diff --git a/frontend/src/app/main/ui/settings/feedback.cljs b/frontend/src/app/main/ui/settings/feedback.cljs index 1bd671406..4b5055d85 100644 --- a/frontend/src/app/main/ui/settings/feedback.cljs +++ b/frontend/src/app/main/ui/settings/feedback.cljs @@ -55,7 +55,7 @@ (fn [form _] (reset! loading true) (let [data (:clean-data @form)] - (->> (rp/mutation! :send-feedback data) + (->> (rp/command! :send-feedback data) (rx/subs on-succes on-error)))))] [:& fm/form {:class "feedback-form" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3ae8bfe83..4f4889e30 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -699,6 +699,10 @@ msgstr "This invite might be canceled or may be expired." msgid "errors.ldap-disabled" msgstr "LDAP authentication is disabled." +#: src/app/main/ui/auth/login.cljs +msgid "errors.auth-provider-not-configured" +msgstr "Authentication provider not configured." + msgid "errors.media-format-unsupported" msgstr "The image format is not supported (must be svg, jpg or png)."