mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 08:08:46 +00:00
✨ Add account lockout after failed login attempts
Implement per-account brute-force protection using a Redis-backed failed-login counter. After 5 failed attempts within 15 minutes, the account is temporarily locked out and all login attempts (including with the correct password) are rejected with a 429 response. Closes #11397 AI-assisted-by: longcat-2.0
This commit is contained in:
parent
0e388442a1
commit
04d7b623aa
@ -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 \
|
||||
|
||||
132
backend/src/app/auth/login_lockout.clj
Normal file
132
backend/src/app/auth/login_lockout.clj
Normal file
@ -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}))
|
||||
46
backend/src/app/auth/login_lockout.lua
Normal file
46
backend/src/app/auth/login_lockout.lua
Normal file
@ -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}
|
||||
@ -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]
|
||||
|
||||
@ -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 _ _]
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
145
backend/test/backend_tests/auth_login_lockout_test.clj
Normal file
145
backend/test/backend_tests/auth_login_lockout_test.clj
Normal file
@ -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")))))
|
||||
@ -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))))))))
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")))))
|
||||
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user