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
239 lines
8.2 KiB
Go
239 lines
8.2 KiB
Go
// 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
|
|
}
|