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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/login.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"password": {
"type": "string",
"minLength": 1
},
"identifier": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string"
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
},
"allOf": [
{
"if": {
"properties": {
"method": {
"const": "password"
}
},
"required": [
"method"
]
},
"then": {
"required": [
"password"
]
}
},
{
"if": {
"properties": {
"password_identifier": {
"type": "string",
"minLength": 1
}
},
"required": [
"password_identifier"
]
},
"then": {
"required": [
"password_identifier"
]
},
"else": {
"required": [
"identifier"
]
}
}
]
}

View File

@@ -0,0 +1,39 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"password": {
"type": "string",
"minLength": 1
},
"traits": {
"description": "This field will be overwritten in registration.go's decoder() method. Do not add anything to this field as it has no effect."
},
"method": {
"type": "string"
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
},
"if": {
"properties": {
"method": {
"const": "password"
}
},
"required": [
"method"
]
},
"then": {
"required": [
"password"
]
}
}

View File

@@ -0,0 +1,24 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"password"
],
"properties": {
"csrf_token": {
"type": "string"
},
"method": {
"type": "string"
},
"password": {
"type": "string",
"minLength": 1
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "current-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010022,
"text": "Sign in with password",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,67 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "hidden",
"value": "some@user.com",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "current-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010001,
"text": "Sign in",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "current-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010022,
"text": "Sign in with password",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "current-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010022,
"text": "Sign in with password",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "current-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010022,
"text": "Sign in with password",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "current-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010022,
"text": "Sign in with password",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,15 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,15 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,15 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import "net/url"
func tidyForm(vv url.Values) url.Values {
for _, k := range []string{"password", "csrf_token", "flow"} {
vv.Del(k)
}
return vv
}

View File

@@ -0,0 +1,20 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTidyForm(t *testing.T) {
assert.EqualValues(t, url.Values{"foobar": {"foo"}}, tidyForm(url.Values{
"password": {"some-value"},
"csrf_token": {"some-value"},
"flow": {"some-value"},
"foobar": {"foo"},
}))
}

View File

@@ -0,0 +1,244 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"bytes"
"cmp"
"context"
"encoding/json"
"net/http"
"time"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/x/otelx"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flowhelpers"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/selfservice/strategy/idfirst"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/decoderx"
)
var _ login.AAL1FormHydrator = new(Strategy)
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, payload updateLoginFlowWithPasswordMethod, err error) error {
if f != nil {
f.UI.Nodes.ResetNodes("password")
f.UI.Nodes.SetValueAttribute("identifier", cmp.Or(payload.Identifier, payload.LegacyIdentifier))
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
}
return err
}
func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, _ *session.Session) (i *identity.Identity, err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.Login")
defer otelx.End(span, &err)
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil {
span.SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL1"))
return nil, err
}
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil {
return nil, err
}
var p updateLoginFlowWithPasswordMethod
if err := decoderx.Decode(r, &p,
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema),
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
return nil, s.handleLoginError(r, f, p, err)
}
f.TransientPayload = p.TransientPayload
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return nil, s.handleLoginError(r, f, p, err)
}
identifier := cmp.Or(p.Identifier, p.LegacyIdentifier)
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), identifier)
if err != nil {
time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(ctx).ExpectedDuration, s.d.Config().HasherArgon2(ctx).ExpectedDeviation))
return nil, s.handleLoginError(r, f, p, errors.WithStack(schema.NewInvalidCredentialsError()))
}
var o identity.CredentialsPassword
d := json.NewDecoder(bytes.NewBuffer(c.Config))
if err := d.Decode(&o); err != nil {
return nil, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("The password credentials could not be decoded properly").WithDebug(err.Error()).WithWrap(err)), i.ID)
}
if o.ShouldUsePasswordMigrationHook() {
pwHook := s.d.Config().PasswordMigrationHook(ctx)
if !pwHook.Enabled {
return nil, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Password migration hook is not enabled but password migration is requested.")), i.ID)
}
migrationHook := hook.NewPasswordMigrationHook(s.d, &pwHook.Config)
err = migrationHook.Execute(ctx, r, f, &hook.PasswordMigrationRequest{
Identifier: identifier,
Password: p.Password,
Identity: i,
})
if err != nil {
return nil, s.handleLoginError(r, f, p, x.WrapWithIdentityIDError(err, i.ID))
}
if err := s.migratePasswordHash(ctx, i.ID, []byte(p.Password)); err != nil {
return nil, s.handleLoginError(r, f, p, x.WrapWithIdentityIDError(err, i.ID))
}
} else {
if err := hash.Compare(ctx, []byte(p.Password), []byte(o.HashedPassword)); err != nil {
return nil, s.handleLoginError(r, f, p, errors.WithStack(x.WrapWithIdentityIDError(schema.NewInvalidCredentialsError(), i.ID)))
}
if !s.d.Hasher(ctx).Understands([]byte(o.HashedPassword)) {
if err := s.migratePasswordHash(ctx, i.ID, []byte(p.Password)); err != nil {
s.d.Logger().Warnf("Unable to migrate password hash for identity %s: %s Keeping existing password hash and continuing.", i.ID, x.WrapWithIdentityIDError(err, i.ID))
}
}
}
f.Active = s.ID()
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
return nil, s.handleLoginError(r, f, p, errors.WithStack(x.WrapWithIdentityIDError(herodot.ErrInternalServerError().WithReason("Could not update flow").WithDebug(err.Error()), i.ID)))
}
return i, nil
}
func (s *Strategy) migratePasswordHash(ctx context.Context, identifier uuid.UUID, password []byte) (err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.migratePasswordHash")
defer otelx.End(span, &err)
hpw, err := s.d.Hasher(ctx).Generate(ctx, password)
if err != nil {
return err
}
co, err := json.Marshal(&identity.CredentialsPassword{HashedPassword: string(hpw)})
if err != nil {
return errors.Wrap(err, "unable to encode password configuration to JSON")
}
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, identifier)
if err != nil {
return err
}
c, ok := i.GetCredentials(s.ID())
if !ok {
return errors.New("expected to find password credential but could not")
}
c.Config = co
i.SetCredentials(s.ID(), *c)
return s.d.IdentityManager().Update(ctx, i, identity.ManagerAllowWriteProtectedTraits)
}
func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow, _ *session.Session) (err error) {
ctx := r.Context()
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.PopulateLoginMethodFirstFactorRefresh")
defer otelx.End(span, &err)
identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID())
if identifier == "" {
return nil
}
// If we don't have a password set, do not show the password field.
count, err := s.CountActiveFirstFactorCredentials(ctx, id.Credentials)
if err != nil {
return err
} else if count == 0 {
return nil
}
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden))
sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword))
sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLogin()))
return nil
}
func (s *Strategy) addIdentifierNode(r *http.Request, sr *login.Flow) error {
ds, err := sr.IdentitySchema.URL(r.Context(), s.d.Config())
if err != nil {
return err
}
identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String(), s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
if err != nil {
return err
}
sr.UI.SetNode(node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel))
// node.WithInputAttributes(func(a *node.InputAttributes) { // TODO: https://github.com/ory-corp/cloud/issues/12416
// // Hint password managers to autofill the saved username for this site.
// a.Autocomplete = node.InputAttributeAutocompleteUsername
// })).WithMetaLabel(identifierLabel))
return nil
}
func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error {
if err := s.addIdentifierNode(r, sr); err != nil {
return err
}
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword))
sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword()))
return nil
}
func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) (err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.PopulateLoginMethodIdentifierFirstCredentials")
defer otelx.End(span, &err)
o := login.NewFormHydratorOptions(opts)
var count int
if o.IdentityHint != nil {
var err error
// If we have an identity hint we can perform identity credentials discovery and
// hide this credential if it should not be included.
if count, err = s.CountActiveFirstFactorCredentials(ctx, o.IdentityHint.Credentials); err != nil {
return err
}
}
if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(ctx) {
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword))
sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword()))
}
if count == 0 {
return errors.WithStack(idfirst.ErrNoCredentialsFound)
}
return nil
}
func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error {
return nil
}

View File

@@ -0,0 +1,45 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/configx"
)
func TestLoginIdentifierAutocomplete(t *testing.T) {
t.Skip() // TODO: https://github.com/ory-corp/cloud/issues/12416
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValue(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword)+".enabled", true),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/login.schema.json")),
)
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypePassword)
require.NoError(t, err)
fh, ok := s.(login.AAL1FormHydrator)
require.True(t, ok)
r := httptest.NewRequest("GET", "/self-service/login/browser", nil).WithContext(t.Context())
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f))
identifier := f.UI.Nodes.Find("identifier")
require.NotNil(t, identifier, "identifier node must exist")
attr, ok := identifier.Attributes.(*node.InputAttributes)
require.True(t, ok)
require.Equal(t, node.InputAttributeAutocompleteUsername, attr.Autocomplete)
}

View File

@@ -0,0 +1,59 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/pkg"
)
// TestLoginConfigMutationIsolation guards against Race B: a config-mutating
// subtest must not share its *configx.Provider with requests that read config
// concurrently. The reader path (Bcrypt.Generate -> Config().HasherBcrypt ->
// IsInsecureDevMode -> Provider.Bool) and the writer path (Config.MustSet ->
// Provider.Set -> replaceKoanf, swapping p.Koanf) operate on the same
// *configx.Provider when a registry is shared, which the race detector flags.
//
// The fix is that a config-mutating subtest owns its own registry+config, so
// reads on one registry never race writes on another. This test verifies that
// the per-registry isolation pattern is race-free: a reader keeps hashing on
// one registry while a writer mutates config on its own, separate registry.
func TestLoginConfigMutationIsolation(t *testing.T) {
t.Parallel()
// Reader owns its registry, mirroring an in-flight request that hashes a
// password and reads IsInsecureDevMode on every call.
_, readerReg := pkg.NewFastRegistryWithMocks(t)
hasher := hash.NewHasherBcrypt(readerReg)
// Writer owns a separate registry+config, mirroring a config-mutating
// subtest that calls MustSet (and its cleanup), as required by
// .claude/rules/go.md: "subtests that need parallelization must own their
// resources".
writerConf, _ := pkg.NewFastRegistryWithMocks(t)
var eg errgroup.Group
eg.Go(func() error {
for range 200 {
if _, err := hasher.Generate(t.Context(), []byte("a-long-enough-password")); err != nil {
return err
}
}
return nil
})
eg.Go(func() error {
for range 200 {
writerConf.MustSet(t.Context(), config.ViperKeyUseLegacyRequireVerifiedLoginError, true)
writerConf.MustSet(t.Context(), config.ViperKeyUseLegacyRequireVerifiedLoginError, false)
}
return nil
})
require.NoError(t, eg.Wait())
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
)
func NewPasswordNode(name string, autocomplete node.UiNodeInputAttributeAutocomplete) *node.Node {
return node.NewInputField(name, nil, node.PasswordGroup,
node.InputAttributeTypePassword,
node.WithRequiredInputAttribute,
node.WithInputAttributes(func(a *node.InputAttributes) {
a.Autocomplete = autocomplete
})).
WithMetaLabel(text.NewInfoNodeInputPassword())
}

View File

@@ -0,0 +1,224 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/phayes/freeport"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
dockertest "github.com/ory/dockertest/v4"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/x/urlx"
)
type clientAppConfig struct {
client *oauth2.Config
expectToken bool
state *clientAppState
}
type clientAppState struct {
visits int64
tokens int64
}
type callTrace string
const (
RegistrationUI callTrace = "registration-ui"
RegistrationWithOAuth2LoginChallenge callTrace = "registration-with-oauth2-login-challenge"
RegistrationWithFlowID callTrace = "registration-with-flow-id"
LoginUI callTrace = "login-ui"
LoginWithOAuth2LoginChallenge callTrace = "login-with-oauth2-login-challenge"
LoginWithFlowID callTrace = "login-with-flow-id"
Consent callTrace = "consent"
ConsentWithChallenge callTrace = "consent-with-challenge"
ConsentAccept callTrace = "consent-accept"
ConsentSkip callTrace = "consent-skip"
ConsentClientSkip callTrace = "consent-client-skip"
CodeExchange callTrace = "code-exchange"
CodeExchangeWithToken callTrace = "code-exchange-with-token"
)
type testContextKey string
const (
TestUIConfig testContextKey = "test-ui-config"
TestOAuthClientState testContextKey = "test-oauth-client-state"
)
type testConfig struct {
identifier string
password string
browserClient *http.Client
kratosPublicTS *httptest.Server
clientAppTS *httptest.Server
hydraAdminClient hydraclientgo.OAuth2API
consentRemember bool
requestedScope []string
callTrace *[]callTrace
}
func createHydraOAuth2ApiClient(url string) hydraclientgo.OAuth2API {
configuration := hydraclientgo.NewConfiguration()
configuration.Host = urlx.ParseOrPanic(url).Host
configuration.Servers = hydraclientgo.ServerConfigurations{{URL: url}}
return hydraclientgo.NewAPIClient(configuration).OAuth2API
}
func createOAuth2Client(t *testing.T, ctx context.Context, hydraAdmin hydraclientgo.OAuth2API, redirectURIs []string, scope string, skipConsent bool) string {
t.Helper()
clientName := "kratos-hydra-integration-test-client-1"
tokenEndpointAuthMethod := "client_secret_post"
clientSecret := "client-secret"
c, r, err := hydraAdmin.CreateOAuth2Client(ctx).OAuth2Client(
hydraclientgo.OAuth2Client{
ClientName: &clientName,
RedirectUris: redirectURIs,
Scope: &scope,
TokenEndpointAuthMethod: &tokenEndpointAuthMethod,
ClientSecret: &clientSecret,
SkipConsent: &skipConsent,
},
).Execute()
require.NoError(t, err)
require.Equal(t, r.StatusCode, http.StatusCreated)
return *c.ClientId
}
func makeAuthCodeURL(t *testing.T, c *oauth2.Config, requestedClaims string, isForced bool) string {
t.Helper()
var options []oauth2.AuthCodeOption
if isForced {
options = append(options, oauth2.SetAuthURLParam("prompt", "login"))
}
if requestedClaims != "" {
options = append(options, oauth2.SetAuthURLParam("claims", requestedClaims))
}
state := fmt.Sprintf("%x", uuid.Must(uuid.NewV4()))
return c.AuthCodeURL(state, options...)
}
func newHydra(t *testing.T, loginUI string, consentUI string) (hydraAdmin string, hydraPublic string) {
pool := dockertest.NewPoolT(t, "")
// The public host port must be known before the container starts because it
// is part of URLS_SELF_ISSUER. freeport releases the port before Docker binds
// it, so another process can grab it in between — retry on that race.
var hydraResource dockertest.ClosableResource
ctx := t.Context()
require.EventuallyWithT(t, func(t *assert.CollectT) {
publicPort, err := freeport.GetFreePort()
require.NoError(t, err)
hydraResource, err = pool.Run(ctx, "oryd/hydra",
// Keep tag in sync with the version in ci.yaml
dockertest.WithTag("v2.2.0"),
dockertest.WithoutReuse(),
dockertest.WithEnv([]string{
"DSN=memory",
fmt.Sprintf("URLS_SELF_ISSUER=http://127.0.0.1:%d/", publicPort),
"URLS_LOGIN=" + loginUI,
"URLS_CONSENT=" + consentUI,
"LOG_LEAK_SENSITIVE_VALUES=true",
"SECRETS_SYSTEM=someverylongsecretthatis32byteslong",
}),
dockertest.WithCmd([]string{"serve", "all", "--dev"}),
dockertest.WithContainerConfig(func(cc *container.Config) {
cc.ExposedPorts = network.PortSet{
network.MustParsePort("4444/tcp"): struct{}{},
network.MustParsePort("4445/tcp"): struct{}{},
}
}),
dockertest.WithHostConfig(func(hc *container.HostConfig) {
hc.PortBindings = network.PortMap{
network.MustParsePort("4444/tcp"): {{HostPort: strconv.Itoa(publicPort)}},
network.MustParsePort("4445/tcp"): {{HostPort: ""}},
}
}),
)
require.NoError(t, err)
}, 2*time.Minute, 100*time.Millisecond)
hydraResource.Cleanup(t)
require.NotEmpty(t, hydraResource.GetPort("4444/tcp"), "%+v", hydraResource.Container().NetworkSettings.Ports)
require.NotEmpty(t, hydraResource.GetPort("4445/tcp"), "%+v", hydraResource.Container)
hydraPublic = "http://127.0.0.1:" + hydraResource.GetPort("4444/tcp")
hydraAdmin = "http://127.0.0.1:" + hydraResource.GetPort("4445/tcp")
go func() {
stdout, stderr, err := hydraResource.Logs(context.Background())
if err == nil {
if stdout != "" {
t.Logf("[hydra stdout]:\n%s\n", stdout)
}
if stderr != "" {
t.Logf("[hydra stderr]:\n%s\n", stderr)
}
}
}()
client := testhelpers.NewTestClient(t)
require.EventuallyWithT(t, func(t *assert.CollectT) {
res, err := client.Get(hydraPublic + "/health/ready")
require.NoError(t, err)
assert.Equal(t, 200, res.StatusCode)
res, err = client.Get(hydraAdmin + "/health/ready")
require.NoError(t, err)
assert.Equal(t, 200, res.StatusCode)
}, 30*time.Second, time.Second)
t.Logf("Ory Hydra running at: %s %s", hydraPublic, hydraAdmin)
return hydraAdmin, hydraPublic
}
type TestLogWriter struct {
streamName string
*testing.T
}
func (t TestLogWriter) Write(p []byte) (int, error) {
t.Logf("[%d bytes @ %s]:\n\n%s\n", len(p), t.streamName, string(p))
return len(p), nil
}
func doOAuthFlow(t *testing.T, ctx context.Context, oauthClient *oauth2.Config, browserClient *http.Client) {
t.Helper()
authCodeURL := makeAuthCodeURL(t, oauthClient, "", false)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authCodeURL, nil)
require.NoError(t, err)
res, err := browserClient.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, "", string(body))
require.Equal(t, http.StatusOK, res.StatusCode)
}

