🐛 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 bundled wordlist during
registration and password change flows.

AI-assisted-by: qwen3.7-plus
This commit is contained in:
Andrey Antukh 2026-07-29 17:24:14 +00:00
parent 41afcba297
commit 5f0175a641
7 changed files with 365 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"}

View File

@ -0,0 +1,223 @@
password
123456
12345678
1234
qwerty
12345
dragon
pussy
baseball
football
letmein
monkey
696969
abc123
mustang
michael
shadow
master
jennifer
111111
2000
jordan
superman
harley
1234567
fuckme
hunter
fuckyou
trustno1
ranger
buster
thomas
tigger
robert
soccer
fuck
batman
test
pass
killer
hockey
george
charlie
andrew
michelle
love
sunshine
jessica
asshole
6969
pepper
daniel
access
123456789
654321
joshua
maggie
starwars
silver
william
dallas
yankees
123123
ashley
666666
hello
amanda
orange
biteme
freedom
computer
sexy
thunder
nicole
ginger
heather
hammer
summer
corvette
taylor
fucker
austin
1111
merlin
matthew
121212
golfer
cheese
princess
martin
chelsea
patrick
richard
diamond
yellow
bigdog
secret
asdfgh
sparky
cowboy
camaro
anthony
matrix
falcon
iloveyou
bailey
guitar
jackson
purple
scooter
phoenix
aaaaaa
morgan
tigers
porsche
mickey
maverick
cookie
nascar
peanut
justin
131313
money
horny
samantha
panties
steelers
joseph
snoopy
boomer
whatever
iceman
smokey
gateway
dakota
cowboys
eagles
chicken
dick
black
zxcvbn
please
andrea
ferrari
knight
hardcore
compaq
coffee
booboo
bitch
bulldog
xxxxxx
welcome
james
player
ncc1701
wizard
sbpnb
december
hello123
admin
qwerty123
1q2w3e4r
1q2w3e4r5t
password1
password123
iloveu
letmein1
abc1234
qwertyuiop
asdfghjkl
zxcvbnm
1234567890
0987654321
11223344
admin123
root
toor
pass123
test123
guest
default
changeme
temp
temp123
passwd
passw0rd
p@ssword
p@ssw0rd
qwerty1
abc12345
123321
12345678910
asdf
asdfasdf
qwer
qwerqwer
zxcv
zxcvzxcv
!@#$%^&*
!@#$%
qazwsx
qazwsxedc
1qaz2wsx
1qazxsw2
pass1234
test1234
admin1234
letmein123
welcome1
welcome123
monkey123
dragon123
master123
shadow123
sunshine1
princess1
football1
baseball1
soccer1
hockey1
batman1
superman1

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]
@ -200,6 +201,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))
@ -1202,3 +1202,49 @@
(t/is (true? (get-in props [:props :onboarding-viewed])))
(t/is (false? (get-in props [:props :newsletter-updates])))
(t/is (= :wasm (get-in props [:props :renderer]))))))
(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))))