penpot/backend/test/backend_tests/rpc_auth_test.clj
Andrey Antukh 04d7b623aa 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
2026-08-28 11:30:00 +02:00

233 lines
9.9 KiB
Clojure

;; 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 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])
(:import
java.lang.AutoCloseable))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest logout-invalidates-current-session
(let [prof (th/create-profile* 1)
manager (::session/manager th/*system*)
sid (uuid/random)
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid (:id prof) "test-agent"])
session (session/read-session manager sid)]
;; Arrange: session exists before logout
(t/is (some? session) "session should exist before logout")
(t/is (= sid (:id session)))
;; Act: simulate Ring request as produced by wrap-authz (has ::session/session)
;; delete-fn is used as response transform via rph/with-transform in auth/logout
(let [request {::session/session session}
response {}
delete-fn (session/delete-fn th/*system*)
result (delete-fn request response)]
;; Assert: server-side session is deleted (CWE-613)
(t/is (nil? (session/read-session manager sid))
"session must be deleted server-side after logout (GHSA-mj9f-5cwq-7p3q)")
;; Assert: cookie is cleared
(t/is (= "" (get-in result [::yres/cookies "auth-token" :value]))
"auth-token cookie should be cleared")
(t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age]))
"auth-token cookie max-age should be 0"))))
(t/deftest logout-clears-cookie-even-when-session-missing
(let [manager (::session/manager th/*system*)
sid (uuid/random)
;; No session inserted, read should be nil
_ (t/is (nil? (session/read-session manager sid)))
request {}
response {}
delete-fn (session/delete-fn th/*system*)
result (delete-fn request response)]
;; Should still clear cookie (idempotent)
(t/is (= "" (get-in result [::yres/cookies "auth-token" :value])))
(t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age])))))
(t/deftest logout-does-not-invalidate-other-sessions
(let [prof (th/create-profile* 1)
manager (::session/manager th/*system*)
sid1 (uuid/random)
sid2 (uuid/random)
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid1 (:id prof) "agent-1"])
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid2 (:id prof) "agent-2"])
s1 (session/read-session manager sid1)
s2 (session/read-session manager sid2)]
(t/is (some? s1))
(t/is (some? s2))
;; Logout only sid1
(let [request {::session/session s1}
response {}
delete-fn (session/delete-fn th/*system*)]
(delete-fn request response))
;; sid1 deleted, sid2 intact
(t/is (nil? (session/read-session manager sid1)) "current session should be deleted")
(t/is (some? (session/read-session manager sid2)) "other sessions should remain")))
(t/deftest replay-after-logout-cannot-authenticate
(let [prof (th/create-profile* 1)
manager (::session/manager th/*system*)
sid (uuid/random)
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid (:id prof) "test-agent"])
session (session/read-session manager sid)]
(t/is (some? session) "session exists before logout")
;; Simulate logout
(let [request {::session/session session}
response {}
delete-fn (session/delete-fn th/*system*)]
(delete-fn request response))
;; Replay: attempt to read session with same sid should fail (no profile attached)
(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))))))))