View File

@@ -0,0 +1,850 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"context"
_ "embed"
"fmt"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/urfave/negroni"
"golang.org/x/oauth2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
)
func TestOAuth2Provider(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, true)),
)
var testRequireLogin atomic.Bool
testRequireLogin.Store(true)
router := httprouterx.NewRouterPublic()
kratosPublicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
errTS := testhelpers.NewErrorTestServer(t, reg)
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
router.GET("/login-ts", func(w http.ResponseWriter, r *http.Request) {
t.Log("[loginTS] navigated to the login ui")
c := r.Context().Value(TestUIConfig).(*testConfig)
*c.callTrace = append(*c.callTrace, LoginUI)
q := r.URL.Query()
hlc := r.URL.Query().Get("login_challenge")
if hlc != "" {
*c.callTrace = append(*c.callTrace, LoginWithOAuth2LoginChallenge)
loginUrl, err := url.Parse(c.kratosPublicTS.URL + login.RouteInitBrowserFlow)
require.NoError(t, err)
q := loginUrl.Query()
q.Set("login_challenge", hlc)
loginUrl.RawQuery = q.Encode()
req, err := http.NewRequest("GET", loginUrl.String(), nil)
require.NoError(t, err)
resp, err := c.browserClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, resp.Body.Close())
// if the registration page redirects us to the login page
// we will sign in which means we might have a session
var oryCookie *http.Cookie
currentURL, err := url.Parse(kratosPublicTS.URL)
require.NoError(t, err)
for _, c := range c.browserClient.Jar.Cookies(currentURL) {
if c.Name == config.DefaultSessionCookieName {
oryCookie = c
break
}
}
// in some cases the initialize login flow already navigated to the consent page
// in which case no flow exists, but a session cookie does
if oryCookie != nil && !resp.Request.URL.Query().Has("flow") {
t.Logf("[loginTS] found a session cookie: %s", oryCookie.String())
return
}
flowID := resp.Request.URL.Query().Get("flow")
lf := testhelpers.GetLoginFlow(t, c.browserClient, c.kratosPublicTS, flowID)
require.NotNil(t, lf)
values := url.Values{"method": {"password"}, "identifier": {c.identifier}, "password": {c.password}, "csrf_token": {nosurfx.FakeCSRFToken}}.Encode()
_, res := testhelpers.LoginMakeRequest(t, false, false, lf, c.browserClient, values)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
return
}
if q.Has("flow") {
*c.callTrace = append(*c.callTrace, LoginWithFlowID)
t.Log("[loginTS] login flow is ignored here since it will be handled by the code above, we just need to return")
return
}
})
router.GET("/consent", func(w http.ResponseWriter, r *http.Request) {
t.Log("[consentTS] navigated to the consent ui")
c := r.Context().Value(TestUIConfig).(*testConfig)
*c.callTrace = append(*c.callTrace, Consent)
q := r.URL.Query()
consentChallenge := q.Get("consent_challenge")
assert.NotEmpty(t, consentChallenge)
if consentChallenge != "" {
*c.callTrace = append(*c.callTrace, ConsentWithChallenge)
}
cr, resp, err := c.hydraAdminClient.GetOAuth2ConsentRequest(ctx).ConsentChallenge(q.Get("consent_challenge")).Execute()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.ElementsMatch(t, cr.RequestedScope, c.requestedScope)
if cr.GetSkip() {
*c.callTrace = append(*c.callTrace, ConsentSkip)
}
if cr.Client.GetSkipConsent() {
*c.callTrace = append(*c.callTrace, ConsentClientSkip)
}
completedAcceptRequest, resp, err := c.hydraAdminClient.AcceptOAuth2ConsentRequest(r.Context()).AcceptOAuth2ConsentRequest(hydraclientgo.AcceptOAuth2ConsentRequest{
Remember: &c.consentRemember,
GrantScope: c.requestedScope,
}).ConsentChallenge(q.Get("consent_challenge")).Execute()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, completedAcceptRequest)
*c.callTrace = append(*c.callTrace, ConsentAccept)
t.Logf("[consentTS] navigating to %s", completedAcceptRequest.RedirectTo)
resp, err = c.browserClient.Get(completedAcceptRequest.RedirectTo)
require.NoError(t, err)
require.Equal(t, c.clientAppTS.URL, fmt.Sprintf("%s://%s", resp.Request.URL.Scheme, resp.Request.URL.Host))
})
kratosUIMiddleware := negroni.New()
kratosUIMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// add the context from the global context to each request
next(rw, r.WithContext(ctx))
})
kratosUIMiddleware.UseHandler(router)
kratosUITS := testhelpers.NewHTTPTestServer(t, kratosUIMiddleware)
clientAppTSMiddleware := negroni.New()
clientAppTSMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// add the context from the global context to each request
next(rw, r.WithContext(ctx))
})
clientAppTSMiddleware.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := ctx.Value(TestOAuthClientState).(*clientAppConfig)
kc := ctx.Value(TestUIConfig).(*testConfig)
*kc.callTrace = append(*kc.callTrace, CodeExchange)
c.state.visits += 1
t.Logf("[clientAppTS] handling a callback at client app %s", r.URL.String())
if r.URL.Query().Has("code") {
token, err := c.client.Exchange(r.Context(), r.URL.Query().Get("code"))
require.NoError(t, err)
if token != nil && token.AccessToken != "" {
t.Log("[clientAppTS] successfully exchanged code for token")
*kc.callTrace = append(*kc.callTrace, CodeExchangeWithToken)
c.state.tokens += 1
} else {
t.Log("[clientAppTS] did not receive a token")
}
} else {
t.Error("[clientAppTS] code query parameter is missing")
}
})
// A new OAuth client which will also function as the callback for the code exchange
clientAppTS := testhelpers.NewHTTPTestServer(t, clientAppTSMiddleware)
conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"})
conf.MustSet(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, kratosUITS.URL+"/login-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, redirTS.URL+"/return-ts")
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/login.schema.json")
hydraAdmin, hydraPublic := newHydra(t, kratosUITS.URL+"/login-ts", kratosUITS.URL+"/consent")
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, hydraAdmin)
hydraAdminClient := createHydraOAuth2ApiClient(hydraAdmin)
loginToAccount := func(t *testing.T, browserClient *http.Client, identifier, pwd string) {
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, kratosPublicTS, false, false, false, false)
values := url.Values{"method": {"password"}, "identifier": {identifier}, "password": {pwd}, "csrf_token": {nosurfx.FakeCSRFToken}}.Encode()
body, res := testhelpers.LoginMakeRequest(t, false, false, f, browserClient, values)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body)
}
t.Run("should prompt the user for login and consent", func(t *testing.T) {
// This is the default case, where the user is prompted for login and consent.
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
})
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: true,
})
ct := make([]callTrace, 0)
tc := &testConfig{
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
hydraAdminClient: hydraAdminClient,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
consentRemember: true,
identifier: identifier,
password: pwd,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected := []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct)
})
t.Run("should prompt the user for login and consent again", func(t *testing.T) {
// This test verifies that when Kratos is set
// to SessionPersistentCookie=false, the user is not
// remembered from the previous OAuth2 flow.
// The user must then re-authenticate and re-consent.
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: true,
})
ct := make([]callTrace, 0)
tc := &testConfig{
password: pwd,
identifier: identifier,
hydraAdminClient: hydraAdminClient,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
consentRemember: false,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected := []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct)
// Reset the call trace
ct = []callTrace{}
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 2,
tokens: 2,
}, clientAS)
expected = []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct)
})
t.Run("should prompt the user for consent, but not for login", func(t *testing.T) {
// This test verifies that when Kratos is set
// to SessionPersistentCookie=true, the user is
// remembered from the previous OAuth2 flow.
// The user must then only re-consent.
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: true,
})
ct := make([]callTrace, 0)
tc := &testConfig{
hydraAdminClient: hydraAdminClient,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
consentRemember: false,
password: pwd,
identifier: identifier,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected := []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct)
// reset the call trace
ct = []callTrace{}
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 2,
tokens: 2,
}, clientAS)
expected = []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct)
})
t.Run("should prompt login even with session with OAuth flow", func(t *testing.T) {
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
ct := make([]callTrace, 0)
tc := &testConfig{
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
hydraAdminClient: hydraAdminClient,
identifier: identifier,
password: pwd,
consentRemember: false,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: true,
})
loginToAccount(t, browserClient, identifier, pwd)
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatch(t, []callTrace{
LoginUI,
LoginWithFlowID,
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}, ct)
})
t.Run("first party clients can skip consent", func(t *testing.T) {
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
clientSkipConsent := true
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), clientSkipConsent)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
ct := make([]callTrace, 0)
tc := &testConfig{
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
hydraAdminClient: hydraAdminClient,
identifier: identifier,
password: pwd,
consentRemember: false,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: true,
})
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatch(t, []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentClientSkip,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}, ct)
})
t.Run("oauth flow with consent remember, skips consent", func(t *testing.T) {
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
ct := make([]callTrace, 0)
tc := &testConfig{
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
hydraAdminClient: hydraAdminClient,
identifier: identifier,
password: pwd,
consentRemember: true,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: true,
})
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatch(t, []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}, ct)
// reset the call trace
ct = []callTrace{}
clientAS = clientAppState{
visits: 0,
tokens: 0,
}
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatch(t, []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge,
Consent,
ConsentWithChallenge,
ConsentSkip,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}, ct)
})
t.Run("should fail when Hydra session subject doesn't match the subject authenticated by Kratos", func(t *testing.T) {
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier, pwd := x.NewUUID().String(), "password"
createIdentity(ctx, reg, t, identifier, pwd)
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
ct := make([]callTrace, 0)
tc := &testConfig{
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
callTrace: &ct,
requestedScope: scopes,
hydraAdminClient: hydraAdminClient,
identifier: identifier,
password: pwd,
consentRemember: false,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
state: &clientAS,
expectToken: false,
})
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatch(t, []callTrace{
LoginUI,
LoginWithFlowID,
LoginUI,
LoginWithOAuth2LoginChallenge,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}, ct)
// reset the call trace
ct = []callTrace{}
clientAS = clientAppState{
visits: 0,
tokens: 0,
}
reg.SetHydra(&AcceptWrongSubject{h: reg.Hydra().(*hydra.DefaultHydra)})
doOAuthFlow(t, ctx, oauthClient, browserClient)
// In Hydra v2.2.0-rc.3 this would have failed outright: Hydra returned an error
// when AcceptLoginRequest arrived with a subject that did not match the subject
// stored in the existing login session (skip=true flow).
//
// In Hydra v2.2.0 final the mismatch is handled gracefully instead of with an
// error. When Hydra detects that the accepted subject differs from the session
// subject (consent/handler.go, acceptOAuth2LoginRequest), it redirects the
// browser back to the original authorization URL with prompt=login appended.
// That restart produces a fresh flow with f.Subject="" (no prior session
// context), so the mismatch guard (f.Subject != "" && payload.Subject !=
// f.Subject) does not fire on the second attempt — and AcceptWrongSubject's
// random UUID is accepted, eventually completing the flow.
//
// Security note: this is not exploitable in production. AcceptLoginRequest is a
// trusted admin API reachable only by the login provider (Kratos), which always
// sends the correctly authenticated identity ID. AcceptWrongSubject is a test
// construct only. The change removes a hard early-failure signal for accidental
// subject substitution but does not introduce a new attack surface.
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected := []callTrace{
LoginUI,
LoginWithOAuth2LoginChallenge, // skip=true, wrong subject → prompt=login redirect
LoginUI,
LoginWithOAuth2LoginChallenge, // skip=false, f.Subject="" → guard bypassed, wrong subject accepted
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct)
})
}
var _ hydra.Hydra = &AcceptWrongSubject{}
type AcceptWrongSubject struct {
h *hydra.DefaultHydra
}
func (h *AcceptWrongSubject) AcceptLoginRequest(ctx context.Context, params hydra.AcceptLoginRequestParams) (string, error) {
params.IdentityID = uuid.Must(uuid.NewV4()).String()
return h.h.AcceptLoginRequest(ctx, params)
}
func (h *AcceptWrongSubject) GetLoginRequest(ctx context.Context, loginChallenge string) (*hydraclientgo.OAuth2LoginRequest, error) {
return h.h.GetLoginRequest(ctx, loginChallenge)
}

View File

