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:
Andrey Antukh 2026-08-27 15:01:40 +00:00
parent 0e388442a1
commit 04d7b623aa
13 changed files with 549 additions and 36 deletions

View File

@ -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 \

View 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}))

View 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}

View File

@ -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]

View File

@ -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 _ _]

View File

@ -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))

View File

@ -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]

View 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")))))

View File

@ -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))))))))

View File

@ -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.

View File

@ -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")))))

View File

@ -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."

View File

@ -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."