diff --git a/backend/src/app/auth.clj b/backend/src/app/auth.clj index 1f978f357c..efc508936a 100644 --- a/backend/src/app/auth.clj +++ b/backend/src/app/auth.clj @@ -14,10 +14,21 @@ :iterations 3 :parallelism 2}) +(def ^:private weak-options + {:alg :pbkdf2+sha256 + :iterations 100}) + (defn derive-password [password] (hashers/derive password default-options)) +(defn derive-password-weak + "Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations). + Intended for demo users only — they are already gated behind the + `demo-users` config flag which is disabled in production." + [password] + (hashers/derive password weak-options)) + (defn verify-password [attempt password] (try diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 743a17804b..d0ffd1cf58 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -392,6 +392,8 @@ :delete-object (ig/ref :app.tasks.delete-object/handler) + :demo-purge + (ig/ref :app.tasks.demo-purge/handler) :process-webhook-event (ig/ref ::webhooks/process-event-handler) :run-webhook @@ -429,6 +431,9 @@ :app.tasks.delete-object/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.demo-purge/handler + {::db/pool (ig/ref ::db/pool)} + :app.tasks.file-gc/handler {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 13b7a2f374..56baa1916b 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -7,9 +7,9 @@ (ns app.rpc.commands.demo "A demo specific mutations." (:require - [app.auth :refer [derive-password]] + [app.auth :refer [derive-password-weak]] [app.common.exceptions :as ex] - [app.common.time :as ct] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] @@ -17,6 +17,7 @@ [app.rpc.commands.auth :as auth] [app.rpc.doc :as-alias doc] [app.util.services :as sv] + [app.worker :as wrk] [buddy.core.codecs :as bc] [buddy.core.nonce :as bn])) @@ -34,8 +35,8 @@ :code :demo-users-not-allowed :hint "Demo users are disabled by config.")) - (let [sem (System/currentTimeMillis) - email (str "demo-" sem ".demo@example.com") + (let [sem (uuid/next) + email (str "demo-" sem "@demo.example.com") fullname (str "Demo User " sem) password (-> (bn/random-bytes 16) @@ -46,13 +47,17 @@ :fullname fullname :is-active true :is-demo true - :deleted-at (ct/in-future (cf/get-deletion-delay)) - :password (derive-password password) + :password (derive-password-weak password) :props {}} profile (db/tx-run! cfg (fn [cfg] (->> (auth/create-profile cfg params) (auth/create-profile-rels cfg))))] + + (wrk/submit! (-> cfg + (assoc ::wrk/task :demo-purge) + (assoc ::wrk/delay (cf/get-deletion-delay)) + (assoc ::wrk/params {:profile-id (:id profile)}))) + (with-meta {:email email :password password} {::audit/profile-id (:id profile)}))) - diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 4027efc6a4..22c7e3dfa5 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -141,9 +141,7 @@ (defn get-profile "Get profile by id. Throws not-found exception if no profile found." [conn id & {:as opts}] - ;; NOTE: We need to set ::db/remove-deleted to false because demo profiles - ;; are created with a set deleted-at value - (-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false)) + (-> (db/get-by-id conn :profile id opts) (decode-row))) ;; --- MUTATION: Update Profile (own) diff --git a/backend/src/app/tasks/demo_purge.clj b/backend/src/app/tasks/demo_purge.clj new file mode 100644 index 0000000000..429816c053 --- /dev/null +++ b/backend/src/app/tasks/demo_purge.clj @@ -0,0 +1,41 @@ +;; 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) KALEIDOS INC Sucursal en España SL + +(ns app.tasks.demo-purge + "Task handler for delayed demo profile deletion. Submitted at demo + creation time with a delay matching the configured deletion-delay." + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.db :as db] + [app.worker :as wrk] + [integrant.core :as ig])) + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [{:keys [props]}] + (let [profile-id (get props :profile-id) + now (ct/now)] + + (l/trc :hint "demo-purge" :profile-id (str profile-id)) + + ;; Mark the profile for immediate deletion + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (db/update! conn :profile + {:deleted-at now} + {:id profile-id} + {::db/return-keys false}) + (wrk/submit! + (-> cfg + (assoc ::wrk/task :delete-object) + (assoc ::wrk/params {:object :profile + :deleted-at now + :id profile-id})))))))) diff --git a/backend/test/backend_tests/demo_test.clj b/backend/test/backend_tests/demo_test.clj new file mode 100644 index 0000000000..da1cc342c9 --- /dev/null +++ b/backend/test/backend_tests/demo_test.clj @@ -0,0 +1,47 @@ +;; 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) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.demo-test + (:require + [app.common.time :as ct] + [app.db :as db] + [app.rpc.commands.profile :as profile] + [app.tasks.demo-purge :as demo-purge] + [app.worker :as wrk] + [backend-tests.helpers :as th] + [clojure.test :as t] + [integrant.core :as ig])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest demo-profile-created-without-deleted-at + (let [profile (th/create-profile* 999 {:is-demo true})] + (t/is (true? (:is-demo profile))) + (t/is (nil? (:deleted-at profile))) + (t/is (some? (:id profile))))) + +(t/deftest get-profile-finds-demo-user-without-override + (let [profile (th/create-profile* 998 {:is-demo true}) + found (db/run! th/*pool* + (fn [{:keys [::db/conn]}] + (profile/get-profile conn (:id profile))))] + (t/is (some? found)) + (t/is (= (:id profile) (:id found))))) + +(t/deftest demo-purge-handler-submits-delete-object + (let [profile (th/create-profile* 996 {:is-demo true}) + handler (ig/init-key :app.tasks.demo-purge/handler + {::db/pool th/*pool*}) + submitted (atom nil)] + (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}] + (reset! submitted {:task task :params params}))] + (handler {:props {:profile-id (:id profile) + :deleted-at (ct/now)}})) + (t/is (= :delete-object (:task @submitted))) + (t/is (= :profile (:object (:params @submitted)))) + (t/is (= (:id profile) (:id (:params @submitted)))) + (t/is (some? (:deleted-at (:params @submitted)))))) diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj new file mode 100644 index 0000000000..d990ec8a32 --- /dev/null +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -0,0 +1,40 @@ +;; 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) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-demo-test + (:require + [app.auth :as auth] + [app.config :as cf] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; Capture the real verifier before the shared test fixture replaces it. +(def verify-password* auth/verify-password) + +(t/deftest weak-password-hash-verifies + (let [password "DemoPassword123!" + hashed (auth/derive-password-weak password)] + (t/is (:valid (verify-password* password hashed))))) + +(t/deftest create-demo-profile-uses-unique-uuid-email + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [first-result (th/command! {::th/type :create-demo-profile}) + second-result (th/command! {::th/type :create-demo-profile}) + first-profile (:result first-result) + second-profile (:result second-result)] + (t/is (nil? (:error first-result))) + (t/is (nil? (:error second-result))) + (t/is (re-matches #"demo-[0-9a-fA-F-]+@demo\.example\.com" + (:email first-profile))) + (t/is (not= (:email first-profile) (:email second-profile)))))) + +(t/deftest create-demo-profile-requires-feature-flag + (with-redefs [cf/flags (disj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile})] + (t/is (th/ex-of-code? error :demo-users-not-allowed))))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 0f86a64cb2..106b2c498d 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -59,8 +59,13 @@ (let [out (th/command! {::th/type :create-upload-session ::rpc/profile-id (:id prof) :total-chunks total-chunks})] - (t/is (nil? (:error out))) - (:session-id (:result out)))) + (let [session-id (:session-id (:result out))] + (t/is (nil? (:error out)) + (str "create-upload-session failed: " + (some-> (:error out) ex-data))) + (t/is (uuid? session-id) + (str "create-upload-session returned an invalid session-id: " session-id)) + session-id))) (defn- upload-font-chunked! "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload @@ -68,14 +73,15 @@ [prof ^bytes font-bytes mtype chunk-size] (let [chunks (split-bytes-into-chunks font-bytes chunk-size) session-id (create-upload-session! prof (count chunks))] - (doseq [[idx chunk-data] (map-indexed vector chunks)] - (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] - (t/is (nil? (:error out))))) + (when (uuid? session-id) + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out)))))) session-id)) (defn- assert-font-variant-result @@ -613,43 +619,42 @@ ;; N2-07: A user with edit permissions on their own team must not be ;; able to create a font variant using a font-id that already belongs ;; to another team (BOLA / CWE-639). - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof1 (th/create-profile* 1 {:is-active true}) - prof2 (th/create-profile* 2 {:is-active true}) - team1 (:default-team-id prof1) - team2 (:default-team-id prof2) - font-id (uuid/custom 10 999) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*))] + (let [prof1 (th/create-profile* 1 {:is-active true}) + prof2 (th/create-profile* 2 {:is-active true}) + team1 (:default-team-id prof1) + team2 (:default-team-id prof2) + font-id (uuid/custom 10 999) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") + (io/read*))] - ;; prof1 creates a font variant in team1 with font-id - (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof1) - :team-id team1 - :font-id font-id - :font-family "SharedFont" - :font-weight 400 - :font-style "normal" - :uploads {"font/ttf" session-id}} - out (th/command! params)] - (t/is (nil? (:error out)))) + ;; prof1 creates a font variant in team1 with font-id + (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof1) + :team-id team1 + :font-id font-id + :font-family "SharedFont" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + (t/is (nil? (:error out)))) - ;; prof2 tries to create a variant using the same font-id but - ;; in team2, which must be rejected because font-id belongs to team1 - (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof2) - :team-id team2 - :font-id font-id - :font-family "SharedFont" - :font-weight 700 - :font-style "normal" - :uploads {"font/ttf" session-id}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :not-found (-> out :error ex-data :type))) - (t/is (= :object-not-found (-> out :error ex-data :code))))))) + ;; prof2 tries to create a variant using the same font-id but + ;; in team2, which must be rejected because font-id belongs to team1 + (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof2) + :team-id team2 + :font-id font-id + :font-family "SharedFont" + :font-weight 700 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) (t/deftest get-font-variants-nonexistent-file (let [prof (th/create-profile* 1 {:is-active true})