@@ -0,0 +1,905 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"context"
_ "embed"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/negroni"
"golang.org/x/oauth2"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/x"
"github.com/ory/x/configx"
)
func TestOAuth2ProviderRegistration(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeySelfServiceRegistrationEnableLegacyOneStep, true))
kratosPublicTS, _ := testhelpers.NewKratosServer(t, reg)
errTS := testhelpers.NewErrorTestServer(t, reg)
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
var hydraAdminClient hydraclientgo.OAuth2API
router := http.NewServeMux()
type contextKey string
const (
TestUIConfig contextKey = "test-ui-config"
TestOAuthClientState contextKey = "test-oauth-client-state"
)
router.HandleFunc("GET /login-ts", func(w http.ResponseWriter, r *http.Request) {
t.Log("[loginTS] navigated to the login ui")
c := r.Context().Value(TestUIConfig).(*testConfig)
*c.callTrace = append(*c.callTrace, LoginUI)
q := r.URL.Query()
hlc := r.URL.Query().Get("login_challenge")
if hlc != "" {
*c.callTrace = append(*c.callTrace, LoginWithOAuth2LoginChallenge)
return
}
if q.Has("flow") {
*c.callTrace = append(*c.callTrace, LoginWithFlowID)
lf := testhelpers.GetLoginFlow(t, c.browserClient, c.kratosPublicTS, q.Get("flow"))
require.NotNil(t, lf)
values := testhelpers.SDKFormFieldsToURLValues(lf.Ui.Nodes)
values.Set("password", c.password)
_, _ = testhelpers.LoginMakeRequest(t, false, false, lf, c.browserClient, values.Encode())
t.Log("[loginTS] login flow is ignored here since it will be handled by the code above, we just need to return")
return
}
})
router.HandleFunc("GET /registration-ts", func(w http.ResponseWriter, r *http.Request) {
t.Log("[registrationTS] navigated to the registration ui")
c := r.Context().Value(TestUIConfig).(*testConfig)
*c.callTrace = append(*c.callTrace, RegistrationUI)
q := r.URL.Query()
hlc := q.Get("login_challenge")
if hlc != "" {
*c.callTrace = append(*c.callTrace, RegistrationWithOAuth2LoginChallenge)
t.Log("[registrationTS] initializing a new OpenID Provider flow through the registration endpoint")
registrationUrl, err := url.Parse(c.kratosPublicTS.URL + registration.RouteInitBrowserFlow)
require.NoError(t, err)
q := registrationUrl.Query()
q.Set("login_challenge", hlc)
registrationUrl.RawQuery = q.Encode()
req, err := http.NewRequest("GET", registrationUrl.String(), nil)
require.NoError(t, err)
resp, err := c.browserClient.Do(req)
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
require.NoError(t, resp.Body.Close())
// if the registration page redirects us to the login page
// we will sign in which means we might have a session
var oryCookie *http.Cookie
currentURL, err := url.Parse(kratosPublicTS.URL)
require.NoError(t, err)
for _, c := range c.browserClient.Jar.Cookies(currentURL) {
if c.Name == config.DefaultSessionCookieName {
oryCookie = c
break
}
}
if oryCookie != nil {
t.Log("[registrationTS] we expect to have been at the login screen and got an active flow. This means we have a session now")
return
}
flowID := resp.Request.URL.Query().Get("flow")
assert.NotEmpty(t, flowID)
f := testhelpers.GetRegistrationFlow(t, c.browserClient, c.kratosPublicTS, flowID)
require.NotNil(t, f)
// continue the registration flow here
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("traits.foobar", c.identifier)
values.Set("traits.username", c.identifier)
values.Set("password", c.password)
_, res := testhelpers.RegistrationMakeRequest(t, false, false, f, c.browserClient, values.Encode())
assert.EqualValues(t, http.StatusOK, res.StatusCode)
return
}
if q.Has("flow") {
*c.callTrace = append(*c.callTrace, RegistrationWithFlowID)
t.Log("[registrationTS] registration flow is ignored here since it will be handled by the code above, we just need to return")
return
}
})
router.HandleFunc("GET /consent", func(w http.ResponseWriter, r *http.Request) {
t.Log("[consentTS] navigated to the consent ui")
c := r.Context().Value(TestUIConfig).(*testConfig)
*c.callTrace = append(*c.callTrace, Consent)
q := r.URL.Query()
consentChallenge := q.Get("consent_challenge")
assert.NotEmpty(t, consentChallenge)
if consentChallenge != "" {
*c.callTrace = append(*c.callTrace, ConsentWithChallenge)
}
cr, resp, err := hydraAdminClient.GetOAuth2ConsentRequest(ctx).ConsentChallenge(q.Get("consent_challenge")).Execute()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.ElementsMatch(t, cr.RequestedScope, c.requestedScope)
if cr.GetSkip() {
*c.callTrace = append(*c.callTrace, ConsentSkip)
}
if cr.Client.GetSkipConsent() {
*c.callTrace = append(*c.callTrace, ConsentClientSkip)
}
completedAcceptRequest, resp, err := hydraAdminClient.AcceptOAuth2ConsentRequest(r.Context()).AcceptOAuth2ConsentRequest(hydraclientgo.AcceptOAuth2ConsentRequest{
Remember: &c.consentRemember,
GrantScope: c.requestedScope,
}).ConsentChallenge(q.Get("consent_challenge")).Execute()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, completedAcceptRequest)
*c.callTrace = append(*c.callTrace, ConsentAccept)
t.Logf("[consentTS] navigating to %s", completedAcceptRequest.RedirectTo)
resp, err = c.browserClient.Get(completedAcceptRequest.RedirectTo)
require.NoError(t, err)
require.Equal(t, c.clientAppTS.URL, fmt.Sprintf("%s://%s", resp.Request.URL.Scheme, resp.Request.URL.Host))
})
kratosUIMiddleware := negroni.New()
kratosUIMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// add the context from the global context to each request
next(rw, r.WithContext(ctx))
})
kratosUIMiddleware.UseHandler(router)
kratosUITS := testhelpers.NewHTTPTestServer(t, kratosUIMiddleware)
clientAppTSMiddleware := negroni.New()
clientAppTSMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// add the context from the global context to each request
next(rw, r.WithContext(ctx))
})
clientAppTSMiddleware.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := ctx.Value(TestOAuthClientState).(*clientAppConfig)
kc := ctx.Value(TestUIConfig).(*testConfig)
*kc.callTrace = append(*kc.callTrace, CodeExchange)
c.state.visits += 1
t.Logf("[clientAppTS] handling a callback at client app %s", r.URL.String())
if r.URL.Query().Has("code") {
token, err := c.client.Exchange(r.Context(), r.URL.Query().Get("code"))
require.NoError(t, err)
if token != nil && token.AccessToken != "" {
t.Log("[clientAppTS] successfully exchanged code for token")
*kc.callTrace = append(*kc.callTrace, CodeExchangeWithToken)
c.state.tokens += 1
} else {
t.Log("[clientAppTS] did not receive a token")
}
} else {
t.Error("[clientAppTS] code query parameter is missing")
}
})
// A new OAuth client which will also function as the callback for the code exchange
clientAppTS := testhelpers.NewHTTPTestServer(t, clientAppTSMiddleware)
// we want to test if the registration ui is used if the flow contains an oauth2 login challenge
// so we will have Hydra redirect to the base path of the test kratos ui server which
// will then initiate the registration flow
hydraAdmin, hydraPublic := newHydra(t, kratosUITS.URL+"/registration-ts", kratosUITS.URL+"/consent")
hydraAdminClient = createHydraOAuth2ApiClient(hydraAdmin)
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, hydraAdmin+"/")
conf.MustSet(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, kratosUITS.URL+"/login-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationUI, kratosUITS.URL+"/registration-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, redirTS.URL+"/return-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, redirTS.URL+"/registration-return-ts")
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true})
conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"})
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
doOAuthFlow := func(t *testing.T, ctx context.Context, oauthClient *oauth2.Config, browserClient *http.Client) {
t.Helper()
authCodeURL := makeAuthCodeURL(t, oauthClient, "", false)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authCodeURL, nil)
require.NoError(t, err)
res, err := browserClient.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, "", string(body))
require.Equal(t, http.StatusOK, res.StatusCode)
}
registerNewAccount := func(t *testing.T, browserClient *http.Client, identifier, password string) {
// we need to create a new session directly with kratos
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, kratosPublicTS, false, false, false)
require.NotNil(t, f)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("traits.foobar", identifier)
values.Set("traits.username", identifier)
values.Set("password", password)
_, resp := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, values.Encode())
require.EqualValues(t, http.StatusOK, resp.StatusCode)
var cookie *http.Cookie
currentURL, err := url.Parse(kratosPublicTS.URL)
require.NoError(t, err)
for _, c := range browserClient.Jar.Cookies(currentURL) {
if c.Name == config.DefaultSessionCookieName {
cookie = c
break
}
}
require.NotNil(t, cookie, "expected exactly one session cookie to be set but got none")
}
// important, we will set the persistent cookie to false for most of the tests here
// once this is true, the behavior of the consent flow changes
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
t.Run("case=should accept oauth login request on registration", func(t *testing.T) {
// this test initiates a new OAuth2 flow which goes directly to the registration page
// we then create a new account through the registration flow
// and expect the OAuth2 flow to succeed
scopes := []string{"profile", "email"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauth2Client := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier := x.NewUUID().String()
password := x.NewUUID().String()
ct := make([]callTrace, 0)
ctx = context.WithValue(ctx, TestUIConfig, &testConfig{
identifier: identifier,
password: password,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
hydraAdminClient: hydraAdminClient,
consentRemember: true,
callTrace: &ct,
requestedScope: scopes,
})
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauth2Client,
expectToken: true,
state: &clientAS,
})
doOAuthFlow(t, ctx,
oauth2Client,
browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected := []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
RegistrationUI,
RegistrationWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
})
t.Run("case=registration with session should redirect to login to re-authenticate and to consent", func(t *testing.T) {
// this test registers a new account which sets a session
// we then initiate a new OAuth2 flow which should redirect us to the registration page
// the registration page does a session validation and retrieves the loginRequest from Hydra
// which in this case will indicate we cannot skip the login flow (since the there is no previous OAuth flow associated)
// we then get redirected to the the login page with refresh=true
// we then sign in and expect to be redirected to the consent page
// and then back to the client app
scopes := []string{"profile", "email", "offline_access"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier := x.NewUUID().String()
password := x.NewUUID().String()
ct := make([]callTrace, 0)
tc := &testConfig{
identifier: identifier,
password: password,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
hydraAdminClient: hydraAdminClient,
consentRemember: true,
requestedScope: scopes,
callTrace: &ct,
}
ctx = context.WithValue(ctx, TestUIConfig, tc)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
client: oauthClient,
expectToken: true,
state: &clientAS,
})
registerNewAccount(t, browserClient, identifier, password)
require.ElementsMatch(t, []callTrace{
RegistrationUI,
RegistrationWithFlowID,
}, ct, "expected the call trace to match")
// reset the call trace
ct = []callTrace{}
tc.callTrace = &ct
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected := []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
})
t.Run("case=registration should redirect to login if session exists and skip=false", func(t *testing.T) {
// we dont want to skip the consent page here
// we want the registration page to redirect to the login page
// since we have a session but do not skip the consent page
clientSkipConsent := false
consentRemember := false
scopes := []string{"profile", "email", "offline_access"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), clientSkipConsent)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier := x.NewUUID().String()
password := x.NewUUID().String()
ct := make([]callTrace, 0)
tc := &testConfig{
identifier: identifier,
password: password,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
hydraAdminClient: hydraAdminClient,
consentRemember: consentRemember,
requestedScope: scopes,
callTrace: &ct,
}
clientAppConfig := &clientAppConfig{
client: oauthClient,
expectToken: true,
}
expected := []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
RegistrationUI,
RegistrationWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
// set the global context values
ctx = context.WithValue(ctx, TestUIConfig, tc)
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
doSuccessfulOAuthFlow := func(t *testing.T) {
t.Helper()
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
doOAuthFlow(t, ctx,
oauthClient,
browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
}
doSuccessfulOAuthFlow(t)
// reset our state on the client app
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
// we should now have a session, but not skip the consent page
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected = append(expected,
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
)
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
})
t.Run("case=consent should be skipped if client is configured to skip", func(t *testing.T) {
clientSkipConsent := true
scopes := []string{"profile", "email", "offline_access"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), clientSkipConsent)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier := x.NewUUID().String()
password := x.NewUUID().String()
ct := make([]callTrace, 0)
tc := &testConfig{
identifier: identifier,
password: password,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
hydraAdminClient: hydraAdminClient,
consentRemember: false,
requestedScope: scopes,
callTrace: &ct,
}
clientAppConfig := &clientAppConfig{
client: oauthClient,
expectToken: true,
}
expected := []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
RegistrationUI,
RegistrationWithFlowID,
Consent,
ConsentWithChallenge,
ConsentClientSkip,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
// set the global context values
ctx = context.WithValue(ctx, TestUIConfig, tc)
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
doOAuthFlow(t, ctx,
oauthClient,
browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
})
t.Run("case=consent should be skipped if user has a session and has already consented", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
})
consentRemember := true
scopes := []string{"profile", "email", "offline_access"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
identifier := x.NewUUID().String()
password := x.NewUUID().String()
ct := make([]callTrace, 0)
kratosUIConfig := &testConfig{
identifier: identifier,
password: password,
browserClient: browserClient,
kratosPublicTS: kratosPublicTS,
clientAppTS: clientAppTS,
hydraAdminClient: hydraAdminClient,
consentRemember: consentRemember,
requestedScope: scopes,
callTrace: &ct,
}
clientAppConfig := &clientAppConfig{
client: oauthClient,
expectToken: true,
}
expected := []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
RegistrationUI,
RegistrationWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
// set the global context values
ctx = context.WithValue(ctx, TestUIConfig, kratosUIConfig)
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
doSuccessfulOAuthFlow := func(t *testing.T) {
t.Helper()
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
doOAuthFlow(t, ctx,
oauthClient,
browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
}
doSuccessfulOAuthFlow(t)
// reset our state on the client app
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
// reset the call trace
ct = []callTrace{}
kratosUIConfig.callTrace = &ct
// we should now have a session, but not skip the consent page
doOAuthFlow(t, ctx, oauthClient, browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected = []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
Consent,
ConsentWithChallenge,
ConsentSkip,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
})
t.Run("case=should fail because the persistent Hydra session doesn't match the new Kratos session subject", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
})
// this test re-uses the previous oauthClient
// but creates a new user account through the registration flow
// since the session with the new user does not match the hydra session it should fail
scopes := []string{"profile", "email", "offline_access"}
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
oauthClient := &oauth2.Config{
ClientID: clientID,
ClientSecret: "client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: hydraPublic + "/oauth2/auth",
TokenURL: hydraPublic + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: scopes,
RedirectURL: clientAppTS.URL,
}
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
ct := make([]callTrace, 0)
kratosUIConfig := &testConfig{
identifier: x.NewUUID().String(),
password: x.NewUUID().String(),
kratosPublicTS: kratosPublicTS,
browserClient: browserClient,
clientAppTS: clientAppTS,
hydraAdminClient: hydraAdminClient,
consentRemember: true,
callTrace: &ct,
requestedScope: scopes,
}
ctx = context.WithValue(ctx, TestUIConfig, kratosUIConfig)
clientAppConfig := &clientAppConfig{
client: oauthClient,
expectToken: false,
}
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
expected := []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge,
RegistrationUI,
RegistrationWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
doSuccessfulOAuthFlow := func(t *testing.T) {
t.Helper()
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
doOAuthFlow(t, ctx,
oauthClient,
browserClient)
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
}
doSuccessfulOAuthFlow(t)
clientAS := clientAppState{
visits: 0,
tokens: 0,
}
clientAppConfig.state = &clientAS
currentURL, err := url.Parse(kratosPublicTS.URL)
require.NoError(t, err)
cookies := browserClient.Jar.Cookies(currentURL)
// remove the kratos session so we can register a new account
for _, c := range cookies {
if c.Name == config.DefaultSessionCookieName {
c.MaxAge = -1
c.Value = ""
break
}
}
browserClient.Jar.SetCookies(currentURL, cookies)
kratosUIConfig.identifier = x.NewUUID().String()
kratosUIConfig.password = x.NewUUID().String()
// reset the call trace
ct = []callTrace{}
kratosUIConfig.callTrace = &ct
doOAuthFlow(t, ctx,
oauthClient,
browserClient)
// In Hydra v2.2.0-rc.3 this would have failed outright: Hydra returned an error
// when AcceptLoginRequest arrived with a subject (new user B) that did not match
// the subject in the existing login session (user A, skip=true flow).
//
// In Hydra v2.2.0 final the mismatch is handled with a prompt=login redirect
// instead of an error. The restart produces a fresh flow with f.Subject="" so
// the mismatch guard (f.Subject != "" && payload.Subject != f.Subject) in
// consent/handler.go does not fire on the second attempt, and the new user's
// subject is accepted. The flow completes successfully for user B.
//
// Security note: this is not exploitable in production. AcceptLoginRequest is a
// trusted admin API reachable only by the login provider (Kratos), which always
// sends the correctly authenticated identity ID. The change removes a hard
// early-failure signal for accidental subject substitution but does not
// introduce a new attack surface.
assert.EqualValues(t, clientAppState{
visits: 1,
tokens: 1,
}, clientAS)
expected = []callTrace{
RegistrationUI,
RegistrationWithOAuth2LoginChallenge, // skip=true, subject mismatch → prompt=login redirect
RegistrationUI,
RegistrationWithFlowID,
RegistrationUI,
RegistrationWithOAuth2LoginChallenge, // skip=false, f.Subject="" → guard bypassed, new subject accepted
LoginUI,
LoginWithFlowID,
Consent,
ConsentWithChallenge,
ConsentAccept,
CodeExchange,
CodeExchangeWithToken,
}
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
})
}

