Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

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

- 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.
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` accepts
`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 |
Expand Down
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <nil>
}

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."
}
115 changes: 115 additions & 0 deletions it/password_strength.go
Original file line number Diff line number Diff line change
@@ -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)
}
55 changes: 55 additions & 0 deletions it/password_strength_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions message/translations/english/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions message/translations/russian/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down
14 changes: 14 additions & 0 deletions test/constraints_password_strength_cases_test.go
Original file line number Diff line number Diff line change
@@ -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},
}
1 change: 1 addition & 0 deletions test/constraints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type ConstraintValidationTestCase struct {
}

var validateTestCases = mergeTestCases(
passwordStrengthConstraintTestCases,
cssColorConstraintTestCases,
cardSchemeConstraintTestCases,
weekConstraintTestCases,
Expand Down
Loading
Loading