snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
238
selfservice/strategy/password/validator.go
Normal file
238
selfservice/strategy/password/validator.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha1" //#nosec G505 -- sha1 is used for k-anonymity
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/arbovm/levenshtein"
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
const hashCacheItemTTL = time.Hour
|
||||
|
||||
// maxIdentifierSimilarityCheckLength bounds the operands of the O(n·m)
|
||||
// identifier-similarity check to keep its worst-case cost constant. Real
|
||||
// identifiers (emails, usernames) and passwords are far shorter than this, so
|
||||
// legitimate flows are unaffected, while oversized operands are skipped to keep
|
||||
// the check's cost bounded. The bound is checked on the raw byte length as a
|
||||
// cheap pre-filter and again on the case-folded operands, since lowercasing can
|
||||
// expand an operand (e.g. Turkish dotted I).
|
||||
const maxIdentifierSimilarityCheckLength = 256
|
||||
|
||||
// Validator implements a validation strategy for passwords. One example is that the password
|
||||
// has to have at least 6 characters and at least one lower and one uppercase password.
|
||||
type Validator interface {
|
||||
// Validate returns nil if the password is passing the validation strategy and an error otherwise. If a validation error
|
||||
// occurs, a regular error will be returned. If some other type of error occurs (e.g. HTTP request failed), an error
|
||||
// of type *herodot.DefaultError will be returned.
|
||||
Validate(ctx context.Context, identifier, password string) error
|
||||
}
|
||||
|
||||
type ValidationProvider interface {
|
||||
PasswordValidator() Validator
|
||||
}
|
||||
|
||||
var (
|
||||
_ Validator = new(DefaultPasswordValidator)
|
||||
ErrNetworkFailure = herodot.ErrUpstreamError().WithError("Leaked password server unavailable").WithReasonf("Unable to check if password has been leaked because an unexpected network error occurred")
|
||||
ErrUnexpectedStatusCode = herodot.ErrUpstreamError().WithError("Leaked password server unavailable").WithReasonf("Unexpected status code from haveibeenpwned.com")
|
||||
)
|
||||
|
||||
// DefaultPasswordValidator implements Validator. It is based on best
|
||||
// practices as defined in the following blog posts:
|
||||
//
|
||||
// - https://www.troyhunt.com/passwords-evolved-authentication-guidance-for-the-modern-era/
|
||||
// - https://www.microsoft.com/en-us/research/wp-content/uploads/2016/06/Microsoft_Password_Guidance-1.pdf
|
||||
//
|
||||
// Additionally passwords are being checked against Troy Hunt's
|
||||
// [haveibeenpwnd](https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange) service to check if the
|
||||
// password has been breached in a previous data leak using k-anonymity.
|
||||
type DefaultPasswordValidator struct {
|
||||
reg validatorDependencies
|
||||
hashes *ristretto.Cache[string, int64]
|
||||
|
||||
minIdentifierPasswordDist int
|
||||
maxIdentifierPasswordSubstrThreshold float32
|
||||
}
|
||||
|
||||
type validatorDependencies interface {
|
||||
config.Provider
|
||||
httpx.ClientProvider
|
||||
}
|
||||
|
||||
func NewDefaultPasswordValidatorStrategy(reg validatorDependencies) (*DefaultPasswordValidator, error) {
|
||||
cache, err := ristretto.NewCache(&ristretto.Config[string, int64]{
|
||||
NumCounters: 10 * 10000,
|
||||
MaxCost: 60 * 10000, // BCrypt hash size is 60 bytes
|
||||
BufferItems: 64,
|
||||
IgnoreInternalCost: true,
|
||||
})
|
||||
// sanity check - this should never happen unless above configuration variables are invalid
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error while setting up validator cache")
|
||||
}
|
||||
return &DefaultPasswordValidator{
|
||||
reg: reg,
|
||||
hashes: cache,
|
||||
minIdentifierPasswordDist: 5, maxIdentifierPasswordSubstrThreshold: 0.5,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func b20(src []byte) string {
|
||||
return fmt.Sprintf("%X", src)
|
||||
}
|
||||
|
||||
// code inspired by https://rosettacode.org/wiki/Longest_Common_Substring#Go
|
||||
func lcsLength(a, b string) int {
|
||||
lengths := make([]int, len(a)*len(b))
|
||||
greatestLength := 0
|
||||
for i, x := range a {
|
||||
for j, y := range b {
|
||||
if x == y {
|
||||
curr := 1
|
||||
if i != 0 && j != 0 {
|
||||
curr = lengths[(i-1)*len(b)+j-1] + 1
|
||||
}
|
||||
|
||||
if curr > greatestLength {
|
||||
greatestLength = curr
|
||||
}
|
||||
lengths[i*len(b)+j] = curr
|
||||
}
|
||||
}
|
||||
}
|
||||
return greatestLength
|
||||
}
|
||||
|
||||
func (s *DefaultPasswordValidator) fetch(ctx context.Context, hpw []byte, apiDNSName string) (int64, error) {
|
||||
prefix := fmt.Sprintf("%X", hpw)[0:5]
|
||||
loc := fmt.Sprintf("https://%s/range/%s", apiDNSName, prefix)
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, "GET", loc, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.reg.HTTPClient(ctx, httpx.ResilientClientWithConnectionTimeout(time.Second)).Do(req)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(ErrNetworkFailure, "%s", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return 0, errors.Wrapf(ErrUnexpectedStatusCode, "%d", res.StatusCode)
|
||||
}
|
||||
|
||||
var thisCount int64
|
||||
|
||||
sc := bufio.NewScanner(res.Body)
|
||||
for sc.Scan() {
|
||||
row := sc.Text()
|
||||
result := strings.Split(strings.TrimSpace(row), ":")
|
||||
|
||||
// We assume a count of 1. HIBP API sometimes responds without the
|
||||
// colon, so we just assume that the leak count is one.
|
||||
//
|
||||
// See https://github.com/ory/kratos/issues/2145
|
||||
count := int64(1)
|
||||
if len(result) == 2 {
|
||||
count, err = strconv.ParseInt(strings.ReplaceAll(result[1], ",", ""), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("Expected password hash to contain a count formatted as int but got: %s", result[1]))
|
||||
}
|
||||
}
|
||||
|
||||
s.hashes.SetWithTTL(prefix+result[0], count, 1, hashCacheItemTTL)
|
||||
if prefix+result[0] == b20(hpw) {
|
||||
thisCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// A read that fails after the response headers arrived (connection reset,
|
||||
// client timeout, truncated body) is as much a network failure as a failed
|
||||
// request, so it has to be classified as one to stay subject to
|
||||
// ignore_network_errors.
|
||||
if err := sc.Err(); err != nil {
|
||||
return 0, errors.Wrapf(ErrNetworkFailure, "unable to read the response body: %s", err)
|
||||
}
|
||||
|
||||
s.hashes.SetWithTTL(b20(hpw), thisCount, 1, hashCacheItemTTL)
|
||||
return thisCount, nil
|
||||
}
|
||||
|
||||
func (s *DefaultPasswordValidator) Validate(ctx context.Context, identifier, password string) error {
|
||||
return otelx.WithSpan(ctx, "password.DefaultPasswordValidator.Validate", func(ctx context.Context) error {
|
||||
return s.validate(ctx, identifier, password)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DefaultPasswordValidator) validate(ctx context.Context, identifier, password string) error {
|
||||
passwordPolicyConfig := s.reg.Config().PasswordPolicyConfig(ctx)
|
||||
|
||||
//nolint:gosec // disable G115
|
||||
if len(password) < int(passwordPolicyConfig.MinPasswordLength) {
|
||||
//nolint:gosec // disable G115
|
||||
return text.NewErrorValidationPasswordMinLength(int(passwordPolicyConfig.MinPasswordLength), len(password))
|
||||
}
|
||||
|
||||
if passwordPolicyConfig.IdentifierSimilarityCheckEnabled && len(identifier) > 0 &&
|
||||
len(identifier) <= maxIdentifierSimilarityCheckLength &&
|
||||
len(password) <= maxIdentifierSimilarityCheckLength {
|
||||
compIdentifier, compPassword := strings.ToLower(identifier), strings.ToLower(password)
|
||||
// Case-folding can expand an operand, so re-check the folded lengths
|
||||
// before running the O(n·m) comparison.
|
||||
if len(compIdentifier) <= maxIdentifierSimilarityCheckLength &&
|
||||
len(compPassword) <= maxIdentifierSimilarityCheckLength {
|
||||
dist := levenshtein.Distance(compIdentifier, compPassword)
|
||||
lcs := float32(lcsLength(compIdentifier, compPassword)) / float32(len(compPassword))
|
||||
if dist < s.minIdentifierPasswordDist || lcs > s.maxIdentifierPasswordSubstrThreshold {
|
||||
return text.NewErrorValidationPasswordIdentifierTooSimilar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !passwordPolicyConfig.HaveIBeenPwnedEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
//#nosec G401 -- sha1 is used for k-anonymity
|
||||
h := sha1.New()
|
||||
if _, err := h.Write([]byte(password)); err != nil {
|
||||
return err
|
||||
}
|
||||
hpw := h.Sum(nil)
|
||||
|
||||
c, ok := s.hashes.Get(b20(hpw))
|
||||
if !ok {
|
||||
var err error
|
||||
c, err = s.fetch(ctx, hpw, passwordPolicyConfig.HaveIBeenPwnedHost)
|
||||
if (errors.Is(err, ErrNetworkFailure) || errors.Is(err, ErrUnexpectedStatusCode)) && passwordPolicyConfig.IgnoreNetworkErrors {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gosec // disable G115
|
||||
if c > int64(s.reg.Config().PasswordPolicyConfig(ctx).MaxBreaches) {
|
||||
return text.NewErrorValidationPasswordTooManyBreaches(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user