From dd242a09203c7526d8668d5c4fba6ac41c3bbf9e Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Fri, 2 Oct 2026 15:21:18 +0000 Subject: [PATCH 1/2] Add configurable password strength validation --- CHANGELOG.md | 2 + README.md | 50 ++++++ errors.go | 1 + is/password_strength.go | 9 ++ is/password_strength_test.go | 25 +++ it/example_test.go | 14 ++ it/password_strength.go | 115 ++++++++++++++ it/password_strength_test.go | 55 +++++++ message/messages.go | 1 + message/translations/english/messages.go | 1 + message/translations/russian/messages.go | 1 + ...onstraints_password_strength_cases_test.go | 14 ++ test/constraints_test.go | 1 + validate/password_strength.go | 143 ++++++++++++++++++ validate/password_strength_test.go | 96 ++++++++++++ 15 files changed, 528 insertions(+) create mode 100644 is/password_strength.go create mode 100644 is/password_strength_test.go create mode 100644 it/password_strength.go create mode 100644 it/password_strength_test.go create mode 100644 test/constraints_password_strength_cases_test.go create mode 100644 validate/password_strength.go create mode 100644 validate/password_strength_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7755eb5..81894b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `it.HasPasswordStrength`, `validate.PasswordStrength`, `is.PasswordStrength`, and `validate.EstimatePasswordStrength` using Symfony 8.0’s byte-based heuristic, configurable minimum scores and estimators, and English/Russian messages. Empty passwords are evaluated; passwords are not automatically included in violations. No new dependencies. + - Add `it.IsCSSColor`, `validate.CSSColor`, and `is.CSSColor` with 12 selectable Symfony 8.0 color formats, English/Russian messages, and no new dependencies. Document strict legacy function syntax and unsupported modern CSS features. - Add `it.IsCardScheme`, `validate.CardScheme`, and `is.CardScheme` with explicit selection of 12 payment card schemes, Symfony 8.0 prefix/length rules, and English/Russian messages. Checksum validation remains separate via `it.IsLUHN`; no new dependencies. diff --git a/README.md b/README.md index 45e0d93..8089c46 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,56 @@ and unmatched groups skip validation entirely. Invalid colors produce `WhenGroups`, `This`, and `Each` are supported, with English/Russian messages. No dependencies are added. +### Password strength + +`it.HasPasswordStrength()` requires a score of at least Medium (2): + +```go +err := validator.Validate(context.Background(), + validation.String(password, + it.HasPasswordStrength().WithMinScore(validate.PasswordStrengthStrong), + ), +) +``` + +`validate.EstimatePasswordStrength(password)` returns a `PasswordStrengthScore`: +`PasswordStrengthVeryWeak` (0), `PasswordStrengthWeak` (1), +`PasswordStrengthMedium` (2), `PasswordStrengthStrong` (3), or +`PasswordStrengthVeryStrong` (4). Minimum scores must be 1–4. Standalone +`validate.PasswordStrength` and `is.PasswordStrength` accept +`validate.WithMinPasswordStrength` and `validate.WithPasswordStrengthEstimator`. + +The estimator follows [Symfony 8.0](https://github.com/symfony/validator/blob/8.0/Constraints/PasswordStrengthValidator.php): +`E = U*log2(P) + (L-U)*log2(U)`, using byte length `L`, distinct bytes `U`, +and the combined sizes `P` of present byte categories. Categories are lowercase +ASCII (26), uppercase ASCII (26), digits (10), symbols including space (33), +control bytes (33), and non-ASCII bytes (128). Thresholds are 60, 80, 100, and 120. +UTF-8 bytes are counted separately without normalization; arbitrary byte strings +are accepted. Empty input has score 0. + +This is a heuristic, not a guarantee of resistance to guessing. There are no +wordlists or sequence checks: the lowercase alphabet scores 4 and `ab` repeated +50 times scores 3. It is neither zxcvbn nor a Have I Been Pwned breach check. + +`WithEstimator(func(string) validate.PasswordStrengthScore)` replaces the algorithm; +return values outside 0–4 are configuration errors. Nil restores the default. +Custom estimators must support concurrent calls when constraints are shared. +The estimator receives the original string unchanged and is called once per +validated non-nil value. Nil pointers are skipped; **empty strings are evaluated +and rejected by the default estimator**, unlike most string constraints. A custom +estimator also controls the score of empty strings. Invalid minimum scores are +configuration errors even for nil input. Disabled constraints and unmatched groups +skip validation entirely. + +Violations use `validation.ErrPasswordTooWeak`, with English/Russian translations. +`WithError`, `WithMessage`, `When`, `WhenGroups`, `This`, and `Each` are supported. +Automatic message parameters are only `{{ strength }}` and `{{ minScore }}`: +the library does not add the password or a `{{ value }}` parameter to violations. +Avoid including passwords in custom messages, parameters, or estimator logs. +Standalone failures use `validate.ErrPasswordTooWeak`, +`validate.ErrInvalidPasswordStrengthMinimum`, or `validate.ErrInvalidPasswordStrengthScore`. +No dependencies or network access are required. + ## Documentation | Topic | Description | diff --git a/errors.go b/errors.go index 1525a6c..727989b 100644 --- a/errors.go +++ b/errors.go @@ -42,6 +42,7 @@ var ( ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON) ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN) ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC) + ErrPasswordTooWeak = NewError("password strength too low", message.PasswordTooWeak) ErrInvalidCSSColor = NewError("invalid CSS color", message.InvalidCSSColor) ErrInvalidCardScheme = NewError("invalid card scheme", message.InvalidCardScheme) ErrInvalidWeekFormat = NewError("invalid week format", message.InvalidWeekFormat) diff --git a/is/password_strength.go b/is/password_strength.go new file mode 100644 index 0000000..79f8ac3 --- /dev/null +++ b/is/password_strength.go @@ -0,0 +1,9 @@ +package is + +import "github.com/muonsoft/validation/validate" + +// PasswordStrength reports whether a password meets the configured minimum score. +// See [validate.PasswordStrength] for options and empty-value behavior. +func PasswordStrength(password string, options ...func(*validate.PasswordStrengthOptions)) bool { + return validate.PasswordStrength(password, options...) == nil +} diff --git a/is/password_strength_test.go b/is/password_strength_test.go new file mode 100644 index 0000000..5492bf8 --- /dev/null +++ b/is/password_strength_test.go @@ -0,0 +1,25 @@ +package is_test + +import ( + "fmt" + "testing" + + "github.com/muonsoft/validation/is" + "github.com/muonsoft/validation/validate" + "github.com/stretchr/testify/require" +) + +func TestPasswordStrength(t *testing.T) { + require.True(t, is.PasswordStrength("Reasonable-pwd")) + require.False(t, is.PasswordStrength("")) + require.False(t, is.PasswordStrength("password")) + require.False(t, is.PasswordStrength("Reasonable-pwd", validate.WithMinPasswordStrength(validate.PasswordStrengthStrong))) + require.False(t, is.PasswordStrength("secret", validate.WithMinPasswordStrength(0))) + require.True(t, is.PasswordStrength("custom", validate.WithPasswordStrengthEstimator(func(string) validate.PasswordStrengthScore { return 4 }))) +} + +func ExamplePasswordStrength() { + fmt.Println(is.PasswordStrength("Reasonable-pwd")) + // Output: + // true +} diff --git a/it/example_test.go b/it/example_test.go index 1400e71..cd526e2 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -1225,3 +1225,17 @@ func ExampleIsCSSColor_invalid() { // Output: // violation: "This value is not a valid CSS color." } + +func ExampleHasPasswordStrength_valid() { + err := validator.Validate(context.Background(), validation.String("Reasonable-pwd", it.HasPasswordStrength())) + fmt.Println(err) + // Output: + // +} + +func ExampleHasPasswordStrength_invalid() { + err := validator.Validate(context.Background(), validation.String("password", it.HasPasswordStrength().WithMinScore(validate.PasswordStrengthStrong))) + fmt.Println(err) + // Output: + // violation: "The password strength is too low. Please use a stronger password." +} diff --git a/it/password_strength.go b/it/password_strength.go new file mode 100644 index 0000000..459fa1f --- /dev/null +++ b/it/password_strength.go @@ -0,0 +1,115 @@ +package it + +import ( + "context" + "errors" + "strconv" + + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/validate" +) + +// PasswordStrengthConstraint checks a minimum heuristic password score. +// Nil values are skipped. Empty strings are evaluated, unlike most string constraints. +// Passwords are never automatically included in violation messages or parameters. +type PasswordStrengthConstraint struct { + isIgnored bool + groups []string + minScore validate.PasswordStrengthScore + estimator validate.PasswordStrengthEstimator + err error + messageTemplate string + messageParameters validation.TemplateParameterList +} + +// HasPasswordStrength requires at least Medium strength using Symfony's byte-based +// heuristic. See [validate.EstimatePasswordStrength] for its limitations. +func HasPasswordStrength() PasswordStrengthConstraint { + return PasswordStrengthConstraint{ + minScore: validate.PasswordStrengthMedium, + err: validation.ErrPasswordTooWeak, + messageTemplate: validation.ErrPasswordTooWeak.Message(), + } +} + +// WithMinScore sets the inclusive minimum, from Weak (1) to VeryStrong (4). +func (c PasswordStrengthConstraint) WithMinScore(score validate.PasswordStrengthScore) PasswordStrengthConstraint { + c.minScore = score + return c +} + +// WithEstimator sets a custom estimator returning 0–4. Nil restores the default. +// A custom estimator must be safe for concurrent calls if the constraint is shared. +func (c PasswordStrengthConstraint) WithEstimator(estimator validate.PasswordStrengthEstimator) PasswordStrengthConstraint { + c.estimator = estimator + return c +} + +// WithError overrides the error for a password below the required strength. +func (c PasswordStrengthConstraint) WithError(err error) PasswordStrengthConstraint { + c.err = err + return c +} + +// WithMessage sets a custom message with {{ strength }} and {{ minScore }} parameters. +// There is no automatic {{ value }} parameter containing the password. +func (c PasswordStrengthConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) PasswordStrengthConstraint { + c.messageTemplate = template + c.messageParameters = parameters + return c +} + +// When disables validation when condition is false. +func (c PasswordStrengthConstraint) When(condition bool) PasswordStrengthConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups limits validation to the specified groups. +func (c PasswordStrengthConstraint) WhenGroups(groups ...string) PasswordStrengthConstraint { + c.groups = groups + return c +} + +// ValidateString validates an optional password without storing it in a violation. +func (c PasswordStrengthConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) { + return nil + } + if c.minScore < validate.PasswordStrengthWeak || c.minScore > validate.PasswordStrengthVeryStrong { + return validator.CreateConstraintError("PasswordStrengthConstraint", "minimum score must be between 1 and 4") + } + if value == nil { + return nil + } + score, err := c.check(*value) + if err == nil { + return nil + } + if errors.Is(err, validate.ErrInvalidPasswordStrengthScore) { + return validator.CreateConstraintError("PasswordStrengthConstraint", "estimator score must be between 0 and 4") + } + return validator.BuildViolation(ctx, c.err, c.messageTemplate).WithParameters(c.messageParameters.Prepend( + validation.TemplateParameter{Key: "{{ strength }}", Value: strconv.Itoa(int(score))}, + validation.TemplateParameter{Key: "{{ minScore }}", Value: strconv.Itoa(int(c.minScore))}, + )...).Create() +} + +func (c PasswordStrengthConstraint) check(password string) (validate.PasswordStrengthScore, error) { + estimator := c.estimator + if estimator == nil { + estimator = validate.EstimatePasswordStrength + } + var score validate.PasswordStrengthScore + err := validate.PasswordStrength(password, validate.WithMinPasswordStrength(c.minScore), + validate.WithPasswordStrengthEstimator(func(value string) validate.PasswordStrengthScore { + score = estimator(value) + return score + })) + return score, err +} + +// Validate implements [validation.Constraint][string] for [validation.This] and [validation.Each]. +func (c PasswordStrengthConstraint) Validate(ctx context.Context, validator *validation.Validator, value string) error { + return c.ValidateString(ctx, validator, &value) +} diff --git a/it/password_strength_test.go b/it/password_strength_test.go new file mode 100644 index 0000000..66d70d7 --- /dev/null +++ b/it/password_strength_test.go @@ -0,0 +1,55 @@ +package it_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/muonsoft/language" + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message/translations/russian" + "github.com/muonsoft/validation/validate" + "github.com/stretchr/testify/require" +) + +func TestPasswordStrengthConstraint(t *testing.T) { + v, err := validation.NewValidator(validation.Translations(russian.Messages)) + require.NoError(t, err) + ctx := context.Background() + calls := 0 + base := it.HasPasswordStrength().WithEstimator(func(string) validate.PasswordStrengthScore { calls++; return 1 }) + require.NoError(t, base.ValidateString(ctx, v, nil)) + require.Equal(t, 0, calls) + custom := errors.New("custom") + err = base.WithError(custom).WithMessage("{{ strength }}/{{ minScore }} {{ custom }}", validation.TemplateParameter{Key: "{{ custom }}", Value: "test"}).Validate(ctx, v, "PRIVATE_PASSWORD") + require.ErrorIs(t, err, custom) + require.EqualError(t, err, `violation: "1/2 test"`) + require.Equal(t, 1, calls) + var violation validation.Violation + require.ErrorAs(t, err, &violation) + require.NotContains(t, fmt.Sprint(violation.Parameters()), "PRIVATE_PASSWORD") + require.NotContains(t, fmt.Sprint(violation.Parameters()), "{{ value }}") + require.ErrorIs(t, base.Validate(ctx, v, ""), validation.ErrPasswordTooWeak) + require.NoError(t, base.WithMinScore(validate.PasswordStrengthWeak).Validate(ctx, v, "test")) + require.ErrorIs(t, base.Validate(ctx, v, "test"), validation.ErrPasswordTooWeak) + require.NoError(t, base.When(false).Validate(ctx, v, "test")) + require.NoError(t, base.WhenGroups("password").Validate(ctx, v, "test")) + require.ErrorIs(t, base.WhenGroups("password").Validate(ctx, v.WithGroups("password"), "test"), validation.ErrPasswordTooWeak) + require.EqualError(t, it.HasPasswordStrength().Validate(ctx, v.WithLanguage(language.Russian), "short"), `violation: "Пароль слишком слабый. Используйте более надёжный пароль."`) + require.NoError(t, base.WithEstimator(nil).Validate(ctx, v, "Reasonable-pwd")) + require.NoError(t, v.Validate(ctx, validation.This("Reasonable-pwd", it.HasPasswordStrength()))) + require.ErrorIs(t, v.Validate(ctx, validation.Each([]string{"Reasonable-pwd", ""}, it.HasPasswordStrength())), validation.ErrPasswordTooWeak) +} + +func TestPasswordStrengthConfigurationErrors(t *testing.T) { + v, err := validation.NewValidator() + require.NoError(t, err) + for _, score := range []validate.PasswordStrengthScore{-1, 0, 5} { + require.EqualError(t, it.HasPasswordStrength().WithMinScore(score).ValidateString(context.Background(), v, nil), "validate by PasswordStrengthConstraint: minimum score must be between 1 and 4") + } + for _, score := range []validate.PasswordStrengthScore{-1, 5} { + require.EqualError(t, it.HasPasswordStrength().WithEstimator(func(string) validate.PasswordStrengthScore { return score }).Validate(context.Background(), v, "secret"), "validate by PasswordStrengthConstraint: estimator score must be between 0 and 4") + } +} diff --git a/message/messages.go b/message/messages.go index 36a4c24..5f94842 100644 --- a/message/messages.go +++ b/message/messages.go @@ -60,6 +60,7 @@ const ( InvalidJSON = "This value should be valid JSON." InvalidLUHN = "Invalid card number." InvalidMAC = "This value is not a valid MAC address." + PasswordTooWeak = "The password strength is too low. Please use a stronger password." InvalidCSSColor = "This value is not a valid CSS color." InvalidCardScheme = "Unsupported card type or invalid card number." InvalidWeekFormat = "This value is not a valid ISO week in YYYY-Www format." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 8d41707..427813e 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -73,6 +73,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidJSON: catalog.String(message.InvalidJSON), message.InvalidLUHN: catalog.String(message.InvalidLUHN), message.InvalidMAC: catalog.String(message.InvalidMAC), + message.PasswordTooWeak: catalog.String(message.PasswordTooWeak), message.InvalidCSSColor: catalog.String(message.InvalidCSSColor), message.InvalidCardScheme: catalog.String(message.InvalidCardScheme), message.InvalidWeekFormat: catalog.String(message.InvalidWeekFormat), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index cdb12b2..e50c1e0 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -76,6 +76,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."), message.InvalidLUHN: catalog.String("Недействительный номер карты."), message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."), + message.PasswordTooWeak: catalog.String("Пароль слишком слабый. Используйте более надёжный пароль."), message.InvalidCSSColor: catalog.String("Значение не является допустимым цветом CSS."), message.InvalidCardScheme: catalog.String("Неподдерживаемый тип карты или неверный номер карты."), message.InvalidWeekFormat: catalog.String("Значение не является допустимой ISO-неделей в формате YYYY-Www."), diff --git a/test/constraints_password_strength_cases_test.go b/test/constraints_password_strength_cases_test.go new file mode 100644 index 0000000..0cd2731 --- /dev/null +++ b/test/constraints_password_strength_cases_test.go @@ -0,0 +1,14 @@ +package test + +import ( + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message" +) + +var passwordStrengthConstraintTestCases = []ConstraintValidationTestCase{ + {name: "PasswordStrength nil", isApplicableFor: specificValueTypes(stringType), constraint: it.HasPasswordStrength(), assert: assertNoError}, + {name: "PasswordStrength empty", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue(""), constraint: it.HasPasswordStrength(), assert: assertHasOneViolation(validation.ErrPasswordTooWeak, message.PasswordTooWeak)}, + {name: "PasswordStrength weak", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("password"), constraint: it.HasPasswordStrength(), assert: assertHasOneViolation(validation.ErrPasswordTooWeak, message.PasswordTooWeak)}, + {name: "PasswordStrength valid", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("Reasonable-pwd"), constraint: it.HasPasswordStrength(), assert: assertNoError}, +} diff --git a/test/constraints_test.go b/test/constraints_test.go index 088444f..b223c4e 100644 --- a/test/constraints_test.go +++ b/test/constraints_test.go @@ -50,6 +50,7 @@ type ConstraintValidationTestCase struct { } var validateTestCases = mergeTestCases( + passwordStrengthConstraintTestCases, cssColorConstraintTestCases, cardSchemeConstraintTestCases, weekConstraintTestCases, diff --git a/validate/password_strength.go b/validate/password_strength.go new file mode 100644 index 0000000..d9a6bf0 --- /dev/null +++ b/validate/password_strength.go @@ -0,0 +1,143 @@ +package validate + +import ( + "errors" + "math" +) + +// PasswordStrengthScore is a heuristic score from 0 (very weak) to 4 (very strong). +type PasswordStrengthScore int + +const ( + // PasswordStrengthVeryWeak is the lowest score, including empty passwords. + PasswordStrengthVeryWeak PasswordStrengthScore = iota + // PasswordStrengthWeak corresponds to an entropy estimate of at least 60. + PasswordStrengthWeak + // PasswordStrengthMedium corresponds to an entropy estimate of at least 80. + PasswordStrengthMedium + // PasswordStrengthStrong corresponds to an entropy estimate of at least 100. + PasswordStrengthStrong + // PasswordStrengthVeryStrong corresponds to an entropy estimate of at least 120. + PasswordStrengthVeryStrong +) + +var ( + // ErrPasswordTooWeak indicates a score below the configured minimum. + ErrPasswordTooWeak = errors.New("password strength too low") + // ErrInvalidPasswordStrengthMinimum indicates a minimum outside 1–4. + ErrInvalidPasswordStrengthMinimum = errors.New("invalid password strength minimum") + // ErrInvalidPasswordStrengthScore indicates an estimator result outside 0–4. + ErrInvalidPasswordStrengthScore = errors.New("invalid password strength score") +) + +// PasswordStrengthEstimator evaluates a password. It must return a score from 0 to 4. +// Implementations used concurrently must be safe for concurrent calls. +type PasswordStrengthEstimator func(string) PasswordStrengthScore + +// PasswordStrengthOptions configures the minimum and optional custom estimator. +type PasswordStrengthOptions struct { + minimum PasswordStrengthScore + estimator PasswordStrengthEstimator +} + +// WithMinPasswordStrength sets the inclusive minimum score (1–4). Default is Medium. +func WithMinPasswordStrength(score PasswordStrengthScore) func(*PasswordStrengthOptions) { + return func(o *PasswordStrengthOptions) { o.minimum = score } +} + +// WithPasswordStrengthEstimator replaces the estimator. Nil restores the default. +func WithPasswordStrengthEstimator(estimator PasswordStrengthEstimator) func(*PasswordStrengthOptions) { + return func(o *PasswordStrengthOptions) { o.estimator = estimator } +} + +// PasswordStrength validates a password's score, defaulting to Medium. +// Empty strings are evaluated (score 0 by default), not skipped. +// It returns ErrPasswordTooWeak, ErrInvalidPasswordStrengthMinimum, or +// ErrInvalidPasswordStrengthScore on failure. Errors never contain the password. +func PasswordStrength(password string, options ...func(*PasswordStrengthOptions)) error { + o := PasswordStrengthOptions{minimum: PasswordStrengthMedium} + for _, option := range options { + option(&o) + } + if o.minimum < PasswordStrengthWeak || o.minimum > PasswordStrengthVeryStrong { + return ErrInvalidPasswordStrengthMinimum + } + if o.estimator == nil { + o.estimator = EstimatePasswordStrength + } + score := o.estimator(password) + if score < PasswordStrengthVeryWeak || score > PasswordStrengthVeryStrong { + return ErrInvalidPasswordStrengthScore + } + if score < o.minimum { + return ErrPasswordTooWeak + } + return nil +} + +// EstimatePasswordStrength reproduces Symfony 8.0's byte-based heuristic. +// It uses byte length, unique byte count, and character category pool sizes. +// No Unicode normalization, dictionary, sequence detection, or breach lookup is +// performed. The score is not a guarantee of resistance to password guessing. +// Empty input returns VeryWeak. Runtime is O(len(password)), memory is O(1). +// See https://github.com/symfony/validator/blob/8.0/Constraints/PasswordStrengthValidator.php. +func EstimatePasswordStrength(password string) PasswordStrengthScore { + if len(password) == 0 { + return PasswordStrengthVeryWeak + } + var seen [256]bool + var categories [6]bool + unique := 0 + for i := 0; i < len(password); i++ { + b := password[i] + if !seen[b] { + seen[b] = true + unique++ + categories[passwordByteCategory(b)] = true + } + } + pool := 0 + for i, size := range [...]int{33, 10, 26, 26, 128, 33} { + if categories[i] { + pool += size + } + } + entropy := float64(unique)*math.Log2(float64(pool)) + float64(len(password)-unique)*math.Log2(float64(unique)) + return passwordEntropyScore(entropy) +} + +func passwordByteCategory(b byte) int { + switch { + case b < 32 || b == 127: + return 0 + case passwordByteInRange(b, '0', '9'): + return 1 + case passwordByteInRange(b, 'A', 'Z'): + return 2 + case passwordByteInRange(b, 'a', 'z'): + return 3 + case b >= 128: + return 4 + default: + return 5 + } +} + +func passwordEntropyScore(entropy float64) PasswordStrengthScore { + switch { + case entropy >= 120: + return PasswordStrengthVeryStrong + case entropy >= 100: + return PasswordStrengthStrong + case entropy >= 80: + return PasswordStrengthMedium + case entropy >= 60: + return PasswordStrengthWeak + default: + return PasswordStrengthVeryWeak + } +} + +func passwordByteInRange(b, first, last byte) bool { + return b >= first && b <= last +} diff --git a/validate/password_strength_test.go b/validate/password_strength_test.go new file mode 100644 index 0000000..9ef3086 --- /dev/null +++ b/validate/password_strength_test.go @@ -0,0 +1,96 @@ +package validate_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/muonsoft/validation/validate" + "github.com/stretchr/testify/require" +) + +func TestEstimatePasswordStrength(t *testing.T) { + tests := []struct { + password string + score validate.PasswordStrengthScore + }{ + {"", 0}, + {"password", 0}, + {"How-is-this", 1}, + {"Reasonable-pwd", 2}, + {"Good password?", 1}, + {"This 1s a very g00d Pa55word! ;-)", 4}, + {"pudding-smack-👌🏼-fox-😎", 4}, + {strings.Repeat("a", 1000), 0}, + {strings.Repeat("ab", 50), 3}, + {"abcdefghijklmnopqrstuvwxyz", 4}, + } + for i, tt := range tests { + t.Run(fmt.Sprint(i), func(t *testing.T) { require.Equal(t, tt.score, validate.EstimatePasswordStrength(tt.password)) }) + } +} + +func TestPasswordStrengthCategoryThresholds(t *testing.T) { + // Four unique bytes give exactly two estimated bits per repeated byte. + // These lengths are immediately below and above each threshold for each pool. + tests := []struct { + chars string + lengths []int + }{ + {"0123", []int{28, 38, 48, 58}}, + {"abcd", []int{25, 35, 45, 55}}, + {"ABCD", []int{25, 35, 45, 55}}, + {" !@#", []int{24, 34, 44, 54}}, + {"\x00\x01\x1f\x7f", []int{24, 34, 44, 54}}, + {"\x80\x81\xfe\xff", []int{20, 30, 40, 50}}, + } + for i, tt := range tests { + for boundary, length := range tt.lengths { + t.Run(fmt.Sprintf("%d/%d", i, boundary), func(t *testing.T) { + input := strings.Repeat(tt.chars, 20) + require.Equal(t, validate.PasswordStrengthScore(boundary), validate.EstimatePasswordStrength(input[:length-1])) + require.Equal(t, validate.PasswordStrengthScore(boundary+1), validate.EstimatePasswordStrength(input[:length])) + }) + } + } +} + +func TestPasswordStrengthOptions(t *testing.T) { + require.ErrorIs(t, validate.PasswordStrength(""), validate.ErrPasswordTooWeak) + require.NoError(t, validate.PasswordStrength("Reasonable-pwd")) + for score := validate.PasswordStrengthVeryWeak; score <= validate.PasswordStrengthVeryStrong; score++ { + for minimum := validate.PasswordStrengthWeak; minimum <= validate.PasswordStrengthVeryStrong; minimum++ { + calls := 0 + err := validate.PasswordStrength("secret", validate.WithMinPasswordStrength(minimum), validate.WithPasswordStrengthEstimator(func(value string) validate.PasswordStrengthScore { + calls++ + require.Equal(t, "secret", value) + return score + })) + require.Equal(t, 1, calls) + if score < minimum { + require.ErrorIs(t, err, validate.ErrPasswordTooWeak) + } else { + require.NoError(t, err) + } + } + } + for _, minimum := range []validate.PasswordStrengthScore{-1, 0, 5} { + require.ErrorIs(t, validate.PasswordStrength("", validate.WithMinPasswordStrength(minimum), validate.WithPasswordStrengthEstimator(func(string) validate.PasswordStrengthScore { t.Fatal("must not run"); return 0 })), validate.ErrInvalidPasswordStrengthMinimum) + } + for _, score := range []validate.PasswordStrengthScore{-1, 5} { + require.ErrorIs(t, validate.PasswordStrength("secret", validate.WithPasswordStrengthEstimator(func(string) validate.PasswordStrengthScore { return score })), validate.ErrInvalidPasswordStrengthScore) + } + require.NoError(t, validate.PasswordStrength("Reasonable-pwd", validate.WithPasswordStrengthEstimator(nil))) +} + +func ExampleEstimatePasswordStrength() { + fmt.Println(validate.EstimatePasswordStrength("Reasonable-pwd")) + // Output: + // 2 +} + +func ExamplePasswordStrength() { + fmt.Println(validate.PasswordStrength("Reasonable-pwd")) + // Output: + // +} From 320ad5015f24400154b3e8ad76cf71f3f299058c Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Fri, 2 Oct 2026 16:42:31 +0000 Subject: [PATCH 2/2] Remove redundant boolean password strength helper --- CHANGELOG.md | 2 +- README.md | 2 +- is/password_strength.go | 9 --------- is/password_strength_test.go | 25 ------------------------- 4 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 is/password_strength.go delete mode 100644 is/password_strength_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 81894b1..c9c3c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `it.HasPasswordStrength`, `validate.PasswordStrength`, `is.PasswordStrength`, and `validate.EstimatePasswordStrength` using Symfony 8.0’s byte-based heuristic, configurable minimum scores and estimators, and English/Russian messages. Empty passwords are evaluated; passwords are not automatically included in violations. No new dependencies. +- Add `it.HasPasswordStrength`, `validate.PasswordStrength`, and `validate.EstimatePasswordStrength` using Symfony 8.0’s byte-based heuristic, configurable minimum scores and estimators, and English/Russian messages. Empty passwords are evaluated; passwords are not automatically included in violations. No new dependencies. - Add `it.IsCSSColor`, `validate.CSSColor`, and `is.CSSColor` with 12 selectable Symfony 8.0 color formats, English/Russian messages, and no new dependencies. Document strict legacy function syntax and unsupported modern CSS features. diff --git a/README.md b/README.md index 8089c46..db07461 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ err := validator.Validate(context.Background(), `PasswordStrengthVeryWeak` (0), `PasswordStrengthWeak` (1), `PasswordStrengthMedium` (2), `PasswordStrengthStrong` (3), or `PasswordStrengthVeryStrong` (4). Minimum scores must be 1–4. Standalone -`validate.PasswordStrength` and `is.PasswordStrength` accept +`validate.PasswordStrength` accepts `validate.WithMinPasswordStrength` and `validate.WithPasswordStrengthEstimator`. The estimator follows [Symfony 8.0](https://github.com/symfony/validator/blob/8.0/Constraints/PasswordStrengthValidator.php): diff --git a/is/password_strength.go b/is/password_strength.go deleted file mode 100644 index 79f8ac3..0000000 --- a/is/password_strength.go +++ /dev/null @@ -1,9 +0,0 @@ -package is - -import "github.com/muonsoft/validation/validate" - -// PasswordStrength reports whether a password meets the configured minimum score. -// See [validate.PasswordStrength] for options and empty-value behavior. -func PasswordStrength(password string, options ...func(*validate.PasswordStrengthOptions)) bool { - return validate.PasswordStrength(password, options...) == nil -} diff --git a/is/password_strength_test.go b/is/password_strength_test.go deleted file mode 100644 index 5492bf8..0000000 --- a/is/password_strength_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package is_test - -import ( - "fmt" - "testing" - - "github.com/muonsoft/validation/is" - "github.com/muonsoft/validation/validate" - "github.com/stretchr/testify/require" -) - -func TestPasswordStrength(t *testing.T) { - require.True(t, is.PasswordStrength("Reasonable-pwd")) - require.False(t, is.PasswordStrength("")) - require.False(t, is.PasswordStrength("password")) - require.False(t, is.PasswordStrength("Reasonable-pwd", validate.WithMinPasswordStrength(validate.PasswordStrengthStrong))) - require.False(t, is.PasswordStrength("secret", validate.WithMinPasswordStrength(0))) - require.True(t, is.PasswordStrength("custom", validate.WithPasswordStrengthEstimator(func(string) validate.PasswordStrengthScore { return 4 }))) -} - -func ExamplePasswordStrength() { - fmt.Println(is.PasswordStrength("Reasonable-pwd")) - // Output: - // true -}