🐛 Add backend password validation with complexity rules and dictionary check

Enforce minimum 8-character password length, require at least 1 lowercase
letter, 1 uppercase letter, 1 digit, and 1 special character, and reject
common passwords using Passay library with a 10k-entry wordlist from
SecLists during registration and password change flows.

AI-assisted-by: mimo-v2.5-pro
This commit is contained in:
Andrey Antukh 2026-07-29 17:24:14 +00:00 committed by Luis de Dios
parent 23ea2bbad6
commit 31892eccc2
7 changed files with 10142 additions and 25 deletions

View File

@ -48,6 +48,7 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
org.passay/passay {:mvn/version "1.6.6"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth.passwords
"Password strength validation using Passay library."
(:require
[app.common.exceptions :as ex]
[clojure.java.io :as io])
(:import
[org.passay CharacterCharacteristicsRule CharacterRule DictionaryRule EnglishCharacterData PasswordData]
[org.passay.dictionary ArrayWordList WordListDictionary]))
(defonce ^:private dictionary
(let [lines (line-seq (io/reader (io/resource "app/common-passwords.txt")))
words (into-array String (sort lines))
word-list (ArrayWordList. words)]
(WordListDictionary. word-list)))
(defonce ^:private dictionary-rule
(DictionaryRule. dictionary))
(defonce ^:private character-characteristics-rule
(doto (CharacterCharacteristicsRule.)
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
(CharacterRule. EnglishCharacterData/UpperCase 1)
(CharacterRule. EnglishCharacterData/Digit 1)
(CharacterRule. EnglishCharacterData/Special 1)])
(.setNumberOfCharacteristics 4)))
(defn validate-password
"Validates password strength.
Returns nil if valid, or raises exception if invalid.
Checks:
- Minimum length of 8 characters
- At least 1 lowercase letter
- At least 1 uppercase letter
- At least 1 digit
- At least 1 special character
- Not in common password dictionary"
[password]
(when (< (count password) 8)
(ex/raise :type :validation
:code :weak-password
:hint "password must be at least 8 characters"))
(let [password-data (PasswordData. password)]
(let [char-result (.validate character-characteristics-rule password-data)]
(when-not (.isValid char-result)
(ex/raise :type :validation
:code :weak-password
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
:details (mapv #(.getErrorCode %) (.getDetails char-result)))))
(let [dict-result (.validate dictionary-rule password-data)]
(when-not (.isValid dict-result)
(ex/raise :type :validation
:code :weak-password
:hint "password is too common"
:details (mapv #(.getErrorCode %) (.getDetails dict-result)))))))

View File

@ -8,6 +8,7 @@
(:require
[app.auth :as auth]
[app.auth.oidc :as oidc]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
@ -240,6 +241,9 @@
:code :email-as-password
:hint "you can't use your email as password"))
;; Validate password strength against common password dictionary
(passwords/validate-password (:password params))
(when (eml/has-bounce-reports? cfg (:email params))
(ex/raise :type :restriction
:code :email-has-permanent-bounces

View File

@ -7,6 +7,7 @@
(ns app.rpc.commands.profile
(:require
[app.auth :as auth]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@ -208,6 +209,9 @@
:code :email-as-password
:hint "you can't use your email as password"))
;; Validate password strength against common password dictionary
(passwords/validate-password (:password params))
(update-profile-password! cfg (assoc profile :password password))
(->> (rph/get-request params)

View File

@ -189,7 +189,7 @@
(let [params (merge {:id (mk-uuid "profile" i)
:fullname (str "Profile " i)
:email (str "profile" i ".test@nodomain.com")
:password "123123"
:password "Test123!"
:is-demo false}
params)]
(db/run! system

View File

@ -42,7 +42,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
#_(th/print-result! out)
@ -56,7 +56,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "123123"}
:password "Test123!"}
out (th/command! data)]
;; (th/print-result! out)
(let [error (:error out)]
@ -69,7 +69,7 @@
(let [profile (th/create-profile* 1 {:is-active true})
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "123123"}
:password "Test123!"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
@ -403,7 +403,7 @@
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "foobar"
:password "Foobar12!"
:utm_campaign "utma"
:mtm_campaign "mtma"}
out (th/command! data)
@ -444,7 +444,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@ -463,7 +463,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@ -498,7 +498,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@ -521,7 +521,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@ -547,7 +547,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@ -576,7 +576,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@ -614,7 +614,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@ -659,7 +659,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@ -692,7 +692,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@ -712,7 +712,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@ -733,7 +733,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@ -754,7 +754,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@ -767,7 +767,7 @@
(let [data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@ -780,7 +780,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email (:email profile)
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
@ -793,7 +793,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "foobar"}]
:password "Foobar12!"}]
(th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
@ -808,7 +808,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "foobar"}]
:password "Foobar12!"}]
(th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
@ -1131,8 +1131,8 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "123123"
:password "foobarfoobar"}
:old-password "Test123!"
:password "Foobar12!"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))))
@ -1143,7 +1143,7 @@
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "badpassword"
:password "foobarfoobar"}
:password "Foobar12!"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
@ -1154,7 +1154,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "123123"
:old-password "Test123!"
:password "profile1.test@nodomain.com"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
@ -1271,3 +1271,49 @@
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest prepare-register-profile-weak-password
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "password123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-too-short
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-weak-password
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "qwerty"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))