View File

@@ -0,0 +1,250 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"context"
"encoding/json"
"net/http"
"net/url"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/otelx"
"github.com/ory/kratos/text"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
)
func nodePasswordInput() *node.Node {
return NewPasswordNode("password", node.InputAttributeAutocompleteNewPassword)
}
func nodeSubmit() *node.Node {
return node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoRegistration())
}
var _ registration.FormHydrator = new(Strategy)
// Update Registration Flow with Password Method
//
// swagger:model updateRegistrationFlowWithPasswordMethod
type UpdateRegistrationFlowWithPasswordMethod struct {
// Password to sign the user up with
//
// required: true
Password string `json:"password"`
// The identity's traits
//
// required: true
Traits json.RawMessage `json:"traits"`
// The CSRF Token
CSRFToken string `json:"csrf_token"`
// Method to use
//
// This field must be set to `password` when using the password method.
//
// required: true
Method string `json:"method"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (s *Strategy) handleRegistrationError(r *http.Request, f *registration.Flow, p UpdateRegistrationFlowWithPasswordMethod, err error) error {
if f != nil {
for _, n := range container.NewFromJSON("", node.DefaultGroup, p.Traits, "traits").Nodes {
// we only set the value and not the whole field because we want to keep types from the initial form generation
f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
}
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
}
return err
}
func (s *Strategy) decode(p *UpdateRegistrationFlowWithPasswordMethod, r *http.Request, ds *url.URL) (err error) {
return registration.DecodeBody(p, r, registrationSchema, ds)
}
func (s *Strategy) Register(_ http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.Register")
defer otelx.End(span, &err)
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil {
return err
}
ds, err := f.IdentitySchema.URL(ctx, s.d.Config())
if err != nil {
return err
}
var p UpdateRegistrationFlowWithPasswordMethod
if err := s.decode(&p, r, ds); err != nil {
return s.handleRegistrationError(r, f, p, err)
}
f.TransientPayload = p.TransientPayload
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return s.handleRegistrationError(r, f, p, err)
}
if len(p.Password) == 0 {
return s.handleRegistrationError(r, f, p, schema.NewRequiredError("#/password", "password"))
}
if len(p.Traits) == 0 {
p.Traits = json.RawMessage("{}")
}
hpw := make(chan []byte)
errC := make(chan error)
go func() {
defer close(hpw)
defer close(errC)
h, err := s.d.Hasher(ctx).Generate(ctx, []byte(p.Password))
if err != nil {
errC <- err
return
}
hpw <- h
}()
if err != nil {
return s.handleRegistrationError(r, f, p, err)
}
i.Traits = identity.Traits(p.Traits)
// We have to set the credential here, so the identity validator can populate the identifiers.
// The password hash is computed in parallel and set later.
if err := i.SetCredentialsWithConfig(s.ID(), identity.Credentials{Type: s.ID(), Identifiers: []string{}}, json.RawMessage("{}")); err != nil {
return s.handleRegistrationError(r, f, p, err)
}
if err := s.validateCredentials(ctx, i, p.Password); err != nil {
return s.handleRegistrationError(r, f, p, err)
}
select {
case err := <-errC:
return s.handleRegistrationError(r, f, p, err)
case h := <-hpw:
co, err := json.Marshal(&identity.CredentialsPassword{HashedPassword: string(h)})
if err != nil {
return s.handleRegistrationError(r, f, p, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode password options to JSON: %s", err)))
}
i.UpsertCredentialsConfig(s.ID(), co, 0)
}
return nil
}
func (s *Strategy) validateCredentials(ctx context.Context, i *identity.Identity, pw string) (err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.validateCredentials")
defer otelx.End(span, &err)
if err := s.d.IdentityValidator().Validate(ctx, i); err != nil {
return err
}
c, ok := i.GetCredentials(identity.CredentialsTypePassword)
if !ok {
// This should never happen
return errors.WithStack(x.PseudoPanic.WithReasonf("identity object did not provide the %s CredentialType unexpectedly", identity.CredentialsTypePassword))
} else if len(c.Identifiers) == 0 {
return schema.NewMissingIdentifierError()
}
for _, id := range c.Identifiers {
if err := s.d.PasswordValidator().Validate(ctx, id, pw); err != nil {
if herodotErr := new(herodot.DefaultError); errors.As(err, &herodotErr) {
return err
}
if message := new(text.Message); errors.As(err, &message) {
return schema.NewPasswordPolicyViolationError("#/password", message)
}
return schema.NewPasswordPolicyViolationError("#/password", text.NewErrorValidationPasswordPolicyViolationGeneric(err.Error()))
}
}
return nil
}
func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) (err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.PopulateRegistrationMethod")
defer otelx.End(span, &err)
ds, err := f.IdentitySchema.URL(ctx, s.d.Config())
if err != nil {
return err
}
// The group used to be `password`, but to make it consistent with other methods and two-step registration,
// it is now `default`. To make this switch backwards compatible, this feature flag is used.
//
// Previously, the behavior would be that the group of NodesFromJSONSchema would be `password`, but as soon
// as any other method (code, passkeys, two-step) would be enabled, the group would be `default`.
//
// Going forward, the default is that the group is `default` and the feature flag is not set.
//
// TODO remove me when everyone has migrated.
if !s.d.Config().SelfServiceFlowRegistrationTwoSteps(r.Context()) && node.UiNodeGroup(s.d.Config().SelfServiceFlowRegistrationPasswordMethodProfileGroup(r.Context())) == node.PasswordGroup {
span.AddEvent(semconv.NewDeprecatedFeatureUsedEvent(ctx, "password_profile_registration_node_group=password"))
// This is the legacy code path. In the new code path, the profile method is responsible for hydrating the form
// nodes. In the old code path, the password method is responsible for hydrating the form nodes if it is
// the only method enabled.
nodes, err := container.NodesFromJSONSchema(r.Context(), node.PasswordGroup, ds.String(), "", nil, s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
if err != nil {
return err
}
for _, n := range nodes {
f.UI.SetNode(n)
}
}
// TODO end
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.Upsert(nodePasswordInput())
f.UI.Nodes.Upsert(nodeSubmit())
return nil
}
func (s *Strategy) PopulateRegistrationMethodCredentials(r *http.Request, f *registration.Flow, options ...registration.FormHydratorModifier) error {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.Upsert(nodePasswordInput())
f.UI.Nodes.Upsert(nodeSubmit())
return nil
}
func (s *Strategy) PopulateRegistrationMethodProfile(r *http.Request, f *registration.Flow, options ...registration.FormHydratorModifier) error {
// The profile method is responsible for rendering the profile form fields.
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.RemoveMatching(nodePasswordInput())
f.UI.Nodes.RemoveMatching(nodeSubmit())
return nil
}

View File

@@ -0,0 +1,985 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"context"
_ "embed"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/registrationhelpers"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/configx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/sqlcon"
)
var flows = []string{"spa", "api", "browser"}
func newRegistrationRegistry(t *testing.T, cfgOpts ...configx.OptionModifier) *driver.RegistryDefault {
_, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
configx.WithValues(map[string]any{
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
config.ViperKeySelfServiceRegistrationLoginHints: true,
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
}),
}, cfgOpts...)...)
return reg
}
func TestRegistration(t *testing.T) {
t.Parallel()
t.Run("case=registration", func(t *testing.T) {
t.Parallel()
reg := newRegistrationRegistry(t, configx.WithValues(map[string]any{
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
config.ViperKeyDefaultIdentitySchemaID: "default",
config.ViperKeyIdentitySchemas: config.Schemas{
{ID: "default", URL: "file://stub/registration.schema.json", SelfserviceSelectable: true},
{ID: "other", URL: "file://stub/registration.schema.json", SelfserviceSelectable: true},
},
}))
conf := reg.Config()
publicTS, _ := testhelpers.NewKratosServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
redirNoSessionTS := testhelpers.NewRedirNoSessionTS(t, reg)
// set the "return to" server, which will assert the session state
// (redirTS: enforce that a session exists, redirNoSessionTS: enforce that no session exists)
useReturnToFromTS := func(ts *httptest.Server) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL+"/default-return-to")
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, ts.URL+"/registration-return-ts")
}
useReturnToFromTS(redirTS)
apiClient := testhelpers.NewDebugClient(t)
t.Run("AssertCommonErrorCases", func(t *testing.T) {
registrationhelpers.AssertCommonErrorCases(t, flows)
})
t.Run("AssertRegistrationRespectsValidation", func(t *testing.T) {
reg := newRegistrationRegistry(t)
registrationhelpers.AssertRegistrationRespectsValidation(t, reg, flows, func(v url.Values) {
v.Del("traits.foobar")
})
})
t.Run("AssertCSRFFailures", func(t *testing.T) {
reg := newRegistrationRegistry(t)
registrationhelpers.AssertCSRFFailures(t, reg, flows, func(v url.Values) {
v.Set("password", x.NewUUID().String())
v.Set("method", identity.CredentialsTypePassword.String())
})
})
t.Run("AssertSchemaDoesNotExist", func(t *testing.T) {
reg := newRegistrationRegistry(t)
registrationhelpers.AssertSchemaDoesNotExist(t, reg, flows, func(v url.Values) {
v.Set("password", x.NewUUID().String())
v.Set("method", identity.CredentialsTypePassword.String())
})
})
expectRegistrationBody := func(t *testing.T, browserRedirTS *httptest.Server, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
if isAPI {
return testhelpers.SubmitRegistrationForm(t, isAPI, hc, publicTS, values,
isSPA, http.StatusOK,
publicTS.URL+registration.RouteSubmitFlow)
}
if hc == nil {
hc = testhelpers.NewClientWithCookies(t)
}
expectReturnTo := browserRedirTS.URL + "/registration-return-ts"
if isSPA {
expectReturnTo = publicTS.URL
}
return testhelpers.SubmitRegistrationForm(t, isAPI, hc, publicTS, values,
isSPA, http.StatusOK, expectReturnTo)
}
expectSuccessfulRegistration := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
useReturnToFromTS(redirTS)
return expectRegistrationBody(t, redirTS, isAPI, isSPA, hc, values)
}
expectNoRegistration := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
useReturnToFromTS(redirNoSessionTS)
t.Cleanup(func() {
useReturnToFromTS(redirTS)
})
return expectRegistrationBody(t, redirNoSessionTS, isAPI, isSPA, hc, values)
}
t.Run("case=should reject invalid transient payload", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
username := x.NewUUID().String()
body := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, f, func(v url.Values) {
v.Set("traits.username", username)
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
v.Set("transient_payload", "42")
})
assert.Equal(t, "bar", gjson.Get(body, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", body)
assert.Equal(t, username, gjson.Get(body, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", body)
assert.Equal(t, int64(4000026), gjson.Get(body, "ui.nodes.#(attributes.name==transient_payload).messages.0.id").Int(), "%s", body)
})
}
})
t.Run("case=should accept valid transient payload", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
setValues := func(username string, v url.Values) {
v.Set("traits.username", username)
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
v.Set("transient_payload.stuff", "42")
}
t.Run("type=api", func(t *testing.T) {
username := x.NewUUID().String()
body := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) {
setValues(username, v)
})
assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session_token").String(), "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
})
t.Run("type=spa", func(t *testing.T) {
username := x.NewUUID().String()
body := expectSuccessfulRegistration(t, false, true, nil, func(v url.Values) {
setValues(username, v)
})
assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
})
t.Run("type=browser", func(t *testing.T) {
username := x.NewUUID().String()
body := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
setValues(username, v)
})
assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body)
})
})
t.Run("case=should pass and set up a session", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
t.Run("type=api", func(t *testing.T) {
body := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-api")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-8-api`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session_token").String(), "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
assert.NotContains(t, gjson.Get(body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", body)
})
t.Run("type=spa", func(t *testing.T) {
body := expectSuccessfulRegistration(t, false, true, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-spa")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-8-spa`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
})
t.Run("type=browser", func(t *testing.T) {
body := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-browser")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-8-browser`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
})
})
t.Run("case=should not set up a session if hook is not configured", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
t.Run("type=api", func(t *testing.T) {
body := expectNoRegistration(t, true, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-api-nosession")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-8-api-nosession`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "session.id").String(), "%s", body)
})
t.Run("type=spa", func(t *testing.T) {
expectNoRegistration(t, false, true, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-spa-nosession")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
})
t.Run("type=browser", func(t *testing.T) {
expectNoRegistration(t, false, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-browser-nosession")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
})
})
t.Run("case=should fail to register the same user again", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
applyTransform := func(values, transform func(v url.Values)) func(v url.Values) {
return func(v url.Values) {
values(v)
transform(v)
}
}
// test duplicate registration on all client types, where the values can be transformed before
// they are sent the second time
testWithTransform := func(t *testing.T, suffix string, transform func(v url.Values)) {
t.Run("type=api", func(t *testing.T) {
values := func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-api-duplicate-"+suffix)
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
}
_ = expectSuccessfulRegistration(t, true, false, apiClient, values)
body := testhelpers.SubmitRegistrationForm(t, true, apiClient, publicTS,
applyTransform(values, transform), false, http.StatusBadRequest,
publicTS.URL+registration.RouteSubmitFlow)
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-api-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body)
})
t.Run("type=spa", func(t *testing.T) {
values := func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-spa-duplicate-"+suffix)
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
}
_ = expectSuccessfulRegistration(t, false, true, nil, values)
body := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, "spa", applyTransform(values, transform))
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-spa-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body)
})
t.Run("type=browser", func(t *testing.T) {
values := func(v url.Values) {
v.Set("traits.username", "registration-identifier-8-browser-duplicate-"+suffix)
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
}
_ = expectSuccessfulRegistration(t, false, false, nil, values)
body := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, "browser", applyTransform(values, transform))
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-browser-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body)
})
}
t.Run("case=identical input", func(t *testing.T) {
testWithTransform(t, "identical", func(v url.Values) {
// base case
})
})
t.Run("case=different capitalization", func(t *testing.T) {
testWithTransform(t, "caps", func(v url.Values) {
v.Set("traits.username", strings.ToUpper(v.Get("traits.username")))
})
})
t.Run("case=leading whitespace", func(t *testing.T) {
testWithTransform(t, "leading", func(v url.Values) {
v.Set("traits.username", " "+v.Get("traits.username"))
})
})
t.Run("case=trailing whitespace", func(t *testing.T) {
testWithTransform(t, "trailing", func(v url.Values) {
v.Set("traits.username", v.Get("traits.username")+" ")
})
})
})
t.Run("case=should return correct error ids from validation failures", func(t *testing.T) {
test := func(t *testing.T, constraint string, setValues func(url.Values), expectedId text.ID, expectedMesage string) {
template := `{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"foobar": {
%s
},
"username": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
},
"required": [
"foobar",
"username"
]
}
},
"additionalProperties": false
}`
testhelpers.SetDefaultIdentitySchemaFromRaw(conf, []byte(fmt.Sprintf(template, constraint)))
browserClient := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, false, false, false)
c := f.Ui
values := testhelpers.SDKFormFieldsToURLValues(c.Nodes)
setValues(values)
values.Set("traits.username", "registration-identifier-9")
values.Set("password", x.NewUUID().String())
actual, _ := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, values.Encode())
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "password", "csrf_token", "traits.username")
assert.EqualValues(t, "registration-identifier-9", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array())
assert.Len(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array(), 0)
assert.Equal(t, int64(expectedId), gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.id").Int())
assert.Equal(t, expectedMesage, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.text").String())
assert.Equal(t, "error", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.type").String())
}
const key = "traits.foobar"
t.Run("case=string violating minLength", func(t *testing.T) {
test(t, `"type": "string", "minLength": 5`, func(v url.Values) { v.Set(key, "bar") }, text.ErrorValidationMinLength, "length must be >= 5, but got 3")
})
t.Run("case=string violating maxLength", func(t *testing.T) {
test(t, `"type": "string", "maxLength": 5`, func(v url.Values) { v.Set(key, "qwerty") }, text.ErrorValidationMaxLength, "length must be <= 5, but got 6")
})
t.Run("case=string violating pattern", func(t *testing.T) {
test(t, `"type": "string", "pattern": "^[a-z]*$"`, func(v url.Values) { v.Set(key, "FUBAR") }, text.ErrorValidationInvalidFormat, "does not match pattern \"^[a-z]*$\"")
})
t.Run("case=number violating minimum", func(t *testing.T) {
test(t, `"type": "number", "minimum": 5`, func(v url.Values) { v.Set(key, "3") }, text.ErrorValidationMinimum, "must be >= 5 but found 3")
})
t.Run("case=number violating exclusiveMinimum", func(t *testing.T) {
test(t, `"type": "number", "exclusiveMinimum": 5`, func(v url.Values) { v.Set(key, "5") }, text.ErrorValidationExclusiveMinimum, "must be > 5 but found 5")
})
t.Run("case=number violating maximum", func(t *testing.T) {
test(t, `"type": "number", "maximum": 5`, func(v url.Values) { v.Set(key, "6") }, text.ErrorValidationMaximum, "must be <= 5 but found 6")
})
t.Run("case=number violating exclusiveMaximum", func(t *testing.T) {
test(t, `"type": "number", "exclusiveMaximum": 5`, func(v url.Values) { v.Set(key, "5") }, text.ErrorValidationExclusiveMaximum, "must be < 5 but found 5")
})
t.Run("case=number violating multipleOf", func(t *testing.T) {
test(t, `"type": "number", "multipleOf": 3`, func(v url.Values) { v.Set(key, "7") }, text.ErrorValidationMultipleOf, "7 not multipleOf 3")
})
t.Run("case=array violating maxItems", func(t *testing.T) {
test(t, `"type": "array", "items": { "type": "string" }, "maxItems": 3`, func(v url.Values) { v.Add(key, "a"); v.Add(key, "b"); v.Add(key, "c"); v.Add(key, "d") }, text.ErrorValidationMaxItems, "maximum 3 items allowed, but found 4 items")
})
t.Run("case=array violating minItems", func(t *testing.T) {
test(t, `"type": "array", "items": { "type": "string" }, "minItems": 3`, func(v url.Values) { v.Add(key, "a"); v.Add(key, "b") }, text.ErrorValidationMinItems, "minimum 3 items allowed, but found 2 items")
})
t.Run("case=array violating uniqueItems", func(t *testing.T) {
test(t, `"type": "array", "items": { "type": "string" }, "uniqueItems": true`, func(v url.Values) { v.Add(key, "abc"); v.Add(key, "XYZ"); v.Add(key, "abc") }, text.ErrorValidationUniqueItems, "items at index 0 and 2 are equal")
})
t.Run("case=wrong type", func(t *testing.T) {
test(t, `"type": "number"`, func(v url.Values) { v.Set(key, "blabla") }, text.ErrorValidationWrongType, "expected number, but got string")
})
})
t.Run("case=should return an error because not passing validation and reset previous errors and values", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
check := func(t *testing.T, actual string) {
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "password", "csrf_token", "traits.username")
}
checkFirst := func(t *testing.T, actual string) {
check(t, actual)
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages.0").String(), `Property username is missing`, "%s", actual)
}
checkSecond := func(t *testing.T, actual string) {
check(t, actual)
assert.EqualValues(t, "registration-identifier-9", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array())
assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array())
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual)
}
valuesFirst := func(v url.Values) url.Values {
v.Del("traits.username")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
return v
}
valuesSecond := func(v url.Values) url.Values {
v.Set("traits.username", "registration-identifier-9")
v.Set("password", x.NewUUID().String())
v.Del("traits.foobar")
return v
}
t.Run("type=api", func(t *testing.T) {
f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, publicTS)
c := f.Ui
actual, _ := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, valuesFirst(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
checkFirst(t, actual)
actual, _ = testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, valuesSecond(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
checkSecond(t, actual)
})
t.Run("type=spa", func(t *testing.T) {
browserClient := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, true, false, false)
c := f.Ui
actual, _ := testhelpers.RegistrationMakeRequest(t, false, true, f, browserClient, testhelpers.EncodeFormAsJSON(t, false, valuesFirst(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
checkFirst(t, actual)
actual, _ = testhelpers.RegistrationMakeRequest(t, false, true, f, browserClient, testhelpers.EncodeFormAsJSON(t, false, valuesSecond(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
checkSecond(t, actual)
})
t.Run("type=browser", func(t *testing.T) {
browserClient := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, false, false, false)
c := f.Ui
actual, _ := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, valuesFirst(testhelpers.SDKFormFieldsToURLValues(c.Nodes)).Encode())
checkFirst(t, actual)
actual, _ = testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, valuesSecond(testhelpers.SDKFormFieldsToURLValues(c.Nodes)).Encode())
checkSecond(t, actual)
})
})
t.Run("case=should work even if password is just numbers", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
t.Run("type=api", func(t *testing.T) {
actual := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-10-api")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-10-api`, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
})
t.Run("type=spa", func(t *testing.T) {
actual := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-10-spa")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-10-spa`, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
})
t.Run("type=browser", func(t *testing.T) {
actual := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
v.Set("traits.username", "registration-identifier-10-browser")
v.Set("password", x.NewUUID().String())
v.Set("traits.foobar", "bar")
})
assert.Equal(t, `registration-identifier-10-browser`, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
})
})
t.Run("case=should fail if no identifier was set in the schema", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/missing-identifier.schema.json")
for _, f := range []string{"spa", "api", "browser"} {
t.Run("type="+f, func(t *testing.T) {
actual := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, f, func(v url.Values) {
v.Set("traits.email", testhelpers.RandomEmail())
v.Set("password", x.NewUUID().String())
v.Set("method", "password")
})
assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
t.Run("case=should work with regular JSON", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/registration.schema.json")
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
hc := testhelpers.NewClientWithCookies(t)
hc.Transport = testhelpers.NewTransportWithLogger(hc.Transport, t)
payload := testhelpers.InitializeRegistrationFlowViaBrowser(t, hc, publicTS, false, false, false)
values := testhelpers.SDKFormFieldsToURLValues(payload.Ui.Nodes)
time.Sleep(time.Millisecond) // add a bit of delay to allow `1ns` to time out.
username := x.NewUUID()
actual, res := testhelpers.RegistrationMakeRequest(t, true, false, payload, hc, fmt.Sprintf(`{
"method": "password",
"csrf_token": "%s",
"password": "%s",
"traits": {
"foobar": "bar",
"username": "%s"
}
}`, values.Get("csrf_token"), x.NewUUID(), username))
assert.EqualValues(t, http.StatusOK, res.StatusCode, assertx.PrettifyJSONPayload(t, actual))
assert.Equal(t, username.String(), gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
})
t.Run("case=should choose the correct identity schema", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "advanced-user")
conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "does-not-exist", URL: "file://./stub/not-exists.schema.json"},
{ID: "advanced-user", URL: "file://./stub/registration.secondary.schema.json"},
})
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
username := "registration-custom-schema"
for _, tc := range []struct {
name string
isAPI bool
isSPA bool
}{
{
name: "api",
isAPI: true,
}, {
name: "spa",
isSPA: true,
}, {
name: "browser",
},
} {
t.Run("type="+tc.name, func(t *testing.T) {
body := expectNoRegistration(t, tc.isAPI, tc.isSPA, nil, func(v url.Values) {
v.Set("traits.username", username+"-"+tc.name)
v.Set("password", x.NewUUID().String())
v.Set("traits.baz", "bar")
})
if tc.isAPI || tc.isSPA { // body is empty for browser flow
assert.Equal(t, username+"-"+tc.name, gjson.Get(body, "identity.traits.username").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
assert.Empty(t, gjson.Get(body, "session.id").String(), "%s", body)
}
})
}
})
t.Run("case=multi-schema select", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "username", URL: "file://stub/sort.schema.json", SelfserviceSelectable: true},
{ID: "email", URL: "file://stub/email.schema.json", SelfserviceSelectable: true},
{ID: "phone", URL: "file://stub/phone.schema.json"},
{ID: "not-allowed", URL: "file://stub/login.schema.json"},
})
// We set default to phone and select email in InitializeRegistrationFlowViaBrowser
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "phone")
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, "https://www.ory.com")
browserClient := testhelpers.NewClientWithCookies(t)
expected := container.Container{
Method: "POST",
Nodes: node.Nodes{
node.NewCSRFNode(nosurfx.FakeCSRFToken),
node.NewInputField("traits.email", nil, node.DefaultGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) {
a.Autocomplete = node.InputAttributeAutocompleteEmail
})).WithMetaLabel(text.NewInfoNodeLabelGenerated("E-Mail", "traits.email")),
node.NewInputField("password", nil, node.PasswordGroup, node.InputAttributeTypePassword, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) {
a.Autocomplete = node.InputAttributeAutocompleteNewPassword
})).WithMetaLabel(text.NewInfoNodeInputPassword()),
node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoRegistration()),
},
}
for _, tc := range []struct {
name string
isAPI bool
isSPA bool
}{
{
name: "api",
isAPI: true,
},
{
name: "spa",
isSPA: true,
},
{
name: "browser",
},
} {
t.Run("type="+tc.name+" registration success", func(t *testing.T) {
var f *kratos.RegistrationFlow
var hc *http.Client
var payload string
if tc.isAPI {
f = testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("email"))
hc = apiClient
} else {
f = testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, tc.isSPA, false, false, testhelpers.InitFlowWithIdentitySchema("email"))
hc = browserClient
}
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
assertx.EqualAsJSON(t, expected, f.Ui)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("traits.email", testhelpers.RandomEmail())
values.Set("password", x.NewUUID().String())
var expectedURL string
if tc.isAPI {
payload = testhelpers.EncodeFormAsJSON(t, true, values)
} else {
payload = values.Encode()
}
if tc.name == "browser" {
expectedURL = redirTS.URL
} else {
expectedURL = conf.SelfPublicURL(t.Context()).String() + login.RouteSubmitFlow
}
actual, resp := testhelpers.RegistrationMakeRequest(t, tc.isAPI, tc.isSPA, f, hc, payload)
require.EqualValues(t, http.StatusOK, resp.StatusCode)
if tc.isAPI {
assert.NotEmpty(t, gjson.Get(actual, "identity.id").String(), "%s", actual)
assert.Equal(t, gjson.Get(actual, "identity.traits.email").String(), values.Get("traits.email"), "%s", actual)
assert.Equal(t, gjson.Get(actual, "identity.schema_id").String(), "email", "%s", actual)
}
identity, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePassword, values.Get("traits.email"))
require.NoError(t, err, sqlcon.ErrNoRows())
assert.NotEmpty(t, identity.ID)
assert.Equalf(t, values.Get("traits.email"), gjson.GetBytes(identity.Traits, "email").String(), "%s", identity.Traits)
assert.Equal(t, "email", identity.SchemaID)
// login
loginValues := func(v url.Values) {
v.Set("identifier", values.Get("traits.email"))
v.Set("password", values.Get("password"))
}
body := testhelpers.SubmitLoginForm(t, tc.isAPI, nil, publicTS, loginValues,
tc.isSPA, false, http.StatusOK, expectedURL, testhelpers.InitFlowWithIdentitySchema("email"))
if tc.name == "browser" {
assert.Equalf(t, identity.ID.String(), gjson.Get(body, "identity.id").String(), "%s", body)
} else {
assert.Equalf(t, identity.ID.String(), gjson.Get(body, "session.identity.id").String(), "%s", body)
}
})
t.Run("type="+tc.name+" registration fail due to invalid form data", func(t *testing.T) {
var f *kratos.RegistrationFlow
var hc *http.Client
var payload string
if tc.isAPI {
f = testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("email"))
hc = apiClient
} else {
f = testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, tc.isSPA, false, false, testhelpers.InitFlowWithIdentitySchema("email"))
hc = browserClient
}
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
assertx.EqualAsJSON(t, expected, f.Ui)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("traits.email", "invalidemail")
values.Set("password", x.NewUUID().String())
if tc.isAPI {
payload = testhelpers.EncodeFormAsJSON(t, true, values)
} else {
payload = values.Encode()
}
actual, resp := testhelpers.RegistrationMakeRequest(t, tc.isAPI, tc.isSPA, f, hc, payload)
if tc.name == "browser" {
require.EqualValues(t, http.StatusOK, resp.StatusCode)
} else {
require.EqualValues(t, http.StatusBadRequest, resp.StatusCode)
}
assert.EqualValuesf(t, 4000040, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.email).messages.0.id").Int(), "%s", actual)
assert.Equalf(t, "Enter a valid email address", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.email).messages.0.text").String(), "%s", actual)
})
}
t.Run("type=browser schema=default", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "phone")
})
// We set default to email
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "email")
f := testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, false)
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
assertx.EqualAsJSON(t, expected, f.Ui)
})
t.Run("type=api schema=default", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "phone")
})
// We set default to email
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "email")
f := testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, apiClient, publicTS)
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
assertx.EqualAsJSON(t, expected, f.Ui)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("traits.email", testhelpers.RandomEmail())
values.Set("password", x.NewUUID().String())
actual, _ := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
assert.NotEmptyf(t, gjson.Get(actual, "identity.id").String(), "%s", actual)
assert.Containsf(t, gjson.Get(actual, "identity.traits.email").String(), values.Get("email"), "%s", actual)
assert.Containsf(t, gjson.Get(actual, "identity.schema_id").String(), "email", "%s", actual)
})
t.Run("type=browser schema=does-not-exist", func(t *testing.T) {
testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, true, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, true, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
})
t.Run("type=api schema=does-not-exist", func(t *testing.T) {
testhelpers.InitializeRegistrationFlowViaAPIExpectError(t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
testhelpers.InitializeLoginFlowViaAPIExpectError(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
})
t.Run("type=browser schema=not-allowed", func(t *testing.T) {
testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, true, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, true, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
})
t.Run("type=api schema=not-allowed", func(t *testing.T) {
testhelpers.InitializeRegistrationFlowViaAPIExpectError(t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
testhelpers.InitializeLoginFlowViaAPIExpectError(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
})
})
})
t.Run("method=PopulateSignUpMethod", func(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(map[string]any{
config.ViperKeyPublicBaseURL: "https://foo/",
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
}),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/sort.schema.json")),
)
publicTS, _ := testhelpers.NewKratosServer(t, reg)
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
browserClient := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, false)
assertx.EqualAsJSON(t, container.Container{
Action: conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id,
Method: "POST",
Nodes: node.Nodes{
node.NewCSRFNode(nosurfx.FakeCSRFToken),
node.NewInputField("traits.username", nil, node.DefaultGroup, node.InputAttributeTypeText),
node.NewInputField("password", nil, node.PasswordGroup, node.InputAttributeTypePassword, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) {
a.Autocomplete = node.InputAttributeAutocompleteNewPassword
})).WithMetaLabel(text.NewInfoNodeInputPassword()),
node.NewInputField("traits.bar", nil, node.DefaultGroup, node.InputAttributeTypeText),
node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoRegistration()),
},
}, f.Ui)
})
}
func TestPopulateRegistrationMethod(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/identity.schema.json")),
)
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypePassword)
require.NoError(t, err)
fh, ok := s.(registration.FormHydrator)
require.True(t, ok)
toSnapshot := func(t *testing.T, f node.Nodes) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.ResetNodes("csrf_token")
snapshotx.SnapshotT(t, f, snapshotx.ExceptNestedKeys("nonce", "src"))
}
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) {
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
r = r.WithContext(ctx)
t.Helper()
f, err := registration.NewFlow(reg, r, flow.TypeBrowser)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
toSnapshot(t, f.UI.Nodes)
})
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
toSnapshot(t, f.UI.Nodes)
})
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
toSnapshot(t, f.UI.Nodes)
})
t.Run("method=idempotency", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
var snapshots []node.Nodes
t.Run("case=1", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=2", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=3", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=4", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=evaluate", func(t *testing.T) {
assertx.EqualAsJSON(t, snapshots[0], snapshots[2])
assertx.EqualAsJSON(t, snapshots[1], snapshots[3])
})
})
}

