diff --git a/backend/scripts/_env b/backend/scripts/_env index 724b55f05b..5d92649eaa 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -48,6 +48,7 @@ export PENPOT_FLAGS="\ enable-user-feedback \ disable-secure-session-cookies \ enable-smtp \ + enable-account-lockout \ enable-prepl-server \ enable-urepl-server \ enable-nrepl-server \ diff --git a/backend/src/app/auth/login_lockout.clj b/backend/src/app/auth/login_lockout.clj new file mode 100644 index 0000000000..2699e49d15 --- /dev/null +++ b/backend/src/app/auth/login_lockout.clj @@ -0,0 +1,132 @@ +;; 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 SUBSIDIARY SL + +(ns app.auth.login-lockout + "Brute-force protection: per-account failed login counter backed by Redis. + Uses an atomic Lua script for the increment operation to prevent + concurrent login bypass. Stores count:window_start in the value; + time is passed from Clojure (ct/now) via ARGV, keeping it injectable + for tests. When the counter reaches the configured threshold within + the time window, the account is locked out until the window expires." + (:require + [app.common.generic-pool :as gpool] + [app.common.logging :as l] + [app.common.time :as ct] + [app.config :as cf] + [app.redis :as rds] + [app.redis.script :as-alias rscript] + [clojure.string :as str]) + (:import + java.lang.AutoCloseable)) + +(def ^:private key-prefix "penpot.login-lockout.") + +(def ^:private lockout-script + {::rscript/name ::login-lockout + ::rscript/path "app/auth/login_lockout.lua"}) + +(defn- get-pool + [cfg] + (:app.redis/pool cfg)) + +(defn- with-conn + [cfg f] + (let [pool (get-pool cfg) + conn (gpool/get pool)] + (try + (f @conn) + (finally + (.close ^AutoCloseable conn))))) + +(defn- parse-value + "Parse stored \"count:window_start\" string. Returns nil if missing + or expired. Throws NumberFormatException for malformed values + (caught by caller → fail-open)." + [value window-ms now-ms] + (when value + (let [sep (str/index-of value ":")] + (when sep + (let [count (Long/parseLong (subs value 0 sep)) + start (Long/parseLong (subs value (inc sep)))] + (when (> (+ start window-ms) now-ms) + {:count count :window-start start})))))) + +(defn record-failed-attempt! + "Increment the failed-login counter for a profile-id (atomic via Lua). + Returns nil when the flag is disabled or on Redis error (fail-open). + Otherwise returns a map with :count (int), :ttl (int, seconds), + and :locked? (boolean)." + [cfg profile-id] + (when (contains? cf/flags :account-lockout) + (try + (let [threshold (cf/get :login-lockout-max-attempts) + window (cf/get :login-lockout-window) + window-ms (if (integer? window) window (.toMillis window)) + _ (assert (>= threshold 1) "login-lockout-max-attempts must be >= 1") + _ (assert (>= window-ms 60000) "login-lockout-window must be >= 60000ms (1 minute)") + key (str key-prefix profile-id) + now-ms (inst-ms (ct/now)) + result (with-conn cfg + (fn [conn] + (rds/eval conn + (assoc lockout-script + ::rscript/keys [key] + ::rscript/vals [threshold window-ms now-ms])))) + [count locked ttl] result] + {:count count + :locked? (= 1 locked) + :ttl (max 0 ttl)}) + (catch Exception cause + (l/warn :hint "redis unavailable, failing open on login lockout" + :profile-id (str profile-id) + :cause cause) + nil)))) + +(defn clear-attempts! + "Clear the failed-login counter (on successful login or password reset). + No-op when the flag is disabled." + [cfg profile-id] + (when (contains? cf/flags :account-lockout) + (try + (with-conn cfg + (fn [conn] + (rds/del conn (str key-prefix profile-id)))) + (catch Exception cause + (l/warn :hint "redis unavailable, failed to clear login lockout" + :profile-id (str profile-id) + :cause cause))))) + +(defn locked? + "Check whether the account is currently locked out. Does not increment + the counter. Returns {:locked? false} when the flag is disabled or on + Redis error (fail-open). When locked, includes :ttl (seconds remaining)." + [cfg profile-id] + (if (contains? cf/flags :account-lockout) + (try + (let [threshold (cf/get :login-lockout-max-attempts) + window (cf/get :login-lockout-window) + window-ms (if (integer? window) window (.toMillis window)) + _ (assert (>= threshold 1) "login-lockout-max-attempts must be >= 1") + _ (assert (>= window-ms 60000) "login-lockout-window must be >= 60000ms (1 minute)") + key (str key-prefix profile-id) + now-ms (inst-ms (ct/now)) + result (with-conn cfg + (fn [conn] + (if-let [current (parse-value (rds/get conn key) window-ms now-ms)] + (let [elapsed (- now-ms (:window-start current)) + ttl-ms (- window-ms elapsed) + locked? (>= (:count current) threshold)] + (cond-> {:locked? locked?} + locked? + (assoc :ttl (int (Math/ceil (/ ttl-ms 1000.0)))))) + {:locked? false})))] + result) + (catch Exception cause + (l/warn :hint "redis unavailable, failing open on lockout check" + :profile-id (str profile-id) + :cause cause) + {:locked? false})) + {:locked? false})) diff --git a/backend/src/app/auth/login_lockout.lua b/backend/src/app/auth/login_lockout.lua new file mode 100644 index 0000000000..aa484c8b71 --- /dev/null +++ b/backend/src/app/auth/login_lockout.lua @@ -0,0 +1,46 @@ +-- 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 SUBSIDIARY SL + +local key = KEYS[1] +local threshold = tonumber(ARGV[1]) +local window_ms = tonumber(ARGV[2]) +local now_ms = tonumber(ARGV[3]) +local window_sec = math.ceil(window_ms / 1000) + +local val = redis.call('GET', key) +local count +local window_start + +if val then + local colon = string.find(val, ':') + if colon then + count = tonumber(string.sub(val, 1, colon - 1)) + window_start = tonumber(string.sub(val, colon + 1)) + else + count = 0 + window_start = now_ms + end +else + count = 0 + window_start = now_ms +end + +if (now_ms - window_start) > window_ms then + count = 0 + window_start = now_ms +end + +count = count + 1 +redis.call('SET', key, count .. ':' .. window_start, 'EX', window_sec) + +local locked = 0 +local ttl = 0 +if count >= threshold then + locked = 1 + ttl = math.ceil((window_ms - (now_ms - window_start)) / 1000) +end + +return {count, locked, ttl} diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 2abeaaffbc..a7f926ef03 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -69,6 +69,9 @@ :profile-bounce-max-age (ct/duration {:days 7}) :profile-bounce-threshold 10 + :login-lockout-max-attempts 5 + :login-lockout-window (ct/duration "15m") + :telemetry-uri "https://telemetry.penpot.app/" :media-max-file-size (* 1024 1024 30) ; 30MiB @@ -151,6 +154,9 @@ [:media-processing-service-uri {:optional true} ::sm/uri] [:media-processing-service-timeout {:optional true} ::sm/int] + [:login-lockout-max-attempts {:optional true} ::sm/int] + [:login-lockout-window {:optional true} ::ct/duration] + [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 4f713a5f6b..3cc6748b3c 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -71,9 +71,12 @@ (defmethod handle-error :rate-limit [err _ _] - (let [headers (-> err ex-data ::http/headers)] + (let [data (ex-data err)] {::yres/status 429 - ::yres/headers headers})) + ::yres/body {:type :rate-limit + :code (:code data) + :hint (:hint data) + :ttl (:ttl data)}})) (defmethod handle-error :concurrency-limit [err _ _] diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index b134d2a0c9..6a11e4f9de 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -7,6 +7,7 @@ (ns app.rpc.commands.auth (:require [app.auth :as auth] + [app.auth.login-lockout :as login-lockout] [app.auth.oidc :as oidc] [app.auth.passwords :as passwords] [app.common.data :as d] @@ -87,9 +88,21 @@ (ex/raise :type :restriction :code :profile-blocked :hint "profile is marked as blocked")) + (let [result (login-lockout/locked? cfg (:id profile))] + (when (:locked? result) + (ex/raise :type :rate-limit + :code :account-locked + :hint "account locked due to too many failed login attempts" + :ttl (:ttl result)))) (when-not (check-password cfg profile password) - (ex/raise :type :validation - :code :wrong-credentials)) + (let [result (login-lockout/record-failed-attempt! cfg (:id profile))] + (if (and result (:locked? result)) + (ex/raise :type :rate-limit + :code :account-locked + :hint "account locked due to too many failed login attempts" + :ttl (:ttl result)) + (ex/raise :type :validation + :code :wrong-credentials)))) (when-let [deleted-at (:deleted-at profile)] (when (ct/is-after? (ct/now) deleted-at) (ex/raise :type :validation @@ -113,6 +126,7 @@ {:invitation-token (:invitation-token params)} (assoc profile :is-admin (let [admins (cf/get :admins)] (contains? admins (:email profile)))))] + (login-lockout/clear-attempts! cfg (:id profile)) (-> response (rph/with-transform (session/create-fn cfg profile)) (rph/with-meta {::audit/props (audit/profile->props profile) @@ -181,11 +195,14 @@ (update-password [conn profile-id] (let [pwd (auth/derive-password password)] (db/update! conn :profile {:password pwd :is-active true} {:id profile-id}) - nil))] + (db/get-by-id conn :profile profile-id)))] (passwords/validate-password password) - (->> (validate-token token) - (update-password conn)) + + (let [profile (some-> (validate-token token) + (update-password conn))] + (when profile + (login-lockout/clear-attempts! cfg (:id profile)))) nil)) diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj index 20321d3d0a..b6016b3106 100644 --- a/backend/src/app/rpc/commands/ldap.clj +++ b/backend/src/app/rpc/commands/ldap.clj @@ -7,6 +7,7 @@ (ns app.rpc.commands.ldap (:require [app.auth.ldap :as ldap] + [app.auth.login-lockout :as login-lockout] [app.common.exceptions :as ex] [app.common.schema :as sm] [app.db :as db] @@ -38,42 +39,62 @@ ::doc/added "1.15" ::doc/module :auth ::sm/params schema:login-with-ldap} - [{:keys [::ldap/provider] :as cfg} params] + [{:keys [::ldap/provider ::db/pool] :as cfg} params] (when-not provider (ex/raise :type :restriction :code :ldap-not-initialized :hint "ldap auth provider is not initialized")) - (let [info (ldap/authenticate provider params)] - (when-not info - (ex/raise :type :validation - :code :wrong-credentials)) + (let [email (profile/clean-email (:email params)) + profile (profile/get-profile-by-email pool email)] - (let [profile (login-or-register cfg info)] + (when profile + (let [result (login-lockout/locked? cfg (:id profile))] + (when (:locked? result) + (ex/raise :type :rate-limit + :code :account-locked + :hint "account locked due to too many failed login attempts" + :ttl (:ttl result))))) - (when (:is-blocked profile) - (ex/raise :type :restriction - :code :profile-blocked)) + (let [info (ldap/authenticate provider params)] + (when-not info + (let [result (when profile + (login-lockout/record-failed-attempt! cfg (:id profile)))] + (if (and result (:locked? result)) + (ex/raise :type :rate-limit + :code :account-locked + :hint "account locked due to too many failed login attempts" + :ttl (:ttl result)) + (ex/raise :type :validation + :code :wrong-credentials)))) - (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 cfg {:token token :iss :team-invitation}) - claims (assoc claims - :member-id (:id profile) - :member-email (:email profile)) - token (tokens/generate cfg claims)] - (-> {:invitation-token token} + (let [profile (or profile (login-or-register cfg info))] + + (when (:is-blocked profile) + (ex/raise :type :restriction + :code :profile-blocked)) + + (login-lockout/clear-attempts! cfg (:id profile)) + + (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 cfg {:token token :iss :team-invitation}) + claims (assoc claims + :member-id (:id profile) + :member-email (:email profile)) + token (tokens/generate cfg claims)] + (-> {:invitation-token token} + (rph/with-transform (session/create-fn cfg profile)) + (rph/with-meta {::audit/props (:props profile) + ::audit/profile-id (:id profile)}))) + + (-> (profile/strip-private-attrs profile) (rph/with-transform (session/create-fn cfg profile)) (rph/with-meta {::audit/props (:props profile) - ::audit/profile-id (:id profile)}))) - - (-> (profile/strip-private-attrs profile) - (rph/with-transform (session/create-fn cfg profile)) - (rph/with-meta {::audit/props (:props profile) - ::audit/profile-id (:id profile)})))))) + ::audit/profile-id (:id profile)}))))))) (defn- login-or-register [cfg info] diff --git a/backend/test/backend_tests/auth_login_lockout_test.clj b/backend/test/backend_tests/auth_login_lockout_test.clj new file mode 100644 index 0000000000..c1d0961e47 --- /dev/null +++ b/backend/test/backend_tests/auth_login_lockout_test.clj @@ -0,0 +1,145 @@ +;; 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.auth-login-lockout-test + (:require + [app.auth.login-lockout :as lol] + [app.common.flags :as flags] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once (partial th/init-system)) +(t/use-fixtures :each (partial th/init-config [:enable-account-lockout])) + +(t/deftest record-failed-attempt-returns-nil-when-disabled + (with-redefs [app.config/flags (flags/parse flags/default th/default-flags)] + (let [profile-id (uuid/next) + result (lol/record-failed-attempt! th/*system* profile-id)] + (t/is (nil? result))))) + +(t/deftest record-failed-attempt-increments-counter + (let [profile-id (uuid/next) + r1 (lol/record-failed-attempt! th/*system* profile-id) + r2 (lol/record-failed-attempt! th/*system* profile-id) + r3 (lol/record-failed-attempt! th/*system* profile-id)] + (t/is (map? r1)) + (t/is (= 1 (:count r1))) + (t/is (false? (:locked? r1))) + (t/is (= 2 (:count r2))) + (t/is (false? (:locked? r2))) + (t/is (= 3 (:count r3))) + (t/is (false? (:locked? r3))))) + +(t/deftest record-failed-attempt-locks-after-threshold + (let [profile-id (uuid/next)] + (dotimes [i 4] + (lol/record-failed-attempt! th/*system* profile-id)) + (let [result (lol/record-failed-attempt! th/*system* profile-id)] + (t/is (= 5 (:count result))) + (t/is (:locked? result))))) + +(t/deftest locked?-returns-false-below-threshold + (let [profile-id (uuid/next)] + (dotimes [i 3] + (lol/record-failed-attempt! th/*system* profile-id)) + (t/is (false? (:locked? (lol/locked? th/*system* profile-id)))))) + +(t/deftest locked?-returns-true-at-threshold + (let [profile-id (uuid/next)] + (dotimes [i 5] + (lol/record-failed-attempt! th/*system* profile-id)) + (t/is (:locked? (lol/locked? th/*system* profile-id))))) + +(t/deftest locked?-returns-false-when-disabled + (with-redefs [app.config/flags (flags/parse flags/default th/default-flags)] + (let [profile-id (uuid/next)] + (t/is (false? (:locked? (lol/locked? th/*system* profile-id))))))) + +(t/deftest clear-attempts-resets-counter + (let [profile-id (uuid/next)] + (dotimes [i 5] + (lol/record-failed-attempt! th/*system* profile-id)) + (t/is (:locked? (lol/locked? th/*system* profile-id))) + (lol/clear-attempts! th/*system* profile-id) + (t/is (false? (:locked? (lol/locked? th/*system* profile-id)))))) + +(t/deftest clear-attempts-is-no-op-when-disabled + (with-redefs [app.config/flags (flags/parse flags/default th/default-flags)] + (let [profile-id (uuid/next)] + (t/is (nil? (lol/clear-attempts! th/*system* profile-id)))))) + +(t/deftest locked?-returns-false-for-unknown-profile + (let [profile-id (uuid/next)] + (t/is (false? (:locked? (lol/locked? th/*system* profile-id)))))) + +(t/deftest locked?-returns-ttl-when-locked + (let [profile-id (uuid/next)] + (dotimes [i 5] + (lol/record-failed-attempt! th/*system* profile-id)) + (let [result (lol/locked? th/*system* profile-id)] + (t/is (:locked? result)) + (t/is (pos-int? (:ttl result)) + "ttl should be a positive integer when locked")))) + +(t/deftest locked?-returns-no-ttl-when-not-locked + (let [profile-id (uuid/next)] + (dotimes [i 2] + (lol/record-failed-attempt! th/*system* profile-id)) + (let [result (lol/locked? th/*system* profile-id)] + (t/is (false? (:locked? result))) + (t/is (nil? (:ttl result)) + "ttl should be nil when not locked")))) + +(t/deftest locked?-expires-after-window + (let [profile-id (uuid/next) + start (ct/inst "2026-01-01T00:00:00Z")] + ;; Record 5 attempts at t=0 → locked + (binding [ct/*clock* (ct/fixed-clock start)] + (dotimes [i 5] + (lol/record-failed-attempt! th/*system* profile-id))) + ;; At t=5min → still locked + (binding [ct/*clock* (ct/fixed-clock (ct/plus start {:minutes 5}))] + (let [result (lol/locked? th/*system* profile-id)] + (t/is (:locked? result) "should still be locked at 5min") + (t/is (pos-int? (:ttl result))))) + ;; At t=16min → expired, not locked + (binding [ct/*clock* (ct/fixed-clock (ct/plus start {:minutes 16}))] + (let [result (lol/locked? th/*system* profile-id)] + (t/is (false? (:locked? result)) "should be unlocked after window expires") + (t/is (nil? (:ttl result))))))) + +(t/deftest record-failed-attempt-resets-window-on-expiry + (let [profile-id (uuid/next) + start (ct/inst "2026-01-01T00:00:00Z")] + ;; Record 2 attempts at t=0 + (binding [ct/*clock* (ct/fixed-clock start)] + (dotimes [i 2] + (lol/record-failed-attempt! th/*system* profile-id))) + ;; At t=20min (after 15min window), counter should reset + (binding [ct/*clock* (ct/fixed-clock (ct/plus start {:minutes 20}))] + (let [result (lol/record-failed-attempt! th/*system* profile-id)] + (t/is (= 1 (:count result)) "count should reset to 1 after window expires") + (t/is (false? (:locked? result))))))) + +(t/deftest locked?-ttl-decreases-over-time + (let [profile-id (uuid/next) + start (ct/inst "2026-01-01T00:00:00Z")] + ;; Record 5 attempts at t=0 → locked + (binding [ct/*clock* (ct/fixed-clock start)] + (dotimes [i 5] + (lol/record-failed-attempt! th/*system* profile-id))) + ;; At t=5min → ttl should be ~600s (10min remaining) + (binding [ct/*clock* (ct/fixed-clock (ct/plus start {:minutes 5}))] + (let [result (lol/locked? th/*system* profile-id)] + (t/is (:locked? result)) + (t/is (<= 590 (:ttl result) 600) "ttl should be ~600s at 5min"))) + ;; At t=14min → ttl should be ~60s + (binding [ct/*clock* (ct/fixed-clock (ct/plus start {:minutes 14}))] + (let [result (lol/locked? th/*system* profile-id)] + (t/is (:locked? result)) + (t/is (<= 50 (:ttl result) 70) "ttl should be ~60s at 14min"))))) diff --git a/backend/test/backend_tests/rpc_auth_test.clj b/backend/test/backend_tests/rpc_auth_test.clj index 0a23c3cab0..69d9f3cd3d 100644 --- a/backend/test/backend_tests/rpc_auth_test.clj +++ b/backend/test/backend_tests/rpc_auth_test.clj @@ -6,11 +6,19 @@ (ns backend-tests.rpc-auth-test (:require + [app.auth.ldap :as ldap] + [app.auth.login-lockout :as lol] + [app.common.flags :as flags] + [app.common.generic-pool :as gpool] [app.common.uuid :as uuid] [app.http.session :as session] + [app.redis :as rds] + [app.rpc.commands.ldap :as ldap-cmd] [backend-tests.helpers :as th] [clojure.test :as t] - [yetti.response :as yres])) + [yetti.response :as yres]) + (:import + java.lang.AutoCloseable)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -103,4 +111,122 @@ (t/is (nil? (session/read-session manager sid)) "replayed token must not resolve to a valid session after logout"))) +(defn- cleanup-redis + [profile-id] + (let [pool (get-in th/*system* [:app.redis/pool]) + conn (gpool/get pool)] + (try + (rds/del @conn (str "penpot.login-lockout." profile-id)) + (finally + (.close ^AutoCloseable conn))))) + +(t/deftest login-with-password-locks-after-max-attempts + (with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])] + (let [profile (th/create-profile* 200 {:is-active true})] + (cleanup-redis (:id profile)) + ;; 4 failed attempts — not yet locked + (dotimes [i 4] + (let [data {::th/type :login-with-password + :email (:email profile) + :password "wrongpassword"} + out (th/command! data)] + (t/is (th/ex-of-code? (:error out) :wrong-credentials)))) + ;; 5th attempt — should trigger lockout + (let [data {::th/type :login-with-password + :email (:email profile) + :password "wrongpassword"} + out (th/command! data)] + (t/is (th/ex-of-type? (:error out) :rate-limit)) + (t/is (th/ex-of-code? (:error out) :account-locked)))))) + +(t/deftest login-with-password-returns-rate-limit-when-locked + (with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])] + (let [profile (th/create-profile* 201 {:is-active true})] + (cleanup-redis (:id profile)) + ;; Trigger lockout + (dotimes [i 5] + (th/command! {::th/type :login-with-password + :email (:email profile) + :password "wrongpassword"})) + ;; Next attempt with correct password — still blocked + (let [data {::th/type :login-with-password + :email (:email profile) + :password "Test123!"} + out (th/command! data)] + (t/is (th/ex-of-type? (:error out) :rate-limit)) + (t/is (th/ex-of-code? (:error out) :account-locked)) + (let [ttl (:ttl (ex-data (:error out)))] + (t/is (some? ttl) "ttl should be present in ex-data") + (t/is (pos? ttl) "ttl should be a positive number")))))) + +(t/deftest successful-login-clears-failed-attempts + (with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])] + (let [profile (th/create-profile* 202 {:is-active true})] + (cleanup-redis (:id profile)) + ;; 3 failed attempts + (dotimes [i 3] + (th/command! {::th/type :login-with-password + :email (:email profile) + :password "wrongpassword"})) + ;; Successful login + (let [data {::th/type :login-with-password + :email (:email profile) + :password "Test123!"} + out (th/command! data)] + (t/is (nil? (:error out)))) + ;; 4 more failed attempts should NOT trigger lockout (counter was reset) + (dotimes [i 4] + (let [data {::th/type :login-with-password + :email (:email profile) + :password "wrongpassword"} + out (th/command! data)] + (t/is (th/ex-of-code? (:error out) :wrong-credentials))))))) + +(t/deftest lockout-does-not-apply-when-flag-disabled + (with-redefs [app.config/flags (flags/parse flags/default [])] + (let [profile (th/create-profile* 203 {:is-active true})] + (cleanup-redis (:id profile)) + ;; 10 failed attempts — no lockout without the flag + (dotimes [i 10] + (let [data {::th/type :login-with-password + :email (:email profile) + :password "wrongpassword"} + out (th/command! data)] + (t/is (th/ex-of-code? (:error out) :wrong-credentials))))))) + +(t/deftest login-with-ldap-returns-rate-limit-when-locked + "Verify that login-with-ldap short-circuits when account is locked, + without attempting LDAP authentication. Calls the actual command + function with a mock provider to test the full flow." + (with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout :login-with-ldap]) + ldap/authenticate (constantly nil)] + (let [profile (th/create-profile* 204 {:is-active true}) + cfg (assoc th/*system* ::ldap/provider {})] + (cleanup-redis (:id profile)) + ;; Lock account with 5 failed attempts + (dotimes [i 5] + (lol/record-failed-attempt! cfg (:id profile))) + ;; Call actual command function — should be blocked without calling authenticate + (let [out (th/try-on! (#'ldap-cmd/sm$login-with-ldap + cfg + {:email (:email profile) + :password "wrongpassword"}))] + (t/is (th/ex-of-type? (:error out) :rate-limit)) + (t/is (th/ex-of-code? (:error out) :account-locked)))))) + +(t/deftest recover-profile-clears-lockout + "Verify that clearing attempts (e.g. after password reset) unlocks the account." + (with-redefs [app.config/flags (flags/parse flags/default [:enable-account-lockout])] + (let [profile (th/create-profile* 205 {:is-active true}) + cfg th/*system*] + (cleanup-redis (:id profile)) + ;; Lock the account + (dotimes [i 5] + (lol/record-failed-attempt! cfg (:id profile))) + (t/is (:locked? (lol/locked? cfg (:id profile)))) + ;; Simulate password reset clearing + (lol/clear-attempts! cfg (:id profile)) + ;; Verify unlocked + (t/is (false? (:locked? (lol/locked? cfg (:id profile)))))))) + diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 376a26df4f..6b7e5b7956 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -98,6 +98,8 @@ :exporter-svgo ;; TODO: deprecate this flag and consolidate the code :backend-svgo + ;; Enables account lockout after repeated failed login attempts. + :account-lockout ;; If enabled, it makes the Google Fonts available. :google-fonts-provider ;; Only for development. diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index 28aece5a74..de5ca4c67b 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.logging :as log] + [app.common.math :as math] [app.common.schema :as sm] [app.config :as cf] [app.main.data.auth :as da] @@ -99,6 +100,11 @@ (= :account-without-password (:code cause))) (reset! error (tr "errors.wrong-credentials")) + (and (= :rate-limit (:type cause)) + (= :account-locked (:code cause))) + (let [minutes (max 1 (int (math/ceil (/ (:ttl cause) 60))))] + (reset! error (tr "errors.account-locked" minutes))) + :else (reset! error (tr "errors.generic"))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 701c91e5b7..a35282835e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1654,7 +1654,11 @@ msgstr "" "The font family name can only contain letters, numbers, spaces, hyphens, " "underscores, and dots." -#: src/app/main/data/auth.cljs:354, src/app/main/errors.cljs:190, src/app/main/ui/auth/login.cljs:103, src/app/main/ui/auth/register.cljs:112, src/app/main/ui/auth/register.cljs:304, src/app/main/ui/auth/verify_token.cljs:119, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:227, src/app/main/ui/dashboard/team.cljs:1065, src/app/main/ui/onboarding/team_choice.cljs:124, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:156, src/app/main/ui/workspace/main_menu.cljs:971 +#: src/app/main/ui/auth/login.cljs:103 +msgid "errors.account-locked" +msgstr "Too many failed attempts. Your account has been locked for %s minutes. Please try again later." + +#: src/app/main/data/auth.cljs:354, src/app/main/errors.cljs:190, src/app/main/ui/auth/login.cljs:107, src/app/main/ui/auth/register.cljs:112, src/app/main/ui/auth/register.cljs:304, src/app/main/ui/auth/verify_token.cljs:119, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:227, src/app/main/ui/dashboard/team.cljs:1065, src/app/main/ui/onboarding/team_choice.cljs:124, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:156, src/app/main/ui/workspace/main_menu.cljs:971 msgid "errors.generic" msgstr "Something wrong has happened." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 945d30a19e..8401ec52af 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1636,7 +1636,11 @@ msgstr "" "del fichero que se esta intentando abrir. Falta aplicar migraciones para " "'%s' antes de poder abrir el fichero." -#: src/app/main/data/auth.cljs:354, src/app/main/errors.cljs:190, src/app/main/ui/auth/login.cljs:103, src/app/main/ui/auth/register.cljs:112, src/app/main/ui/auth/register.cljs:304, src/app/main/ui/auth/verify_token.cljs:119, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:227, src/app/main/ui/dashboard/team.cljs:1065, src/app/main/ui/onboarding/team_choice.cljs:124, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:156, src/app/main/ui/workspace/main_menu.cljs:971 +#: src/app/main/ui/auth/login.cljs:103 +msgid "errors.account-locked" +msgstr "Demasiados intentos fallidos. Su cuenta ha sido bloqueada durante %s minutos. Por favor, inténtelo más tarde." + +#: src/app/main/data/auth.cljs:354, src/app/main/errors.cljs:190, src/app/main/ui/auth/login.cljs:107, src/app/main/ui/auth/register.cljs:112, src/app/main/ui/auth/register.cljs:304, src/app/main/ui/auth/verify_token.cljs:119, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:227, src/app/main/ui/dashboard/team.cljs:1065, src/app/main/ui/onboarding/team_choice.cljs:124, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:156, src/app/main/ui/workspace/main_menu.cljs:971 msgid "errors.generic" msgstr "Ha ocurrido algún error."