View File

@@ -0,0 +1,17 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
_ "embed"
)
//go:embed .schema/login.schema.json
var loginSchema []byte
//go:embed .schema/registration.schema.json
var registrationSchema []byte
//go:embed .schema/settings.schema.json
var settingsSchema []byte

View File

@@ -0,0 +1,242 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"context"
"encoding/json"
"net/http"
"time"
"golang.org/x/sync/errgroup"
"github.com/ory/x/otelx"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/session"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/x"
"github.com/ory/x/decoderx"
)
func (s *Strategy) SettingsStrategyID() string {
return identity.CredentialsTypePassword.String()
}
// Update Settings Flow with Password Method
//
// swagger:model updateSettingsFlowWithPasswordMethod
type updateSettingsFlowWithPasswordMethod struct {
// Password is the updated password
//
// required: true
Password string `json:"password"`
// CSRFToken is the anti-CSRF token
CSRFToken string `json:"csrf_token"`
// Method
//
// Should be set to password when trying to update a password.
//
// required: true
Method string `json:"method"`
// Flow is flow ID.
//
// swagger:ignore
Flow string `json:"flow"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (p *updateSettingsFlowWithPasswordMethod) GetFlowID() uuid.UUID {
return x.ParseUUID(p.Flow)
}
func (p *updateSettingsFlowWithPasswordMethod) SetFlowID(rid uuid.UUID) {
p.Flow = rid.String()
}
func (s *Strategy) Settings(ctx context.Context, w http.ResponseWriter, r *http.Request, f *settings.Flow, ss *session.Session) (_ *settings.UpdateContext, err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.Settings")
defer otelx.End(span, &err)
var p updateSettingsFlowWithPasswordMethod
ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, f, ss, settings.ContinuityKey(s.SettingsStrategyID()), &p)
if errors.Is(err, settings.ErrContinuePreviousAction) {
return ctxUpdate, s.continueSettingsFlow(ctx, r, ctxUpdate, p)
} else if err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.SettingsStrategyID(), s.d); err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
if err := s.decodeSettingsFlow(r, &p); err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
// This does not come from the payload!
p.Flow = ctxUpdate.Flow.ID.String()
if err := s.continueSettingsFlow(ctx, r, ctxUpdate, p); err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
return ctxUpdate, nil
}
func (s *Strategy) decodeSettingsFlow(r *http.Request, dest interface{}) error {
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(settingsSchema)
if err != nil {
return errors.WithStack(err)
}
return decoderx.Decode(r, dest, compiler,
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderAllowedMethods("POST", "GET"),
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.HTTPDecoderJSONFollowsFormFormat(),
)
}
// Try to find a password hash in the credentials. Returns it if found, otherwise return an empty string.
func getPasswordHashFromCredential(creds map[identity.CredentialsType]identity.Credentials) string {
if creds == nil {
return ""
}
cred, ok := creds[identity.CredentialsTypePassword]
if !ok {
return ""
}
var hashedPassword identity.CredentialsPassword
if err := json.Unmarshal(cred.Config, &hashedPassword); err != nil {
return ""
}
return hashedPassword.HashedPassword
}
// Detect whether the new password is the same as the old password.
// This is helpful to a user, e.g. in the case of a password leak: they want to change their password,
// and unknowingly set the new password to be the same as the old one (that leaked). We force them to
// set a different password in that case.
func isNewPasswordSameAsOld(ctx context.Context, oldHashedPassword string, newPassword string) bool {
if oldHashedPassword == "" {
return false
}
// `hash.Compare` returns `nil` on 'success' i.e. old and new are the same.
return hash.Compare(ctx, []byte(newPassword), []byte(oldHashedPassword)) == nil
}
func (s *Strategy) continueSettingsFlow(ctx context.Context, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasswordMethod) error {
if err := flow.MethodEnabledAndAllowed(ctx, flow.SettingsFlow, s.SettingsStrategyID(), p.Method, s.d); err != nil {
return err
}
if err := flow.EnsureCSRF(s.d, r, ctxUpdate.Flow.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return err
}
if ctxUpdate.Session.AuthenticatedAt.Add(s.d.Config().SelfServiceFlowSettingsPrivilegedSessionMaxAge(ctx)).Before(time.Now()) {
return errors.WithStack(settings.NewFlowNeedsReAuth())
}
if len(p.Password) == 0 {
return schema.NewRequiredError("#/password", "password")
}
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID)
if err != nil {
return err
}
g, ctx := errgroup.WithContext(ctx)
var newPasswordHash []byte
// Extract an immutable value to avoid data races between goroutines.
oldHashedPassword := getPasswordHashFromCredential(i.Credentials)
// Do in parallel due to limitations of the `bcrypt` library and for performance:
// - `hash(newPassword)` (expensive).
// - Check that the new password is not the same as the old password,
// which internally computes `hash(newPassword)` (expensive).
// - `validateCredentials` which may call the HaveIBeenPawned external API.
g.Go(func() error {
var err error
newPasswordHash, err = s.d.Hasher(ctx).Generate(ctx, []byte(p.Password))
return err
})
g.Go(func() error {
if isNewPasswordSameAsOld(ctx, oldHashedPassword, p.Password) {
return schema.NewPasswordPolicyViolationError("#/password", text.NewErrorValidationPasswordNewSameAsOld())
}
return nil
})
g.Go(func() error {
// Note: this goroutine mutates `i` so careful not to share it with other goroutines!
// The credentials could have been modified in many ways possible. To keep it simple, we reset, and the validators
// will populate it correctly.
i.UpsertCredentialsConfig(s.ID(), []byte("{}"), 0)
return s.validateCredentials(ctx, i, p.Password)
})
if err := g.Wait(); err != nil {
return err
}
co, err := json.Marshal(&identity.CredentialsPassword{HashedPassword: string(newPasswordHash)})
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode password options to JSON: %s", err))
}
i.UpsertCredentialsConfig(s.ID(), co, 0)
ctxUpdate.UpdateIdentity(i)
return nil
}
func (s *Strategy) PopulateSettingsMethod(ctx context.Context, r *http.Request, _ *identity.Identity, f *settings.Flow) (err error) {
_, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.PopulateSettingsMethod")
defer otelx.End(span, &err)
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.Upsert(NewPasswordNode("password", node.InputAttributeAutocompleteNewPassword).WithMetaLabel(text.NewInfoNodeInputPassword()))
f.UI.Nodes.Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelSave()))
return nil
}
func (s *Strategy) handleSettingsError(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasswordMethod, err error) error {
// Do not pause flow if the flow type is an API flow as we can't save cookies in those flows.
if e := new(settings.FlowNeedsReAuth); errors.As(err, &e) && ctxUpdate.Flow != nil && ctxUpdate.Flow.Type == flow.TypeBrowser {
if _, err := s.d.ContinuityManager().Pause(ctx, w, r, settings.ContinuityKey(s.SettingsStrategyID()), continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(ctx)), settings.ContinuityOptions(p, ctxUpdate.GetSessionIdentity())...); err != nil {
return err
}
}
if ctxUpdate.Flow != nil {
ctxUpdate.Flow.UI.ResetMessages()
ctxUpdate.Flow.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
return err
}

View File

@@ -0,0 +1,684 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/configx"
"github.com/ory/client-go"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/settingshelpers"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/httpx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/randx"
)
func init() {
corpx.RegisterFakes()
}
func newIdentityWithPassword(email string) *identity.Identity {
return &identity.Identity{
ID: x.NewUUID(),
NID: x.NewUUID(),
Credentials: map[identity.CredentialsType]identity.Credentials{
"password": {
Type: "password",
Identifiers: []string{email},
Config: []byte(`{"hashed_password":"foo"}`),
},
},
State: identity.StateActive,
Traits: identity.Traits(`{"email":"` + email + `"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
}
}
func newEmptyIdentity() *identity.Identity {
return &identity.Identity{
ID: x.NewUUID(),
NID: x.NewUUID(),
State: identity.StateActive,
Traits: identity.Traits(`{}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
}
}
func newIdentityWithoutCredentials(email string) *identity.Identity {
return &identity.Identity{
ID: x.NewUUID(),
NID: x.NewUUID(),
State: identity.StateActive,
Traits: identity.Traits(`{"email":"` + email + `"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
}
}
func TestSettings(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/profile.schema.json")),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, true)),
configx.WithValues(testhelpers.MethodEnableConfig(settings.StrategyProfile, true)),
configx.WithValue(config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1m"),
)
settingsUI := testhelpers.NewSettingsUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewLoginUIWith401Response(t, conf)
browserIdentity1 := newIdentityWithPassword("john-browser@doe.com")
apiIdentity1 := newIdentityWithPassword("john-api@doe.com")
browserIdentity2 := newEmptyIdentity()
apiIdentity2 := newEmptyIdentity()
publicTS, _ := testhelpers.NewKratosServer(t, reg)
browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, browserIdentity1)
browserUser1.Jar.SetCookies(nosurfx.WithFakeCSRFCookie(t, reg, publicTS.URL))
browserUser2 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, browserIdentity2)
browserUser2.Jar.SetCookies(nosurfx.WithFakeCSRFCookie(t, reg, publicTS.URL))
apiUser1 := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, apiIdentity1)
apiUser2 := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, apiIdentity2)
t.Run("case=should reject a new password if it is the same as the old one", func(t *testing.T) {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
t.Cleanup(func() {
conf.MustSet(context.Background(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
cfg := client.NewConfiguration()
u, err := url.Parse(publicTS.URL)
require.NoError(t, err)
cfg.Scheme = u.Scheme
cfg.Host = u.Host
cl := client.NewAPIClient(cfg)
api := cl.FrontendAPI
t.Run("type=api", func(t *testing.T) {
// Create a new account.
password := uuid.Must(uuid.NewV4()).String()
var sessionToken string
{
registrationFlow, _, err := api.CreateNativeRegistrationFlow(t.Context()).Execute()
require.NoError(t, err)
require.NotNil(t, registrationFlow)
registrationBody := client.UpdateRegistrationFlowBody{
UpdateRegistrationFlowWithPasswordMethod: &client.UpdateRegistrationFlowWithPasswordMethod{
Method: "password",
Password: password,
Traits: map[string]any{
"email": uuid.Must(uuid.NewV4()).String() + "@ory.dev",
},
},
}
registration, _, err := api.UpdateRegistrationFlow(t.Context()).Flow(registrationFlow.Id).UpdateRegistrationFlowBody(registrationBody).Execute()
require.NoError(t, err)
require.NotNil(t, registration)
require.NotNil(t, registration.SessionToken)
sessionToken = *registration.SessionToken
require.NotEmpty(t, sessionToken)
}
// Create a settings flow.
var settingsFlow *client.SettingsFlow
{
var err error
settingsFlow, _, err = api.CreateNativeSettingsFlow(t.Context()).XSessionToken(sessionToken).Execute()
require.NoError(t, err)
require.NotNil(t, settingsFlow)
}
// Try to set the same password: fails.
{
update := client.UpdateSettingsFlowBody{
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
Method: "password",
Password: password,
},
}
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).XSessionToken(sessionToken)
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
require.Error(t, err)
require.Nil(t, settingsFlow)
require.NotNil(t, httpResp)
require.Equal(t, http.StatusBadRequest, httpResp.StatusCode)
}
// Try to set a different password: succeeds.
{
update := client.UpdateSettingsFlowBody{
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
Method: "password",
Password: uuid.Must(uuid.NewV4()).String(),
},
}
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).XSessionToken(sessionToken)
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
require.NoError(t, err)
require.NotNil(t, settingsFlow)
require.NotNil(t, httpResp)
require.Equal(t, http.StatusOK, httpResp.StatusCode)
}
})
t.Run("type=browser", func(t *testing.T) {
// Create a new account.
password := uuid.Must(uuid.NewV4()).String()
var cookie string
{
registrationFlow, _, err := api.CreateBrowserRegistrationFlow(t.Context()).Execute()
require.NoError(t, err)
require.NotNil(t, registrationFlow)
csrfToken := registrationFlow.Ui.Nodes[0].Attributes.UiNodeInputAttributes.Value.(string)
require.NotEmpty(t, csrfToken)
registrationBody := client.UpdateRegistrationFlowBody{
UpdateRegistrationFlowWithPasswordMethod: &client.UpdateRegistrationFlowWithPasswordMethod{
Method: "password",
Password: password,
Traits: map[string]any{
"email": uuid.Must(uuid.NewV4()).String() + "@ory.dev",
},
CsrfToken: &csrfToken,
},
}
registration, httpResp, err := api.UpdateRegistrationFlow(t.Context()).Flow(registrationFlow.Id).UpdateRegistrationFlowBody(registrationBody).Execute()
require.NoError(t, err)
require.NotNil(t, httpResp)
require.NotNil(t, registration)
cookie = httpResp.Header.Get("Set-Cookie")
require.NotEmpty(t, cookie)
}
// Create a settings flow.
var settingsFlow *client.SettingsFlow
var csrfToken string
{
var err error
settingsFlow, _, err = api.CreateBrowserSettingsFlow(t.Context()).Cookie(cookie).Execute()
require.NoError(t, err)
require.NotNil(t, settingsFlow)
csrfToken = settingsFlow.Ui.Nodes[0].Attributes.UiNodeInputAttributes.Value.(string)
require.NotEmpty(t, csrfToken)
}
// Try to set the same password: fails.
{
update := client.UpdateSettingsFlowBody{
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
Method: "password",
Password: password,
CsrfToken: &csrfToken,
},
}
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).Cookie(cookie)
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
require.Error(t, err)
require.Nil(t, settingsFlow)
require.NotNil(t, httpResp)
require.Equal(t, http.StatusBadRequest, httpResp.StatusCode)
}
// Try to set a different password: succeeds.
{
update := client.UpdateSettingsFlowBody{
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
Method: "password",
Password: uuid.Must(uuid.NewV4()).String(),
CsrfToken: &csrfToken,
},
}
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).Cookie(cookie)
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
require.NoError(t, err)
require.NotNil(t, settingsFlow)
require.NotNil(t, httpResp)
require.Equal(t, http.StatusOK, httpResp.StatusCode)
}
})
})
t.Run("description=not authorized to call endpoints without a session", func(t *testing.T) {
c := testhelpers.NewDebugClient(t)
t.Run("type=browser", func(t *testing.T) {
res, err := c.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(url.Values{"foo": {"bar"}}.Encode()), "application/x-www-form-urlencoded"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%+v", res.Request)
assert.Contains(t, res.Request.URL.String(), conf.GetProvider(t.Context()).String(config.ViperKeySelfServiceLoginUI))
})
t.Run("type=spa", func(t *testing.T) {
res, err := c.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(url.Values{"foo": {"bar"}}.Encode()), "application/json"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%+v", res.Request)
assert.Contains(t, res.Request.URL.String(), settings.RouteSubmitFlow)
})
t.Run("type=api", func(t *testing.T) {
res, err := c.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(`{"foo":"bar"}`), "application/json"))
require.NoError(t, err)
assert.Len(t, res.Cookies(), 0)
defer func() { _ = res.Body.Close() }()
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode)
})
})
expectValidationError := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
return testhelpers.SubmitSettingsForm(t, isAPI, isSPA, hc, publicTS, values,
testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK),
testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+settings.RouteSubmitFlow, conf.SelfServiceFlowSettingsUI(t.Context()).String()))
}
t.Run("description=should fail if password violates policy", func(t *testing.T) {
check := func(t *testing.T, reason, actual string) {
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==password).attributes.value").String(), "%s", actual)
assert.NotEmpty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", actual)
assert.Equal(t, reason, gjson.Get(actual, "ui.nodes.#(attributes.name==password).messages.0.text").String(), "%s", actual)
}
t.Run("session=with privileged session", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
payload := func(v url.Values) {
v.Set("password", "123456")
v.Set("method", "password")
}
t.Run("type=api", func(t *testing.T) {
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, true, false, apiUser1, payload))
})
t.Run("spa=spa", func(t *testing.T) {
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, false, true, browserUser1, payload))
})
t.Run("type=browser", func(t *testing.T) {
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, false, false, browserUser1, payload))
})
})
t.Run("session=needs reauthentication", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
defer testhelpers.NewLoginUIWith401Response(t, conf)
t.Cleanup(func() {
conf.MustSet(context.Background(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
})
payload := func(v url.Values) {
v.Set("method", "password")
v.Set("password", "123456")
}
t.Run("type=api/expected=an error because reauth can not be initialized for API clients", func(t *testing.T) {
_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, testhelpers.NewSDKCustomClient(publicTS, apiUser1), conf)
actual := testhelpers.SubmitSettingsForm(t, true, false, apiUser1, publicTS, payload,
http.StatusForbidden, publicTS.URL+settings.RouteSubmitFlow)
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
assert.NotEmpty(t, json.RawMessage(gjson.Get(actual, "redirect_browser_to").String()))
})
t.Run("type=spa", func(t *testing.T) {
_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, testhelpers.NewSDKCustomClient(publicTS, browserUser1), conf)
actual := testhelpers.SubmitSettingsForm(t, false, true, browserUser1, publicTS, payload,
http.StatusForbidden, publicTS.URL+settings.RouteSubmitFlow)
assertx.EqualAsJSON(t, settings.NewFlowNeedsReAuth().DefaultError, json.RawMessage(gjson.Get(actual, "error").Raw))
assert.NotEmpty(t, json.RawMessage(gjson.Get(actual, "redirect_browser_to").String()))
})
t.Run("type=browser", func(t *testing.T) {
_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, testhelpers.NewSDKCustomClient(publicTS, browserUser1), conf)
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, false, false, browserUser1, payload))
})
})
})
t.Run("description=should not be able to make requests for another user", func(t *testing.T) {
t.Run("type=api", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiUser1, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", x.NewUUID().String())
actual, res := testhelpers.SettingsMakeRequest(t, true, false, f, apiUser2, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "error.reason").String(), "initiated by someone else", "%s", actual)
})
t.Run("type=spa", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, true, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", x.NewUUID().String())
actual, res := testhelpers.SettingsMakeRequest(t, false, true, f, browserUser2, values.Encode())
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "error.reason").String(), "initiated by someone else", "%s", actual)
})
t.Run("type=browser", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, false, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", x.NewUUID().String())
actual, res := testhelpers.SettingsMakeRequest(t, false, false, f, browserUser2, values.Encode())
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "reason").String(), "initiated by someone else", "%s", actual)
})
})
t.Run("description=should update the password and clear errors if everything is ok", func(t *testing.T) {
check := func(t *testing.T, actual string) {
assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==password).value").String(), "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==password).messages.0.text").String(), actual)
}
payload := func(v url.Values) {
v.Set("method", "password")
v.Set("password", x.NewUUID().String())
}
t.Run("type=api", func(t *testing.T) {
actual := testhelpers.SubmitSettingsForm(t, true, false, apiUser1, publicTS, payload, http.StatusOK, publicTS.URL+settings.RouteSubmitFlow)
check(t, actual)
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
})
t.Run("type=spa", func(t *testing.T) {
actual := testhelpers.SubmitSettingsForm(t, false, true, browserUser1, publicTS, payload, http.StatusOK, publicTS.URL+settings.RouteSubmitFlow)
check(t, actual)
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), settingsUI.URL, "%s", actual)
})
t.Run("type=browser", func(t *testing.T) {
actual := testhelpers.SubmitSettingsForm(t, false, false, browserUser1, publicTS, payload, http.StatusOK, conf.SelfServiceFlowSettingsUI(t.Context()).String())
check(t, actual)
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
})
})
t.Run("case=should fail because of missing CSRF token/type=browser", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, false, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", x.NewUUID().String())
values.Set("csrf_token", "invalid_token")
actual, res := testhelpers.SettingsMakeRequest(t, false, false, f, browserUser1, values.Encode())
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.GetProvider(t.Context()).String(config.ViperKeySelfServiceErrorUI))
assertx.EqualAsJSON(t, nosurfx.ErrInvalidCSRFTokenServerTokenMismatch(), json.RawMessage(actual), "%s", actual)
})
t.Run("case=should pass even without CSRF token/type=spa", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, true, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", x.NewUUID().String())
values.Set("csrf_token", "invalid_token")
actual, res := testhelpers.SettingsMakeRequest(t, false, true, f, browserUser1, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assertx.EqualAsJSON(t, nosurfx.ErrInvalidCSRFTokenAJAXTokenMismatch(), json.RawMessage(gjson.Get(actual, "error").Raw), "%s", actual)
})
t.Run("case=should pass even without CSRF token/type=api", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiUser1, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", x.NewUUID().String())
values.Set("csrf_token", "invalid_token")
actual, res := testhelpers.SettingsMakeRequest(t, true, false, f, apiUser1, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.NotEmpty(t, gjson.Get(actual, "identity.id").String(), "%s", actual)
})
t.Run("case=should fail with correct CSRF error cause/type=api", func(t *testing.T) {
for k, tc := range []struct {
mod func(http.Header)
exp string
}{
{
mod: func(h http.Header) {
h.Add("Cookie", "name=bar")
},
exp: "The HTTP Request Header included the \\\"Cookie\\\" key",
},
{
mod: func(h http.Header) {
h.Add("Origin", "www.bar.com")
},
exp: "The HTTP Request Header included the \\\"Origin\\\" key",
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiUser1, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("password", x.NewUUID().String())
req := testhelpers.NewPostRequest(t, true, f.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values)))
tc.mod(req.Header)
res, err := apiUser1.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
actual := string(ioutilx.MustReadAll(res.Body))
assert.EqualValues(t, http.StatusBadRequest, res.StatusCode)
assert.Contains(t, actual, tc.exp)
})
}
})
expectSuccess := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
return testhelpers.SubmitSettingsForm(t, isAPI, isSPA, hc, publicTS, values, http.StatusOK,
testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+settings.RouteSubmitFlow, conf.SelfServiceFlowSettingsUI(t.Context()).String()))
}
t.Run("description=should update the password even if no password was set before", func(t *testing.T) {
bi := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
si := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
ai := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
browserUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, bi)
spaUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, si)
apiUser := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, ai)
check := func(t *testing.T, actual string, id *identity.Identity) {
assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(name==password).attributes.value").String(), "%s", actual)
actualIdentity, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), id.ID)
require.NoError(t, err)
cfg := string(actualIdentity.Credentials[identity.CredentialsTypePassword].Config)
assert.Contains(t, cfg, "hashed_password", "%+v", actualIdentity.Credentials)
require.Len(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers, 1)
assert.Contains(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers[0], "-4")
}
payload := func(v url.Values) {
v.Set("method", "password")
v.Set("password", randx.MustString(16, randx.AlphaNum))
}
t.Run("type=api", func(t *testing.T) {
actual := expectSuccess(t, true, false, apiUser, payload)
check(t, actual, ai)
})
t.Run("type=spa", func(t *testing.T) {
actual := expectSuccess(t, false, true, spaUser, payload)
check(t, actual, si)
})
t.Run("type=browser", func(t *testing.T) {
actual := expectSuccess(t, false, false, browserUser, payload)
check(t, actual, bi)
})
})
t.Run("description=should update the password and perform the correct redirection", func(t *testing.T) {
rts := testhelpers.NewRedirTS(t, "", conf)
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsAfter+"."+config.DefaultBrowserReturnURL, rts.URL+"/return-ts")
t.Cleanup(func() {
conf.MustSet(context.Background(), config.ViperKeySelfServiceSettingsAfter, nil)
})
run := func(t *testing.T, f *kratos.SettingsFlow, isAPI bool, c *http.Client, _ *identity.Identity) {
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "password")
values.Set("password", randx.MustString(16, randx.AlphaNum))
_, res := testhelpers.SettingsMakeRequest(t, isAPI, false, f, c, testhelpers.EncodeFormAsJSON(t, isAPI, values))
require.EqualValues(t, rts.URL+"/return-ts", res.Request.URL.String())
actualIdentity, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), browserIdentity1.ID)
require.NoError(t, err)
cfg := string(actualIdentity.Credentials[identity.CredentialsTypePassword].Config)
assert.NotContains(t, cfg, "foo")
assert.NotEqual(t, `{"hashed_password":"foo"}`, cfg)
}
t.Run("type=api", func(t *testing.T) {
t.Skip("Post-registration redirects do not work for API flows and are thus not tested here.")
})
t.Run("type=spa", func(t *testing.T) {
t.Skip("Post-registration redirects do not work for API flows and are thus not tested here.")
})
t.Run("type=browser", func(t *testing.T) {
rs := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, false, publicTS)
run(t, rs, false, browserUser1, browserIdentity1)
})
})
t.Run("description=should update the password and revoke other user sessions", func(t *testing.T) {
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, "password"), []config.SelfServiceHook{{Name: "revoke_active_sessions"}})
t.Cleanup(func() {
conf.MustSet(context.Background(), config.ViperKeySelfServiceSettingsAfter, nil)
})
check := func(t *testing.T, actual string, id *identity.Identity) {
assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(name==password).attributes.value").String(), "%s", actual)
actualIdentity, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), id.ID)
require.NoError(t, err)
cfg := string(actualIdentity.Credentials[identity.CredentialsTypePassword].Config)
assert.Contains(t, cfg, "hashed_password", "%+v", actualIdentity.Credentials)
require.Len(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers, 1)
assert.Contains(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers[0], "-4")
}
initClients := func(isAPI bool, id *identity.Identity) (client1, client2 *http.Client) {
if isAPI {
client1 = testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
client2 = testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
return client1, client2
}
client1 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
client2 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
return client1, client2
}
run := func(t *testing.T, isAPI, isSPA bool, id *identity.Identity) {
payload := func(v url.Values) {
v.Set("method", "password")
v.Set("password", randx.MustString(16, randx.AlphaNum))
}
user1, user2 := initClients(isAPI, id)
actual := expectSuccess(t, isAPI, isSPA, user1, payload)
check(t, actual, id)
// second client should be logged out
res, err := user2.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(url.Values{"foo": {"bar"}}.Encode()), "application/json"))
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%+v", res.Request)
// again change password via first client
actual = expectSuccess(t, isAPI, isSPA, user1, payload)
check(t, actual, id)
}
t.Run("type=api", func(t *testing.T) {
id := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
run(t, true, false, id)
})
t.Run("type=spa", func(t *testing.T) {
id := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
run(t, false, true, id)
})
t.Run("type=browser", func(t *testing.T) {
id := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
run(t, false, false, id)
})
})
t.Run("case=should fail if no identifier was set in the schema", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/missing-identifier.schema.json")
id := newIdentityWithoutCredentials(testhelpers.RandomEmail())
browser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
api := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
for _, f := range []string{"spa", "api", "browser"} {
t.Run("type="+f, func(t *testing.T) {
hc := browser
if f == "api" {
hc = api
}
actual := settingshelpers.ExpectValidationError(t, publicTS, hc, conf, f, func(v url.Values) {
v.Set("password", x.NewUUID().String())
v.Set("method", "password")
})
assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
}

View File

@@ -0,0 +1,120 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"context"
"encoding/json"
"strings"
"github.com/ory/kratos/x/nosurfx"
"github.com/pkg/errors"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/httpx"
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
)
var (
_ login.Strategy = (*Strategy)(nil)
_ registration.Strategy = (*Strategy)(nil)
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
)
type dependencies interface {
logrusx.Provider
httpx.WriterProvider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
httpx.ClientProvider
otelx.Provider
jsonnetsecure.VMProvider
config.Provider
continuity.ManagementProvider
x.CookieProvider
errorx.ManagementProvider
ValidationProvider
hash.HashProvider
registration.HandlerProvider
registration.HooksProvider
registration.ErrorHandlerProvider
registration.HookExecutorProvider
registration.FlowPersistenceProvider
login.HooksProvider
login.ErrorHandlerProvider
login.HookExecutorProvider
login.FlowPersistenceProvider
login.HandlerProvider
settings.FlowPersistenceProvider
settings.HookExecutorProvider
settings.HooksProvider
settings.ErrorHandlerProvider
identity.PrivilegedPoolProvider
identity.ValidationProvider
identity.ManagementProvider
session.HandlerProvider
session.ManagementProvider
}
type Strategy struct{ d dependencies }
func NewStrategy(d dependencies) *Strategy { return &Strategy{d: d} }
func (s *Strategy) CountActiveFirstFactorCredentials(ctx context.Context, cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
for _, c := range cc {
if c.Type == s.ID() && len(c.Config) > 0 {
var conf identity.CredentialsPassword
if err = json.Unmarshal(c.Config, &conf); err != nil {
return 0, errors.WithStack(err)
}
if len(strings.Join(c.Identifiers, "")) > 0 &&
((s.d.Config().PasswordMigrationHook(ctx).Enabled && conf.UsePasswordMigrationHook) ||
len(conf.HashedPassword) > 0) {
count++
}
}
}
return
}
func (s *Strategy) CountActiveMultiFactorCredentials(_ context.Context, _ map[identity.CredentialsType]identity.Credentials) (count int, err error) {
return 0, nil
}
func (s *Strategy) ID() identity.CredentialsType {
return identity.CredentialsTypePassword
}
func (s *Strategy) CompletedAuthenticationMethod(_ context.Context) session.AuthenticationMethod {
return session.AuthenticationMethod{
Method: s.ID(),
AAL: identity.AuthenticatorAssuranceLevel1,
}
}
func (s *Strategy) NodeGroup() node.UiNodeGroup {
return node.PasswordGroup
}

View File

@@ -0,0 +1,82 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"context"
"io"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/x/configx"
)
func TestDisabledEndpoint(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/sort.schema.json")),
)
publicTS, _ := testhelpers.NewKratosServer(t, reg)
c := testhelpers.NewClientWithCookies(t)
t.Run("case=should not login when password method is disabled", func(t *testing.T) {
f := testhelpers.InitializeLoginFlowViaAPICtx(context.Background(), t, c, publicTS, false)
res, err := c.PostForm(f.Ui.Action, url.Values{"method": {"password"}, "password_identifier": []string{"identifier"}, "password": []string{"password"}})
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator", "%s", b)
})
t.Run("case=should not registration when password method is disabled", func(t *testing.T) {
f := testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, c, publicTS)
res, err := c.PostForm(f.Ui.Action, url.Values{"method": {"password"}, "password_identifier": []string{"identifier"}, "password": []string{"password"}})
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator", "%s", b)
})
t.Run("case=should not settings when password method is disabled", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/login.schema.json")
c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(context.Background(), t, reg)
t.Run("method=GET", func(t *testing.T) {
t.Skip("GET is currently not supported for this endpoint.")
})
t.Run("method=POST", func(t *testing.T) {
f := testhelpers.InitializeSettingsFlowViaAPI(t, c, publicTS)
res, err := c.PostForm(f.Ui.Action, url.Values{
"method": {"password"},
"password": {"bar"},
})
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusNotFound, res.StatusCode)
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator", "%s", b)
})
})
}

View File

@@ -0,0 +1,168 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"cmp"
"context"
"encoding/json"
"fmt"
"testing"
"github.com/go-faker/faker/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/password"
"github.com/ory/x/contextx"
)
func generateRandomConfig(t *testing.T) (identity.CredentialsPassword, []byte) {
t.Helper()
var cred identity.CredentialsPassword
require.NoError(t, faker.FakeData(&cred))
c, err := json.Marshal(cred)
require.NoError(t, err)
return cred, c
}
func TestCountActiveFirstFactorCredentials(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
strategy := password.NewStrategy(reg)
h1, err := hash.NewHasherBcrypt(reg).Generate(t.Context(), []byte("a password"))
require.NoError(t, err)
h2, err := reg.Hasher(t.Context()).Generate(t.Context(), []byte("a password"))
require.NoError(t, err)
t.Run("test regressions fixtures", func(t *testing.T) {
// This test ensures we do not add regressions to this method by, for example, adding a new field.
for k := range 100 {
t.Run(fmt.Sprintf("run=%d", k), func(t *testing.T) {
cred, c := generateRandomConfig(t)
actual, err := strategy.CountActiveFirstFactorCredentials(t.Context(), map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: c,
}})
assert.NoError(t, err)
if len(cred.HashedPassword) == 0 && cred.UsePasswordMigrationHook {
// This case is OK
assert.Equal(t, 0, actual)
return
}
assert.Equal(t, 1, actual)
})
}
})
t.Run("with fixtures", func(t *testing.T) {
for k, tc := range []struct {
in map[identity.CredentialsType]identity.Credentials
expected int
ctx context.Context
}{
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{"hashed_password": "` + string(h1) + `"}`),
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{""},
Config: []byte(`{"hashed_password": "` + string(h1) + `"}`),
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: []byte(`{"hashed_password": "` + string(h1) + `"}`),
}},
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: []byte(`{"hashed_password": "` + string(h2) + `"}`),
}},
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: []byte(`{"use_password_migration_hook":true}`),
}},
expected: 1,
ctx: contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordMigrationHook+".enabled", true),
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: []byte(`{"use_password_migration_hook":true}`),
}},
expected: 0,
ctx: contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordMigrationHook+".enabled", false),
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: []byte(`{"use_password_migration_hook":false}`),
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{"hashed_password": "asdf"}`),
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{}`),
}},
expected: 0,
},
{
in: nil,
expected: 0,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
actual, err := strategy.CountActiveFirstFactorCredentials(cmp.Or(tc.ctx, t.Context()), tc.in)
assert.NoError(t, err)
assert.Equal(t, tc.expected, actual)
actual, err = strategy.CountActiveMultiFactorCredentials(cmp.Or(tc.ctx, t.Context()), tc.in)
assert.NoError(t, err)
assert.Equal(t, 0, actual)
})
}
})
}

View File

@@ -0,0 +1,50 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"totp": {
"account_name": true
},
"code": {
"identifier": true,
"via": "email"
},
"passkey": {
"display_name": true
}
},
"recovery": {
"via": "email"
},
"verification": {
"via": "email"
},
"organizations": {
"matcher": "email_domain"
}
},
"maxLength": 320
}
},
"required": ["email"],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,11 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
}
},
"required": [
"email"
]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,21 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
}
},
"required": [
"email"
]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,44 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/identity.sms.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phone_number": {
"type": "string",
"format": "tel",
"title": "Phone Number",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"totp": {
"account_name": true
},
"code": {
"identifier": true,
"via": "sms"
},
"passkey": {
"display_name": true
}
},
"verification": {
"via": "sms"
}
},
"maxLength": 320
}
},
"required": ["phone_number"],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,25 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"foobar": {
"type": "string",
"minLength": 2
},
"username": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
},
"required": ["foobar", "username"]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,32 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"baz": {
"type": "string",
"minLength": 2
},
"username": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
},
"required": [
"baz",
"username"
]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,26 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"username": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
},
"bar": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import "encoding/json"
// Update Login Flow with Password Method
//
// swagger:model updateLoginFlowWithPasswordMethod
type updateLoginFlowWithPasswordMethod struct {
// Method should be set to "password" when logging in using the identifier and password strategy.
//
// required: true
Method string `json:"method"`
// Sending the anti-csrf token is only required for browser login flows.
CSRFToken string `json:"csrf_token"`
// The user's password.
//
// required: true
Password string `json:"password"`
// Identifier is the email or username of the user trying to log in.
// This field is deprecated!
LegacyIdentifier string `json:"password_identifier"`
// Identifier is the email or username of the user trying to log in.
//
// required: true
Identifier string `json:"identifier"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}

View 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
}

View File

@@ -0,0 +1,31 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func TestLCSLength(t *testing.T) {
for k, tc := range []struct {
a string
b string
l int
}{
{a: "foo", b: "foo", l: 3},
{a: "fo", b: "foo", l: 2},
{a: "bar", b: "foo", l: 0},
{a: "foobar", b: "foo", l: 3},
{a: "foobar", b: "oo", l: 2},
{a: "foobar", b: "a", l: 1},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
require.Equal(t, tc.l, lcsLength(tc.a, tc.b))
require.Equal(t, tc.l, lcsLength(tc.b, tc.a))
})
}
}

View File

@@ -0,0 +1,469 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1" //#nosec G505 -- compatibility for imported passwords
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/password"
"github.com/ory/kratos/text"
"github.com/ory/x/configx"
"github.com/ory/x/contextx"
"github.com/ory/x/httpx"
)
// testRegistry embeds RegistryDefault and overrides HTTPClient to inject a
// controllable HTTP client for HIBP-related tests.
type testRegistry struct {
*driver.RegistryDefault
cl *retryablehttp.Client
}
func (r *testRegistry) HTTPClient(_ context.Context, _ ...httpx.ResilientOptions) *retryablehttp.Client {
return r.cl
}
// newTestValidator wires base with a fake HIBP HTTP client and returns the
// validator along with the fake client for request assertions and stubbing.
func newTestValidator(t *testing.T, base *driver.RegistryDefault, opts ...httpx.ResilientOptions) (*password.DefaultPasswordValidator, *fakeHttpClient) {
t.Helper()
fakeClient := NewFakeHTTPClient()
cl := httpx.NewResilientClient(append([]httpx.ResilientOptions{
httpx.ResilientClientWithMaxRetry(1),
httpx.ResilientClientWithConnectionTimeout(time.Millisecond),
}, opts...)...)
cl.HTTPClient = &fakeClient.Client
s, err := password.NewDefaultPasswordValidatorStrategy(&testRegistry{RegistryDefault: base, cl: cl})
require.NoError(t, err)
return s, fakeClient
}
func TestDefaultPasswordValidationStrategy(t *testing.T) {
t.Parallel()
// Tests are based on:
// - 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
t.Run("default strategy", func(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
for k, tc := range []struct {
id, pw string
expectedErr error
}{
{pw: "", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "12", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "1234", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "123456", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "12345678", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "password", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "1234567890", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "qwertyui", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "l3f9to", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "l3f9toh1uaf81n21"},
{pw: "l3f9toh1uaf81n21", id: "l3f9toh1uaf81n21", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{pw: "l3f9toh1"},
{pw: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},
// simple permutation tests
{id: "hello@example.com", pw: "hello@example.com12345", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "123hello@example.com123", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "HELLO@EXAMPLE.COM", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "HELLO@example.com", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "h3ll0@example", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "abcd", pw: "9d3c8a1b"},
{id: "a", pw: "kjOklafe"},
{id: "ab", pw: "0000ab0000123"},
// longest common substring with long password
{id: "d4f6090b-5a84", pw: "d4f6090b-5a84-2184-4404-8d1b-8da3eb00ebbe"},
{id: "asdflasdflasdf", pw: "asdflasdflpiuhefnciluaksdzuföfhg"},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
t.Parallel()
err := s.Validate(t.Context(), tc.id, tc.pw)
if tc.expectedErr == nil {
require.NoErrorf(t, err, "id: %s, pw: %s", tc.id, tc.pw)
} else {
require.ErrorIsf(t, err, tc.expectedErr, "id: %s, pw: %s", tc.id, tc.pw)
}
})
}
})
t.Run("failure cases", func(t *testing.T) {
t.Parallel()
_, base := pkg.NewFastRegistryWithMocks(t)
s, fakeClient := newTestValidator(t, base, httpx.ResilientClientWithMaxRetryWait(time.Millisecond))
t.Run("case=should send request to pwnedpasswords.com", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
require.Contains(t, fakeClient.RequestedURLs(), "https://api.pwnedpasswords.com/range/BCBA9")
})
t.Run("case=should fail if request fails and ignoreNetworkErrors is not set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
fakeClient.RespondWithError("Network request failed")
require.Error(t, s.Validate(ctx, "", "sumdarmetp"))
})
t.Run("case=should not fail if request fails and ignoreNetworkErrors is set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
fakeClient.RespondWithError("Network request failed")
require.NoError(t, s.Validate(ctx, "", "pepegtawni"))
})
t.Run("case=should fail if response has non 200 code and ignoreNetworkErrors is not set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
fakeClient.RespondWith(http.StatusForbidden, "")
require.Error(t, s.Validate(ctx, "", "jolhakowef"))
})
t.Run("case=should not fail if response has non 200 code code and ignoreNetworkErrors is set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
fakeClient.RespondWith(http.StatusInternalServerError, "")
require.NoError(t, s.Validate(ctx, "", "jenuzuhjoj"))
})
t.Run("case=should fail with a network error if reading the response body fails and ignoreNetworkErrors is not set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
require.ErrorIs(t, s.Validate(ctx, "", "vebjihwoct"), password.ErrNetworkFailure)
})
t.Run("case=should not fail if reading the response body fails and ignoreNetworkErrors is set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
require.NoError(t, s.Validate(ctx, "", "hufzeqmalt"))
})
})
t.Run("max breaches", func(t *testing.T) {
t.Parallel()
const maxBreaches = 5
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMaxBreaches, maxBreaches))
s, fakeClient := newTestValidator(t, base)
hibpResp := make(chan string, 1)
fakeClient.responder = func(req *http.Request) (*http.Response, error) {
buffer := bytes.NewBufferString(<-hibpResp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(buffer),
ContentLength: int64(buffer.Len()),
Request: req,
}, nil
}
hashPw := func(t *testing.T, pw string) string {
//#nosec G401 -- sha1 is used for k-anonymity
h := sha1.New()
_, err := h.Write([]byte(pw))
require.NoError(t, err)
hpw := h.Sum(nil)
return fmt.Sprintf("%X", hpw)[5:]
}
randomPassword := func(t *testing.T) string {
pw := make([]byte, 10)
_, err := rand.Read(pw)
require.NoError(t, err)
return fmt.Sprintf("%x", pw)
}
for _, tc := range []struct {
name string
res func(t *testing.T, hash string) string
expectErr error
}{
{
name: "contains invalid data which is ignored",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:2\ninvalid", hash)
},
},
{
name: "is missing a colon",
res: func(t *testing.T, hash string) string {
return hash
},
},
{
name: "contains invalid hash count",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:text\n%s:2", hashPw(t, randomPassword(t)), hash)
},
expectErr: herodot.ErrUpstreamError(),
},
{
name: "is missing hash count",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s\n%s:2", hash, hashPw(t, randomPassword(t)))
},
},
{
name: "response contains no matches",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:57", hashPw(t, randomPassword(t)))
},
},
{
name: "contains less than maxBreachesThreshold",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf(
"%s:%d\n%s:%d",
hash,
maxBreaches,
hashPw(t, randomPassword(t)),
maxBreaches+1,
)
},
},
{
name: "contains less than maxBreachesThreshold with a leading comma",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf(
"%s:%d\n%s:0,%d",
hash,
maxBreaches,
hashPw(t, randomPassword(t)),
maxBreaches+1,
)
},
},
{
name: "contains more than maxBreachesThreshold",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:%d", hash, maxBreaches+1)
},
expectErr: text.NewErrorValidationPasswordTooManyBreaches(
int64(maxBreaches) + 1, // #nosec G115
),
},
} {
t.Run(fmt.Sprintf("case=%s/expected err=%s", tc.name, tc.expectErr), func(t *testing.T) {
pw := randomPassword(t)
hash := hashPw(t, pw)
hibpResp <- tc.res(t, hash)
err := s.Validate(context.Background(), "", pw)
assert.ErrorIs(t, err, tc.expectErr)
})
// verify the fetch was done, i.e. channel is empty
select {
case r := <-hibpResp:
t.Logf("expected the validate step to fetch the response, but I still got %s", r)
t.FailNow()
default:
// continue
}
}
})
}
func TestChangeHaveIBeenPwnedValidationHost(t *testing.T) {
t.Parallel()
testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
t.Cleanup(testServer.Close)
testServerURL, err := url.Parse(testServer.URL)
require.NoError(t, err)
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedHost, testServerURL.Host))
s, fakeClient := newTestValidator(t, base)
testServerExpectedCallURL := fmt.Sprintf("https://%s/range/BCBA9", testServerURL.Host)
t.Run("case=should send request to test server", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
require.Contains(t, fakeClient.RequestedURLs(), testServerExpectedCallURL)
})
}
func TestDisableHaveIBeenPwnedValidationHost(t *testing.T) {
t.Parallel()
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
s, fakeClient := newTestValidator(t, base)
t.Run("case=should not send request to test server", func(t *testing.T) {
require.NoError(t, s.Validate(t.Context(), "mohutdesub", "damrumukuh"))
require.Empty(t, fakeClient.RequestedURLs())
})
}
func TestChangeMinPasswordLength(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMinLength, 10))
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
t.Run("case=should not fail if password is longer than min length", func(t *testing.T) {
require.NoError(t, s.Validate(t.Context(), "", "kuobahcaas"))
})
t.Run("case=should fail if password is shorter than min length", func(t *testing.T) {
require.Error(t, s.Validate(t.Context(), "", "rfqyfjied"))
})
}
func TestChangeIdentifierSimilarityCheckEnabled(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
t.Run("case=should not fail if password is similar to identifier", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, false)
require.NoError(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
})
t.Run("case=should fail if password is similar to identifier", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
require.Error(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
})
}
// TestIdentifierSimilarityCheckMaxLength guards against an algorithmic-complexity
// DoS (H1 #3866310): the O(n·m) identifier-similarity check must skip oversized
// operands, which a short identifier can never be similar to anyway.
func TestIdentifierSimilarityCheckMaxLength(t *testing.T) {
t.Parallel()
// Disable HaveIBeenPwned so validation is deterministic and offline.
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
t.Run("case=skips the similarity check for oversized operands", func(t *testing.T) {
// Two identical strings one char past the 256-char cap would trip the
// too-similar check if it ran, so a nil result proves the length guard
// skipped it. Staying just over the cap keeps the test cheap even if the
// guard regresses (the check allocates an O(n·m) matrix).
large := strings.Repeat("a", 257)
require.NoError(t, s.Validate(ctx, large, large))
})
t.Run("case=still detects similarity for short operands", func(t *testing.T) {
require.ErrorIs(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"), text.NewErrorValidationPasswordIdentifierTooSimilar())
})
}
type fakeHttpClient struct {
http.Client
requestedURLs []string
responder func(*http.Request) (*http.Response, error)
}
func NewFakeHTTPClient() *fakeHttpClient {
client := fakeHttpClient{
responder: func(*http.Request) (*http.Response, error) {
return nil, errors.New("No responder defined in fake HTTP client")
},
}
client.Client = http.Client{
Transport: &fakeRoundTripper{&client},
Timeout: time.Second,
}
return &client
}
func (c *fakeHttpClient) RespondWith(status int, body string) {
c.responder = func(request *http.Request) (*http.Response, error) {
buffer := bytes.NewBufferString(body)
return &http.Response{
StatusCode: status,
Body: io.NopCloser(buffer),
ContentLength: int64(buffer.Len()),
Request: request,
}, nil
}
}
func (c *fakeHttpClient) RespondWithError(err string) {
c.responder = func(*http.Request) (*http.Response, error) {
return nil, errors.New(err)
}
}
// RespondWithBodyError responds with a status 200 whose body yields prefix and
// then fails, mimicking a connection reset or a client timeout that only hits
// once the response is already being read.
func (c *fakeHttpClient) RespondWithBodyError(prefix string, err error) {
c.responder = func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(io.MultiReader(strings.NewReader(prefix), errorReader{err})),
Request: request,
}, nil
}
}
func (c *fakeHttpClient) Reset() {
c.requestedURLs = nil
}
func (c *fakeHttpClient) RequestedURLs() []string {
return c.requestedURLs
}
func (c *fakeHttpClient) handle(request *http.Request) (*http.Response, error) {
c.requestedURLs = append(c.requestedURLs, request.URL.String())
if request.Body != nil {
_ = request.Body.Close()
}
return c.responder(request)
}
type fakeRoundTripper struct {
client *fakeHttpClient
}
func (rt *fakeRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
return rt.client.handle(request)
}
// errBodyRead is the error net/http returns from a response body read when the
// client's timeout fires after the response headers have been received.
var errBodyRead = errors.New("net/http: request canceled (Client.Timeout or context cancellation while reading body)")
type errorReader struct{ err error }
func (r errorReader) Read([]byte) (int, error) { return 0, r.err }