Merge commit from fork

* fix(security): code credential does not respect `highest_available` setting

This patch fixes a security vulnerability which prevents the `code` method to properly report it's credentials count to the `highest_available` mechanism.

For more details on this issue please refer to the [security advisory](https://github.com/ory/kratos/security/advisories/GHSA-wc43-73w7-x2f5).

* fix: normalize code credentials and deprecate via parameter

Before this, code credentials for passwordless and mfa login were incorrectly stored and normalized. This could cause issues where the system would not detect the user's phone number, and where SMS/email MFA would not properly work with the `highest_available` setting.

Breaking changes: Please note that the `via` parameter is deprecated when performing SMS 2FA. It will be removed in a future version. If the parameter is not included in the request, the user will see all their phone/email addresses from which to perform the flow.

Before upgrading, ensure that your identity schema has the appropriate code configuration when using the code method for passwordless or 2fa login.

If you are using the code method for 2FA login already, or you are using it for 1FA login but have not yet configured the code identifier, set `selfservice.methods.code.config.missing_credential_fallback_enabled` to `true` to prevent users from being locked out.
This commit is contained in:
hackerman 2024-08-28 13:36:40 +02:00 committed by GitHub
parent 7945104750
commit 123e80782b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
165 changed files with 4879 additions and 1467 deletions

View File

@ -176,9 +176,9 @@ func init() {
"NewErrorValidationLoginLinkedCredentialsDoNotMatch": text.NewErrorValidationLoginLinkedCredentialsDoNotMatch(),
"NewErrorValidationAddressUnknown": text.NewErrorValidationAddressUnknown(),
"NewInfoSelfServiceLoginCodeMFA": text.NewInfoSelfServiceLoginCodeMFA(),
"NewInfoSelfServiceLoginCodeMFAHint": text.NewInfoSelfServiceLoginCodeMFAHint("{maskedIdentifier}"),
"NewInfoLoginPassword": text.NewInfoLoginPassword(),
"NewErrorValidationAccountNotFound": text.NewErrorValidationAccountNotFound(),
"NewInfoSelfServiceLoginAAL2CodeAddress": text.NewInfoSelfServiceLoginAAL2CodeAddress("{channel}", "{address}"),
}
}

View File

@ -181,6 +181,7 @@ const (
ViperKeyLinkLifespan = "selfservice.methods.link.config.lifespan"
ViperKeyLinkBaseURL = "selfservice.methods.link.config.base_url"
ViperKeyCodeLifespan = "selfservice.methods.code.config.lifespan"
ViperKeyCodeConfigMissingCredentialFallbackEnabled = "selfservice.methods.code.config.missing_credential_fallback_enabled"
ViperKeyPasswordHaveIBeenPwnedHost = "selfservice.methods.password.config.haveibeenpwned_host"
ViperKeyPasswordHaveIBeenPwnedEnabled = "selfservice.methods.password.config.haveibeenpwned_enabled"
ViperKeyPasswordMaxBreaches = "selfservice.methods.password.config.max_breaches"
@ -1330,6 +1331,10 @@ func (p *Config) SelfServiceCodeMethodLifespan(ctx context.Context) time.Duratio
return p.GetProvider(ctx).DurationF(ViperKeyCodeLifespan, time.Hour)
}
func (p *Config) SelfServiceCodeMethodMissingCredentialFallbackEnabled(ctx context.Context) bool {
return p.GetProvider(ctx).Bool(ViperKeyCodeConfigMissingCredentialFallbackEnabled)
}
func (p *Config) DatabaseCleanupSleepTables(ctx context.Context) time.Duration {
return p.GetProvider(ctx).Duration(ViperKeyDatabaseCleanupSleepTables)
}

View File

@ -33,7 +33,9 @@ func (t *TestConfigProvider) Config(ctx context.Context, config *configx.Provide
if !ok {
return config
}
opts := make([]configx.OptionModifier, 0, len(values))
opts = append(opts, configx.WithValues(config.All()))
for _, v := range values {
opts = append(opts, configx.WithValues(v))
}

View File

@ -797,6 +797,10 @@ func (m *RegistryDefault) LoginCodePersister() code.LoginCodePersister {
return m.Persister()
}
func (m *RegistryDefault) TransactionalPersisterProvider() x.TransactionalPersister {
return m.Persister()
}
func (m *RegistryDefault) Persister() persistence.Persister {
return m.persister
}

View File

@ -1574,6 +1574,13 @@
"pattern": "^([0-9]+(ns|us|ms|s|m|h))+$",
"default": "1h",
"examples": ["1h", "1m", "1s"]
},
"missing_credential_fallback_enabled": {
"type": "boolean",
"title": "Enable Code OTP as a Fallback",
"description": "Enabling this allows users to sign in with the code method, even if their identity schema or their credentials are not set up to use the code method. If enabled, a verified address (such as an email) will be used to send the code to the user. Use with caution and only if actually needed.",
"default": false
}
}
}

2
go.mod
View File

@ -257,7 +257,7 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/nyaruka/phonenumbers v1.3.6 // indirect
github.com/nyaruka/phonenumbers v1.3.6
github.com/ogier/pflag v0.0.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect

View File

@ -0,0 +1,6 @@
{
"type": "password",
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,6 @@
{
"type": "password",
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,18 @@
{
"type": "code",
"config": {
"addresses": [
{
"channel": "sms",
"address": "+4917667111638"
},
{
"channel": "email",
"address": "foo@ory.sh"
}
]
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,18 @@
{
"type": "code",
"config": {
"addresses": [
{
"channel": "sms",
"address": "+4917667111638"
},
{
"channel": "email",
"address": "foo@ory.sh"
}
]
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,6 @@
{
"type": "webauthn",
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,6 @@
{
"type": "password",
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,6 @@
{
"type": "webauthn",
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,6 @@
{
"type": "webauthn",
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "code",
"config": {
"addresses": [
{
"channel": "email",
"address": "foo@ory.sh"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "code",
"config": {
"addresses": [
{
"channel": "email",
"address": "foo@ory.sh"
}
]
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "code",
"config": {
"addresses": [
{
"channel": "email",
"address": "foo@ory.sh"
}
]
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,18 @@
{
"type": "code",
"config": {
"addresses": [
{
"channel": "sms",
"address": "+4917667111638"
},
{
"channel": "email",
"address": "foo@ory.sh"
}
]
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@ -0,0 +1,30 @@
{
"id": "4d64fa08-20fc-450d-bebd-ebd7c7b6e249",
"credentials": {
"code": {
"type": "code",
"identifiers": [
"hi@example.org"
],
"config": {
"addresses": [
{
"address": "hi@example.org",
"channel": "email"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "",
"schema_url": "",
"state": "",
"traits": null,
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@ -0,0 +1,30 @@
{
"id": "4d64fa08-20fc-450d-bebd-ebd7c7b6e249",
"credentials": {
"code": {
"type": "code",
"identifiers": [
"hi@example.org"
],
"config": {
"addresses": [
{
"address": "hi@example.org",
"channel": "email"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "",
"schema_url": "",
"state": "",
"traits": null,
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@ -0,0 +1,35 @@
{
"id": "4d64fa08-20fc-450d-bebd-ebd7c7b6e249",
"credentials": {
"code": {
"type": "code",
"identifiers": [
"foo@example.org",
"bar@example.org"
],
"config": {
"addresses": [
{
"address": "foo@example.org",
"channel": "email"
},
{
"address": "bar@example.org",
"channel": "email"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "",
"schema_url": "",
"state": "",
"traits": null,
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@ -0,0 +1,30 @@
{
"id": "4d64fa08-20fc-450d-bebd-ebd7c7b6e249",
"credentials": {
"code": {
"type": "code",
"identifiers": [
"hi@example.org"
],
"config": {
"addresses": [
{
"address": "hi@example.org",
"channel": "email"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "",
"schema_url": "",
"state": "",
"traits": null,
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@ -0,0 +1,30 @@
{
"id": "4d64fa08-20fc-450d-bebd-ebd7c7b6e249",
"credentials": {
"code": {
"type": "code",
"identifiers": [
"hi@example.org"
],
"config": {
"addresses": [
{
"address": "hi@example.org",
"channel": "email"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "",
"schema_url": "",
"state": "",
"traits": null,
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@ -0,0 +1,35 @@
{
"id": "4d64fa08-20fc-450d-bebd-ebd7c7b6e249",
"credentials": {
"code": {
"type": "code",
"identifiers": [
"foo@example.org",
"+12341234"
],
"config": {
"addresses": [
{
"address": "foo@example.org",
"channel": "email"
},
{
"address": "+12341234",
"channel": "sms"
}
]
},
"version": 1,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "",
"schema_url": "",
"state": "",
"traits": null,
"metadata_public": null,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@ -3,7 +3,7 @@
"credentials": {
"webauthn": {
"type": "webauthn",
"identifiers": null,
"identifiers": [],
"config": {
"credentials": [
{

View File

@ -5,4 +5,5 @@ package identity
const (
AddressTypeEmail = "email"
AddressTypeSMS = "sms"
)

View File

@ -215,8 +215,8 @@ type (
// swagger:ignore
ActiveCredentialsCounter interface {
ID() CredentialsType
CountActiveFirstFactorCredentials(cc map[CredentialsType]Credentials) (int, error)
CountActiveMultiFactorCredentials(cc map[CredentialsType]Credentials) (int, error)
CountActiveFirstFactorCredentials(context.Context, map[CredentialsType]Credentials) (int, error)
CountActiveMultiFactorCredentials(context.Context, map[CredentialsType]Credentials) (int, error)
}
// swagger:ignore

View File

@ -4,22 +4,63 @@
package identity
import (
"database/sql"
"encoding/json"
"github.com/ory/herodot"
"github.com/pkg/errors"
"github.com/ory/x/stringsx"
)
type CodeAddressType = string
type CodeChannel string
const (
CodeAddressTypeEmail CodeAddressType = AddressTypeEmail
CodeChannelEmail CodeChannel = AddressTypeEmail
CodeChannelSMS CodeChannel = AddressTypeSMS
)
func NewCodeChannel(value string) (CodeChannel, error) {
switch f := stringsx.SwitchExact(value); {
case f.AddCase(string(CodeChannelEmail)):
return CodeChannelEmail, nil
case f.AddCase(string(CodeChannelSMS)):
return CodeChannelSMS, nil
default:
return "", errors.Wrap(ErrInvalidCodeAddressType, f.ToUnknownCaseErr().Error())
}
}
// CredentialsCode represents a one time login/registration code
//
// swagger:model identityCredentialsCode
type CredentialsCode struct {
// The type of the address for this code
AddressType CodeAddressType `json:"address_type"`
// UsedAt indicates whether and when a recovery code was used.
UsedAt sql.NullTime `json:"used_at,omitempty"`
Addresses []CredentialsCodeAddress `json:"addresses"`
}
// swagger:model identityCredentialsCodeAddress
type CredentialsCodeAddress struct {
// The type of the address for this code
Channel CodeChannel `json:"channel"`
// The address for this code
Address string `json:"address"`
}
var ErrInvalidCodeAddressType = herodot.ErrInternalServerError.WithReasonf("The address type for sending OTP codes is not supported.")
func (c *CredentialsCodeAddress) UnmarshalJSON(data []byte) (err error) {
type alias CredentialsCodeAddress
var ac alias
if err := json.Unmarshal(data, &ac); err != nil {
return err
}
ac.Channel, err = NewCodeChannel(string(ac.Channel))
if err != nil {
return err
}
*c = CredentialsCodeAddress(ac)
return nil
}

View File

@ -0,0 +1,110 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package identity
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCredentialsCodeAddressUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want CredentialsCodeAddress
wantErr bool
}{
{
name: "valid email address",
input: `{"channel": "email", "address": "user@example.com"}`,
want: CredentialsCodeAddress{
Channel: CodeChannelEmail,
Address: "user@example.com",
},
wantErr: false,
},
{
name: "valid SMS address",
input: `{"channel": "sms", "address": "+1234567890"}`,
want: CredentialsCodeAddress{
Channel: CodeChannelSMS,
Address: "+1234567890",
},
wantErr: false,
},
{
name: "invalid address type",
input: `{"channel": "invalid", "address": "user@example.com"}`,
want: CredentialsCodeAddress{},
wantErr: true,
},
{
name: "missing channel field",
input: `{"address": "user@example.com"}`,
want: CredentialsCodeAddress{},
wantErr: true,
},
{
name: "invalid JSON structure",
input: `{"channel": "email", "address": "user@example.com"`,
want: CredentialsCodeAddress{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got CredentialsCodeAddress
err := json.Unmarshal([]byte(tt.input), &got)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}
func TestNewCodeAddressType(t *testing.T) {
tests := []struct {
name string
input string
want CodeChannel
wantErr bool
}{
{
name: "valid email address type",
input: "email",
want: CodeChannelEmail,
wantErr: false,
},
{
name: "valid SMS address type",
input: "sms",
want: CodeChannelSMS,
wantErr: false,
},
{
name: "invalid address type",
input: "invalid",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewCodeChannel(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}

View File

@ -6,6 +6,7 @@ package identity
import (
"encoding/json"
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@ -60,7 +61,55 @@ func UpgradeCredentials(i *Identity) error {
if err := UpgradeWebAuthnCredentials(i, &c); err != nil {
return errors.WithStack(err)
}
if err := UpgradeCodeCredentials(&c); err != nil {
return errors.WithStack(err)
}
i.Credentials[k] = c
}
return nil
}
func UpgradeCodeCredentials(c *Credentials) (err error) {
if c.Type != CredentialsTypeCodeAuth {
return nil
}
version := c.Version
if version == 0 {
addressType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(c.Config, "address_type").String()))
channel, err := NewCodeChannel(addressType)
if err != nil {
// We know that in some cases the address type can be empty. In this case, we default to email
// as sms is a new addition to the address_type introduced in this PR.
channel = CodeChannelEmail
}
c.Config, err = sjson.DeleteBytes(c.Config, "used_at")
if err != nil {
return errors.WithStack(err)
}
c.Config, err = sjson.DeleteBytes(c.Config, "address_type")
if err != nil {
return errors.WithStack(err)
}
for _, id := range c.Identifiers {
if id == "" {
continue
}
c.Config, err = sjson.SetBytes(c.Config, "addresses.-1", map[string]any{
"address": id,
"channel": channel,
})
if err != nil {
return errors.WithStack(err)
}
}
c.Version = 1
}
return nil
}

View File

@ -8,6 +8,7 @@ import (
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -30,43 +31,63 @@ func TestUpgradeCredentials(t *testing.T) {
snapshotx.SnapshotTExcept(t, &wc, nil)
})
identityID := uuid.FromStringOrNil("4d64fa08-20fc-450d-bebd-ebd7c7b6e249")
run := func(t *testing.T, identifiers []string, config string, version int, credentialsType CredentialsType, expectedVersion int) {
if identifiers == nil {
identifiers = []string{"hi@example.org"}
}
i := &Identity{
ID: uuid.FromStringOrNil("4d64fa08-20fc-450d-bebd-ebd7c7b6e249"),
Credentials: map[CredentialsType]Credentials{
credentialsType: {
Identifiers: identifiers,
Type: credentialsType,
Version: version,
Config: []byte(config),
}},
}
require.NoError(t, UpgradeCredentials(i))
wc := WithCredentialsAndAdminMetadataInJSON(*i)
snapshotx.SnapshotT(t, &wc)
assert.Equal(t, expectedVersion, i.Credentials[credentialsType].Version)
}
t.Run("type=code", func(t *testing.T) {
t.Run("from=v0 with email empty space value", func(t *testing.T) {
t.Run("with one identifier", func(t *testing.T) {
run(t, nil, `{"address_type": "email ", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`, 0, CredentialsTypeCodeAuth, 1)
})
t.Run("with two identifiers", func(t *testing.T) {
run(t, []string{"foo@example.org", "bar@example.org"}, `{"address_type": "email ", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`, 0, CredentialsTypeCodeAuth, 1)
})
})
t.Run("from=v0 with empty value", func(t *testing.T) {
run(t, nil, `{"address_type": "", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`, 0, CredentialsTypeCodeAuth, 1)
})
t.Run("from=v0 with correct value", func(t *testing.T) {
run(t, nil, `{"address_type": "email", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`, 0, CredentialsTypeCodeAuth, 1)
})
t.Run("from=v0 with unknown value", func(t *testing.T) {
run(t, nil, `{"address_type": "other", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`, 0, CredentialsTypeCodeAuth, 1)
})
t.Run("from=v2 with empty value", func(t *testing.T) {
run(t, []string{"foo@example.org", "+12341234"}, `{"addresses": [{"address":"foo@example.org","channel":"email"},{"address":"+12341234","channel":"sms"}]}`, 1, CredentialsTypeCodeAuth, 1)
})
})
t.Run("type=webauthn", func(t *testing.T) {
t.Run("from=v0", func(t *testing.T) {
i := &Identity{
ID: identityID,
Credentials: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {
Identifiers: []string{"4d64fa08-20fc-450d-bebd-ebd7c7b6e249"},
Type: CredentialsTypeWebAuthn,
Version: 0,
Config: webAuthnV0,
}},
}
require.NoError(t, UpgradeCredentials(i))
wc := WithCredentialsAndAdminMetadataInJSON(*i)
snapshotx.SnapshotTExcept(t, &wc, nil)
assert.Equal(t, 1, i.Credentials[CredentialsTypeWebAuthn].Version)
run(t, []string{"4d64fa08-20fc-450d-bebd-ebd7c7b6e249"}, string(webAuthnV0), 0, CredentialsTypeWebAuthn, 1)
})
t.Run("from=v1", func(t *testing.T) {
i := &Identity{
ID: identityID,
Credentials: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {
Type: CredentialsTypeWebAuthn,
Version: 1,
Config: webAuthnV1,
}},
}
require.NoError(t, UpgradeCredentials(i))
wc := WithCredentialsAndAdminMetadataInJSON(*i)
snapshotx.SnapshotTExcept(t, &wc, nil)
assert.Equal(t, 1, i.Credentials[CredentialsTypeWebAuthn].Version)
run(t, []string{}, string(webAuthnV1), 1, CredentialsTypeWebAuthn, 1)
})
})
}

View File

@ -4,16 +4,21 @@
package identity
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/ory/kratos/x"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/schema"
"github.com/ory/x/sqlxx"
"github.com/ory/x/stringslice"
"github.com/ory/x/stringsx"
"github.com/ory/kratos/schema"
)
type SchemaExtensionCredentials struct {
@ -35,6 +40,7 @@ func (r *SchemaExtensionCredentials) setIdentifier(ct CredentialsType, value int
Config: sqlxx.JSONRawMessage{},
}
}
if r.v == nil {
r.v = make(map[CredentialsType][]string)
}
@ -57,22 +63,71 @@ func (r *SchemaExtensionCredentials) Run(ctx jsonschema.ValidationContext, s sch
}
if s.Credentials.Code.Identifier {
switch f := stringsx.SwitchExact(s.Credentials.Code.Via); {
case f.AddCase(AddressTypeEmail):
if !jsonschema.Formats["email"](value) {
return ctx.Error("format", "%q is not a valid %q", value, s.Credentials.Code.Via)
}
r.setIdentifier(CredentialsTypeCodeAuth, value)
// case f.AddCase(AddressTypePhone):
// if !jsonschema.Formats["tel"](value) {
// return ctx.Error("format", "%q is not a valid %q", value, s.Credentials.Code.Via)
// }
// r.setIdentifier(CredentialsTypeCodeAuth, value, CredentialsIdentifierAddressTypePhone)
default:
return ctx.Error("", "credentials.code.via has unknown value %q", s.Credentials.Code.Via)
via, err := NewCodeChannel(s.Credentials.Code.Via)
if err != nil {
return ctx.Error("ory.sh~/kratos/credentials/code/via", "channel type %q must be one of %s", s.Credentials.Code.Via, strings.Join([]string{
string(CodeChannelEmail),
string(CodeChannelSMS),
}, ", "))
}
cred := r.i.GetCredentialsOr(CredentialsTypeCodeAuth, &Credentials{
Type: CredentialsTypeCodeAuth,
Identifiers: []string{},
Config: sqlxx.JSONRawMessage("{}"),
Version: 1,
})
var conf CredentialsCode
if len(cred.Config) > 0 {
// Only decode the config if it is not empty.
if err := json.Unmarshal(cred.Config, &conf); err != nil {
return &jsonschema.ValidationError{Message: "unable to unmarshal identity credentials"}
}
}
if conf.Addresses == nil {
conf.Addresses = []CredentialsCodeAddress{}
}
value, err := x.NormalizeIdentifier(fmt.Sprintf("%s", value), string(via))
if err != nil {
return &jsonschema.ValidationError{Message: err.Error()}
}
conf.Addresses = append(conf.Addresses, CredentialsCodeAddress{
Channel: via,
Address: fmt.Sprintf("%s", value),
})
conf.Addresses = lo.UniqBy(conf.Addresses, func(item CredentialsCodeAddress) string {
return fmt.Sprintf("%x:%s", item.Address, item.Channel)
})
sort.SliceStable(conf.Addresses, func(i, j int) bool {
if conf.Addresses[i].Address == conf.Addresses[j].Address {
return conf.Addresses[i].Channel < conf.Addresses[j].Channel
}
return conf.Addresses[i].Address < conf.Addresses[j].Address
})
if r.v == nil {
r.v = make(map[CredentialsType][]string)
}
r.v[CredentialsTypeCodeAuth] = stringslice.Unique(append(r.v[CredentialsTypeCodeAuth],
lo.Map(conf.Addresses, func(item CredentialsCodeAddress, _ int) string {
return item.Address
})...,
))
cred.Identifiers = r.v[CredentialsTypeCodeAuth]
cred.Config, err = json.Marshal(conf)
if err != nil {
return errors.WithStack(err)
}
r.i.SetCredentials(CredentialsTypeCodeAuth, *cred)
}
return nil

View File

@ -9,6 +9,8 @@ import (
"fmt"
"testing"
"github.com/ory/x/snapshotx"
"github.com/ory/jsonschema/v3"
_ "github.com/ory/jsonschema/v3/fileloader"
@ -87,6 +89,42 @@ func TestSchemaExtensionCredentials(t *testing.T) {
},
ct: identity.CredentialsTypeCodeAuth,
},
{
doc: `{"email":"FOO@ory.sh"}`,
schema: "file://./stub/extension/credentials/code.schema.json",
expect: []string{"foo@ory.sh"},
existing: &identity.Credentials{
Identifiers: []string{"not-foo@ory.sh", "foo@ory.sh"},
},
ct: identity.CredentialsTypeCodeAuth,
},
{
doc: `{"email":"FOO@ory.sh","phone":"+49 176 671 11 638"}`,
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
expect: []string{"+4917667111638", "foo@ory.sh"},
existing: &identity.Credentials{
Identifiers: []string{"not-foo@ory.sh", "foo@ory.sh"},
},
ct: identity.CredentialsTypeCodeAuth,
},
{
doc: `{"email":"FOO@ory.sh","phone":"+49 176 671 11 638"}`,
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
expect: []string{"+4917667111638", "foo@ory.sh"},
existing: &identity.Credentials{
Identifiers: []string{"not-foo@ory.sh", "foo@ory.sh"},
},
ct: identity.CredentialsTypeCodeAuth,
},
{
doc: `{"email":"FOO@ory.sh","email2":"FOO@ory.sh","phone":"+49 176 671 11 638"}`,
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
expect: []string{"+4917667111638", "foo@ory.sh"},
existing: &identity.Credentials{
Identifiers: []string{"not-foo@ory.sh", "fOo@ory.sh"},
},
ct: identity.CredentialsTypeCodeAuth,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
c := jsonschema.NewCompiler()
@ -103,12 +141,15 @@ func TestSchemaExtensionCredentials(t *testing.T) {
err = c.MustCompile(ctx, tc.schema).Validate(bytes.NewBufferString(tc.doc))
if tc.expectErr != nil {
require.EqualError(t, err, tc.expectErr.Error())
} else {
require.NoError(t, err)
}
require.NoError(t, e.Finish())
credentials, ok := i.GetCredentials(tc.ct)
require.True(t, ok)
assert.ElementsMatch(t, tc.expect, credentials.Identifiers)
snapshotx.SnapshotT(t, credentials, snapshotx.ExceptPaths("identifiers"))
})
}
}

View File

@ -19,7 +19,15 @@ func (h *Handler) importCredentials(ctx context.Context, i *Identity, creds *Ide
return nil
}
// This method only support password and OIDC import at the moment.
// If other methods are added please ensure that the available AAL is set correctly in the identity.
//
// It would actually be good if we would validate the identity post-creation to see if the credentials are working.
if creds.Password != nil {
// This method is somewhat hacky, because it does not set the credential's identifier. It relies on the
// identity validation to set the identifier, which is called after this method.
//
// It would be good to make this explicit.
if err := h.importPasswordCredentials(ctx, i, creds.Password); err != nil {
return err
}

View File

@ -68,9 +68,17 @@ type Identity struct {
// Credentials represents all credentials that can be used for authenticating this identity.
Credentials map[CredentialsType]Credentials `json:"credentials,omitempty" faker:"-" db:"-"`
// AvailableAAL defines the maximum available AAL for this identity. If the user has only a password
// configured, the AAL will be 1. If the user has a password and a TOTP configured, the AAL will be 2.
AvailableAAL NullableAuthenticatorAssuranceLevel `json:"-" faker:"-" db:"available_aal"`
// InternalAvailableAAL defines the maximum available AAL for this identity.
//
// - If the user has at least one two-factor authentication method configured, the AAL will be 2.
// - If the user has only a password configured, the AAL will be 1.
//
// This field is AAL2 as soon as a second factor credential is found. A first factor is not required for this
// field to return `aal2`.
//
// This field is primarily used to determine whether the user needs to upgrade to AAL2 without having to check
// all the credentials in the database. Use with caution!
InternalAvailableAAL NullableAuthenticatorAssuranceLevel `json:"-" faker:"-" db:"available_aal"`
// // IdentifierCredentials contains the access and refresh token for oidc identifier
// IdentifierCredentials []IdentifierCredential `json:"identifier_credentials,omitempty" faker:"-" db:"-"`
@ -345,24 +353,27 @@ func (i *Identity) UnmarshalJSON(b []byte) error {
return err
}
// SetAvailableAAL sets the InternalAvailableAAL field based on the credentials stored in the identity.
//
// If a second factor is set up, the AAL will be set to 2. If only a first factor is set up, the AAL will be set to 1.
//
// A first factor is NOT required for the AAL to be set to 2 if a second factor is set up.
func (i *Identity) SetAvailableAAL(ctx context.Context, m *Manager) (err error) {
i.AvailableAAL = NewNullableAuthenticatorAssuranceLevel(NoAuthenticatorAssuranceLevel)
if c, err := m.CountActiveFirstFactorCredentials(ctx, i); err != nil {
return err
} else if c == 0 {
// No first factor set up - AAL is 0
return nil
}
i.AvailableAAL = NewNullableAuthenticatorAssuranceLevel(AuthenticatorAssuranceLevel1)
if c, err := m.CountActiveMultiFactorCredentials(ctx, i); err != nil {
return err
} else if c == 0 {
// No second factor set up - AAL is 1
} else if c > 0 {
i.InternalAvailableAAL = NewNullableAuthenticatorAssuranceLevel(AuthenticatorAssuranceLevel2)
return nil
}
i.AvailableAAL = NewNullableAuthenticatorAssuranceLevel(AuthenticatorAssuranceLevel2)
if c, err := m.CountActiveFirstFactorCredentials(ctx, i); err != nil {
return err
} else if c > 0 {
i.InternalAvailableAAL = NewNullableAuthenticatorAssuranceLevel(AuthenticatorAssuranceLevel1)
return nil
}
i.InternalAvailableAAL = NewNullableAuthenticatorAssuranceLevel(NoAuthenticatorAssuranceLevel)
return nil
}

View File

@ -29,13 +29,13 @@ import (
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3"
"github.com/ory/x/errorsx"
"github.com/ory/kratos/courier"
)
var ErrProtectedFieldModified = herodot.ErrForbidden.
WithReasonf(`A field was modified that updates one or more credentials-related settings. This action was blocked because an unprivileged method was used to execute the update. This is either a configuration issue or a bug and should be reported to the system administrator.`)
var (
ErrProtectedFieldModified = herodot.ErrForbidden.
WithReasonf(`A field was modified that updates one or more credentials-related settings. This action was blocked because an unprivileged method was used to execute the update. This is either a configuration issue or a bug and should be reported to the system administrator.`)
)
type (
managerDependencies interface {
@ -96,10 +96,6 @@ func (m *Manager) Create(ctx context.Context, i *Identity, opts ...ManagerOption
return err
}
if err := i.SetAvailableAAL(ctx, m); err != nil {
return err
}
if err := m.r.PrivilegedIdentityPool().CreateIdentity(ctx, i); err != nil {
if errors.Is(err, sqlcon.ErrUniqueViolation) {
return m.findExistingAuthMethod(ctx, err, i)
@ -329,10 +325,6 @@ func (m *Manager) CreateIdentities(ctx context.Context, identities []*Identity,
i.SchemaID = m.r.Config().DefaultIdentityTraitsSchemaID(ctx)
}
if err := i.SetAvailableAAL(ctx, m); err != nil {
return err
}
o := newManagerOptions(opts)
if err := m.ValidateIdentity(ctx, i, o); err != nil {
return err
@ -390,10 +382,6 @@ func (m *Manager) Update(ctx context.Context, updated *Identity, opts ...Manager
return err
}
if err := updated.SetAvailableAAL(ctx, m); err != nil {
return err
}
return m.r.PrivilegedIdentityPool().UpdateIdentity(ctx, updated)
}
@ -444,6 +432,30 @@ func (m *Manager) SetTraits(ctx context.Context, id uuid.UUID, traits Traits, op
return updated, nil
}
// RefreshAvailableAAL refreshes the available AAL for the identity.
//
// This method is a no-op if everything is up-to date.
//
// Please make sure to load all credentials before using this method.
func (m *Manager) RefreshAvailableAAL(ctx context.Context, i *Identity) (err error) {
if len(i.Credentials) == 0 {
if err := m.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, ExpandCredentials); err != nil {
return err
}
}
aalBefore := i.InternalAvailableAAL
if err := i.SetAvailableAAL(ctx, m); err != nil {
return err
}
if aalBefore.String != i.InternalAvailableAAL.String || aalBefore.Valid != i.InternalAvailableAAL.Valid {
return m.r.PrivilegedIdentityPool().UpdateIdentityColumns(ctx, i, "available_aal")
}
return nil
}
func (m *Manager) UpdateTraits(ctx context.Context, id uuid.UUID, traits Traits, opts ...ManagerOption) (err error) {
ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.UpdateTraits")
defer otelx.End(span, &err)
@ -458,17 +470,18 @@ func (m *Manager) UpdateTraits(ctx context.Context, id uuid.UUID, traits Traits,
}
func (m *Manager) ValidateIdentity(ctx context.Context, i *Identity, o *ManagerOptions) (err error) {
// This trace is more noisy than it's worth in diagnostic power.
// ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.validate")
// defer otelx.End(span, &err)
if err := m.r.IdentityValidator().Validate(ctx, i); err != nil {
if _, ok := errorsx.Cause(err).(*jsonschema.ValidationError); ok && !o.ExposeValidationErrors {
var validationErr *jsonschema.ValidationError
if errors.As(err, &validationErr) && !o.ExposeValidationErrors {
return herodot.ErrBadRequest.WithReasonf("%s", err).WithWrap(err)
}
return err
}
if err := i.SetAvailableAAL(ctx, m); err != nil {
return err
}
return nil
}
@ -478,7 +491,7 @@ func (m *Manager) CountActiveFirstFactorCredentials(ctx context.Context, i *Iden
// defer otelx.End(span, &err)
for _, strategy := range m.r.ActiveCredentialsCounterStrategies(ctx) {
current, err := strategy.CountActiveFirstFactorCredentials(i.Credentials)
current, err := strategy.CountActiveFirstFactorCredentials(ctx, i.Credentials)
if err != nil {
return 0, err
}
@ -494,7 +507,7 @@ func (m *Manager) CountActiveMultiFactorCredentials(ctx context.Context, i *Iden
// defer otelx.End(span, &err)
for _, strategy := range m.r.ActiveCredentialsCounterStrategies(ctx) {
current, err := strategy.CountActiveMultiFactorCredentials(i.Credentials)
current, err := strategy.CountActiveMultiFactorCredentials(ctx, i.Credentials)
if err != nil {
return 0, err
}

View File

@ -4,6 +4,7 @@
package identity_test
import (
"encoding/json"
"fmt"
"testing"
"time"
@ -12,6 +13,8 @@ import (
"github.com/ory/x/pointerx"
"github.com/ory/x/sqlcon"
_ "embed"
"github.com/gofrs/uuid"
"github.com/ory/x/sqlxx"
@ -28,6 +31,9 @@ import (
"github.com/ory/kratos/x"
)
//go:embed stub/aal.json
var refreshAALStubs []byte
func TestManager(t *testing.T) {
conf, reg := internal.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{
config.ViperKeyPublicBaseURL: "https://www.ory.sh/",
@ -70,6 +76,37 @@ func TestManager(t *testing.T) {
}
}
t.Run("method=CreateIdentities", func(t *testing.T) {
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) {
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = newTraits(email, "")
original.Credentials = map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
// By explicitly not setting the identifier, we mimic the behavior of the PATCH endpoint.
// This tests a bug we introduced on the PATCH endpoint where the AAL value would not be correct.
Identifiers: []string{},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
},
identity.CredentialsTypeTOTP: {
Type: identity.CredentialsTypeTOTP,
// By explicitly not setting the identifier, we mimic the behavior of the PATCH endpoint.
// This tests a bug we introduced on the PATCH endpoint where the AAL value would not be correct.
Identifiers: []string{},
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
},
}
require.NoError(t, reg.IdentityManager().CreateIdentities(ctx, []*identity.Identity{original}))
fromStore, err := reg.PrivilegedIdentityPool().GetIdentity(ctx, original.ID, identity.ExpandNothing)
require.NoError(t, err)
got, ok := fromStore.InternalAvailableAAL.ToAAL()
require.True(t, ok)
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got)
})
})
t.Run("method=Create", func(t *testing.T) {
t.Run("case=should create identity and track extension fields", func(t *testing.T) {
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
@ -77,7 +114,7 @@ func TestManager(t *testing.T) {
original.Traits = newTraits(email, "")
require.NoError(t, reg.IdentityManager().Create(ctx, original))
checkExtensionFieldsForIdentities(t, email, original)
got, ok := original.AvailableAAL.ToAAL()
got, ok := original.InternalAvailableAAL.ToAAL()
require.True(t, ok)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got)
})
@ -88,7 +125,7 @@ func TestManager(t *testing.T) {
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = newTraits(email, "")
require.NoError(t, reg.IdentityManager().Create(ctx, original))
got, ok := original.AvailableAAL.ToAAL()
got, ok := original.InternalAvailableAAL.ToAAL()
require.True(t, ok)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got)
})
@ -105,7 +142,7 @@ func TestManager(t *testing.T) {
},
}
require.NoError(t, reg.IdentityManager().Create(ctx, original))
got, ok := original.AvailableAAL.ToAAL()
got, ok := original.InternalAvailableAAL.ToAAL()
require.True(t, ok)
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, got)
})
@ -127,12 +164,12 @@ func TestManager(t *testing.T) {
},
}
require.NoError(t, reg.IdentityManager().Create(ctx, original))
got, ok := original.AvailableAAL.ToAAL()
got, ok := original.InternalAvailableAAL.ToAAL()
require.True(t, ok)
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got)
})
t.Run("case=should set AAL to 0 if only TOTP is set", func(t *testing.T) {
t.Run("case=should set AAL to 2 if only TOTP is set", func(t *testing.T) {
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = newTraits(email, "")
@ -144,9 +181,9 @@ func TestManager(t *testing.T) {
},
}
require.NoError(t, reg.IdentityManager().Create(ctx, original))
got, ok := original.AvailableAAL.ToAAL()
got, ok := original.InternalAvailableAAL.ToAAL()
require.True(t, ok)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got)
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got)
})
})
@ -378,7 +415,7 @@ func TestManager(t *testing.T) {
},
}
require.NoError(t, reg.IdentityManager().Update(ctx, original, identity.ManagerAllowWriteProtectedTraits))
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL.String)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String)
})
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) {
@ -393,19 +430,19 @@ func TestManager(t *testing.T) {
},
}
require.NoError(t, reg.IdentityManager().Create(ctx, original))
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL.String)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String)
require.NoError(t, reg.IdentityManager().Update(ctx, original, identity.ManagerAllowWriteProtectedTraits))
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL.String, "Updating without changes should not change AAL")
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String, "Updating without changes should not change AAL")
original.Credentials[identity.CredentialsTypeTOTP] = identity.Credentials{
Type: identity.CredentialsTypeTOTP,
Identifiers: []string{email},
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
}
require.NoError(t, reg.IdentityManager().Update(ctx, original, identity.ManagerAllowWriteProtectedTraits))
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.AvailableAAL.String)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.InternalAvailableAAL.String)
})
t.Run("case=should set AAL to 0 if only TOTP is set", func(t *testing.T) {
t.Run("case=should set AAL to 2 if only TOTP is set", func(t *testing.T) {
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = newTraits(email, "")
@ -418,8 +455,8 @@ func TestManager(t *testing.T) {
},
}
require.NoError(t, reg.IdentityManager().Update(ctx, original, identity.ManagerAllowWriteProtectedTraits))
assert.True(t, original.AvailableAAL.Valid)
assert.EqualValues(t, identity.NoAuthenticatorAssuranceLevel, original.AvailableAAL.String)
assert.True(t, original.InternalAvailableAAL.Valid)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.InternalAvailableAAL.String)
})
t.Run("case=should not update protected traits without option", func(t *testing.T) {
@ -618,6 +655,51 @@ func TestManager(t *testing.T) {
})
})
t.Run("method=RefreshAvailableAAL", func(t *testing.T) {
var cases []struct {
Credentials []identity.Credentials `json:"credentials"`
Description string `json:"description"`
Expected string `json:"expected"`
}
require.NoError(t, json.Unmarshal(refreshAALStubs, &cases))
for k, tc := range cases {
t.Run("case="+tc.Description, func(t *testing.T) {
email := x.NewUUID().String() + "@ory.sh"
id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
id.Traits = identity.Traits(`{"email":"` + email + `"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, id))
assert.EqualValues(t, identity.NoAuthenticatorAssuranceLevel, id.InternalAvailableAAL.String)
for _, c := range tc.Credentials {
for k := range c.Identifiers {
switch c.Identifiers[k] {
case "{email}":
c.Identifiers[k] = email
case "{id}":
c.Identifiers[k] = id.ID.String()
}
}
id.SetCredentials(c.Type, c)
}
// We use the privileged pool here because we don't want to refresh AAL here but in the code below.
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(ctx, id))
expand := identity.ExpandNothing
if k%2 == 1 { // expand every other test case to test if RefreshAvailableAAL behaves correctly
expand = identity.ExpandCredentials
}
actual, err := reg.IdentityPool().GetIdentity(ctx, id.ID, expand)
require.NoError(t, err)
require.NoError(t, reg.IdentityManager().RefreshAvailableAAL(ctx, actual))
assert.NotEmpty(t, actual.Credentials)
assert.EqualValues(t, tc.Expected, actual.InternalAvailableAAL.String)
})
}
})
t.Run("method=ConflictingIdentity", func(t *testing.T) {
ctx := ctx

View File

@ -78,7 +78,13 @@ type (
// UpdateIdentity updates an identity including its confidential / privileged / protected data.
UpdateIdentity(context.Context, *Identity) error
// GetIdentityConfidential returns the identity including it's raw credentials. This should only be used internally.
// UpdateIdentityColumns updates targeted columns of an identity.
UpdateIdentityColumns(ctx context.Context, i *Identity, columns ...string) error
// GetIdentityConfidential returns the identity including it's raw credentials.
//
// This should only be used internally. Please be aware that this method uses HydrateIdentityAssociations
// internally, which must not be executed as part of a transaction.
GetIdentityConfidential(context.Context, uuid.UUID) (*Identity, error)
// ListVerifiableAddresses lists all tracked verifiable addresses, regardless of whether they are already verified
@ -89,6 +95,9 @@ type (
ListRecoveryAddresses(ctx context.Context, page, itemsPerPage int) ([]RecoveryAddress, error)
// HydrateIdentityAssociations hydrates the associations of an identity.
//
// Please be aware that this method must not be called within a transaction if more than one element is expanded.
// It may error with "conn busy" otherwise.
HydrateIdentityAssociations(ctx context.Context, i *Identity, expandables Expandables) error
// InjectTraitsSchemaURL sets the identity's traits JSON schema URL from the schema's ID.

76
identity/stub/aal.json Normal file
View File

@ -0,0 +1,76 @@
[
{
"description": "password is available aal1",
"expected": "aal1",
"credentials": [
{
"type": "password",
"identifiers": [
"{email}"
],
"config": {
"hashed_password": "$2a$fake"
}
}
]
},
{
"description": "password without identifier is no credential and ergo aal0",
"expected": "aal0",
"credentials": [
{
"type": "password",
"config": {
"hashed_password": "$2a$fake"
}
}
]
},
{
"description": "second factor totp returns available aal2 even if no password is set",
"expected": "aal2",
"credentials": [
{
"type": "totp",
"config": {
"totp_url": "totp://"
}
}
]
},
{
"description": "second factor totp returns aal0 if totp credentials is not set up",
"expected": "aal0",
"credentials": [
{
"type": "totp",
"identifiers": [
"{email}"
],
"config": {}
}
]
},
{
"description": "password and totp is also available aal2",
"expected": "aal1",
"credentials": [
{
"type": "password",
"identifiers": [
"{email}"
],
"config": {
"hashed_password": "$2a$fake"
}
},
{
"type": "totp",
"identifiers": [
"{email}"
],
"config": {}
}
]
}
]

View File

@ -0,0 +1,59 @@
{
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"code": {
"identifier": true,
"via": "email"
}
}
}
},
"email2": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"code": {
"identifier": true,
"via": "email"
}
}
}
},
"phone": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"code": {
"identifier": true,
"via": "sms"
}
}
}
}
}
}

View File

@ -316,14 +316,14 @@ func TestPool(ctx context.Context, p persistence.Persister, m *identity.Manager,
t.Run("case=create with null AAL", func(t *testing.T) {
expected := passwordIdentity("", "id-"+uuid.Must(uuid.NewV4()).String())
expected.AvailableAAL.Valid = false
expected.InternalAvailableAAL.Valid = false
require.NoError(t, p.CreateIdentity(ctx, expected))
createdIDs = append(createdIDs, expected.ID)
actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault)
require.NoError(t, err)
assert.False(t, actual.AvailableAAL.Valid)
assert.False(t, actual.InternalAvailableAAL.Valid)
})
t.Run("suite=create multiple identities", func(t *testing.T) {
@ -549,6 +549,22 @@ func TestPool(ctx context.Context, p persistence.Persister, m *identity.Manager,
require.Contains(t, err.Error(), "malformed")
})
t.Run("case=update an identity column", func(t *testing.T) {
initial := oidcIdentity("", x.NewUUID().String())
initial.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.NoAuthenticatorAssuranceLevel)
require.NoError(t, p.CreateIdentity(ctx, initial))
createdIDs = append(createdIDs, initial.ID)
initial.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.AuthenticatorAssuranceLevel1)
initial.State = identity.StateInactive
require.NoError(t, p.UpdateIdentityColumns(ctx, initial, "available_aal"))
actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault)
require.NoError(t, err)
assert.Equal(t, string(identity.AuthenticatorAssuranceLevel1), actual.InternalAvailableAAL.String)
assert.Equal(t, identity.StateActive, actual.State, "the state remains unchanged")
})
t.Run("case=should fail to insert identity because credentials from traits exist", func(t *testing.T) {
first := passwordIdentity("", "test-identity@ory.sh")
first.Traits = identity.Traits(`{}`)

View File

@ -4,6 +4,7 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -31,7 +31,7 @@ func CourierExpectMessage(ctx context.Context, t *testing.T, reg interface {
})
for _, m := range messages {
if strings.EqualFold(m.Recipient, recipient) && strings.EqualFold(m.Subject, subject) {
if strings.EqualFold(m.Recipient, recipient) && (strings.EqualFold(m.Subject, subject) || strings.Contains(m.Body, subject)) {
return &m
}
}

View File

@ -5,6 +5,7 @@ package testhelpers
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
@ -26,6 +27,7 @@ import (
type mockDeps interface {
identity.PrivilegedPoolProvider
identity.ManagementProvider
session.ManagementProvider
session.PersistenceProvider
config.Provider
@ -34,15 +36,24 @@ type mockDeps interface {
func MockSetSession(t *testing.T, reg mockDeps, conf *config.Config) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
i.NID = uuid.Must(uuid.NewV4())
require.NoError(t, i.SetCredentialsWithConfig(
identity.CredentialsTypePassword,
identity.Credentials{
Type: identity.CredentialsTypePassword,
Identifiers: []string{faker.Email()},
},
json.RawMessage(`{"hashed_password":"$"}`)))
require.NoError(t, reg.IdentityManager().Create(context.Background(), i))
MockSetSessionWithIdentity(t, reg, conf, i)(w, r, ps)
}
}
func MockSetSessionWithIdentity(t *testing.T, reg mockDeps, conf *config.Config, i *identity.Identity) httprouter.Handle {
func MockSetSessionWithIdentity(t *testing.T, reg mockDeps, _ *config.Config, i *identity.Identity) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
activeSession, _ := session.NewActiveSession(r, i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
activeSession, err := NewActiveSession(r, reg, i, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
if aal := r.URL.Query().Get("set_aal"); len(aal) > 0 {
activeSession.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel(aal)
}
@ -52,18 +63,6 @@ func MockSetSessionWithIdentity(t *testing.T, reg mockDeps, conf *config.Config,
}
}
func MockGetSession(t *testing.T, reg mockDeps) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
_, err := reg.SessionManager().FetchFromRequest(r.Context(), r)
if r.URL.Query().Get("has") == "yes" {
require.NoError(t, err)
} else {
require.Error(t, err)
}
w.WriteHeader(http.StatusNoContent)
}
}
func MockMakeAuthenticatedRequest(t *testing.T, reg mockDeps, conf *config.Config, router *httprouter.Router, req *http.Request) ([]byte, *http.Response) {
return MockMakeAuthenticatedRequestWithClient(t, reg, conf, router, req, NewClientWithCookies(t))
}

View File

@ -19,7 +19,7 @@ func CreateSession(t *testing.T, reg driver.Registry) *session.Session {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(req.Context(), i))
sess, err := session.NewActiveSession(req, i, reg.Config(), time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess, err := NewActiveSession(req, reg, i, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(req.Context(), sess))
return sess

View File

@ -11,6 +11,8 @@ import (
"net/url"
"testing"
"github.com/gofrs/uuid"
"github.com/go-faker/faker/v4"
"github.com/gobuffalo/httptest"
"github.com/stretchr/testify/assert"
@ -90,6 +92,7 @@ func SelfServiceHookFakeIdentity(t *testing.T) *identity.Identity {
require.NoError(t, faker.FakeData(&i))
i.Traits = identity.Traits(`{}`)
i.State = identity.StateActive
i.NID = uuid.Must(uuid.NewV4())
return &i
}

View File

@ -10,6 +10,8 @@ import (
"testing"
"time"
confighelpers "github.com/ory/kratos/driver/config/testhelpers"
"github.com/ory/nosurf"
"github.com/stretchr/testify/assert"
@ -46,7 +48,7 @@ func maybePersistSession(t *testing.T, ctx context.Context, reg *driver.Registry
id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, sess.Identity.ID)
if err != nil {
require.NoError(t, sess.Identity.SetAvailableAAL(ctx, reg.IdentityManager()))
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, sess.Identity))
require.NoError(t, reg.IdentityManager().Create(ctx, sess.Identity))
id, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, sess.Identity.ID)
require.NoError(t, err)
}
@ -145,10 +147,9 @@ func NewHTTPClientWithArbitrarySessionToken(t *testing.T, ctx context.Context, r
}
func NewHTTPClientWithArbitrarySessionTokenAndTraits(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, traits identity.Traits) *http.Client {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s, err := session.NewActiveSession(req,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive, Traits: traits},
NewSessionLifespanProvider(time.Hour),
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil).WithContext(confighelpers.WithConfigValue(ctx, "session.lifespan", time.Hour))
s, err := NewActiveSession(req, reg,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive, Traits: traits, NID: x.NewUUID(), SchemaID: "default"},
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -160,9 +161,12 @@ func NewHTTPClientWithArbitrarySessionTokenAndTraits(t *testing.T, ctx context.C
func NewHTTPClientWithArbitrarySessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault) *http.Client {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s, err := session.NewActiveSession(req,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive, Traits: []byte("{}")},
NewSessionLifespanProvider(time.Hour),
req.WithContext(confighelpers.WithConfigValue(ctx, "session.lifespan", time.Hour))
id := x.NewUUID()
s, err := NewActiveSession(req, reg,
&identity.Identity{ID: id, State: identity.StateActive, Traits: []byte("{}"), Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: "password", Identifiers: []string{id.String()}, Config: []byte(`{"hashed_password":"$2a$04$zvZz1zV"}`)},
}},
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -174,9 +178,13 @@ func NewHTTPClientWithArbitrarySessionCookie(t *testing.T, ctx context.Context,
func NewNoRedirectHTTPClientWithArbitrarySessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault) *http.Client {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s, err := session.NewActiveSession(req,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive},
NewSessionLifespanProvider(time.Hour),
req.WithContext(confighelpers.WithConfigValue(ctx, "session.lifespan", time.Hour))
id := x.NewUUID()
s, err := NewActiveSession(req, reg,
&identity.Identity{ID: id, State: identity.StateActive,
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: "password", Identifiers: []string{id.String()}, Config: []byte(`{"hashed_password":"$2a$04$zvZz1zV"}`)},
}},
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -188,9 +196,9 @@ func NewNoRedirectHTTPClientWithArbitrarySessionCookie(t *testing.T, ctx context
func NewHTTPClientWithIdentitySessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, id *identity.Identity) *http.Client {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s, err := session.NewActiveSession(req,
req.WithContext(confighelpers.WithConfigValue(ctx, "session.lifespan", time.Hour))
s, err := NewActiveSession(req, reg,
id,
NewSessionLifespanProvider(time.Hour),
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -202,9 +210,8 @@ func NewHTTPClientWithIdentitySessionCookie(t *testing.T, ctx context.Context, r
func NewHTTPClientWithIdentitySessionCookieLocalhost(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, id *identity.Identity) *http.Client {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s, err := session.NewActiveSession(req,
s, err := NewActiveSession(req, reg,
id,
NewSessionLifespanProvider(time.Hour),
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -216,9 +223,9 @@ func NewHTTPClientWithIdentitySessionCookieLocalhost(t *testing.T, ctx context.C
func NewHTTPClientWithIdentitySessionToken(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, id *identity.Identity) *http.Client {
req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s, err := session.NewActiveSession(req,
req.WithContext(confighelpers.WithConfigValue(ctx, "session.lifespan", time.Hour))
s, err := NewActiveSession(req, reg,
id,
NewSessionLifespanProvider(time.Hour),
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,

View File

@ -0,0 +1,23 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package testhelpers
import (
"net/http"
"time"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/session"
)
func NewActiveSession(r *http.Request, reg interface {
session.ManagementProvider
}, i *identity.Identity, authenticatedAt time.Time, completedLoginFor identity.CredentialsType, completedLoginAAL identity.AuthenticatorAssuranceLevel) (*session.Session, error) {
s := session.NewInactiveSession()
s.CompletedLoginFor(completedLoginFor, completedLoginAAL)
if err := reg.SessionManager().ActivateSession(r, s, i, authenticatedAt); err != nil {
return nil, err
}
return s, nil
}

View File

@ -7,6 +7,8 @@ import (
"context"
"time"
"github.com/ory/kratos/x"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/x/networkx"
@ -63,7 +65,7 @@ type Persister interface {
Migrator() *popx.Migrator
MigrationBox() *popx.MigrationBox
GetConnection(ctx context.Context) *pop.Connection
Transaction(ctx context.Context, callback func(ctx context.Context, connection *pop.Connection) error) error
x.TransactionalPersister
Networker
}

View File

@ -949,6 +949,19 @@ func (p *IdentityPersister) ListIdentities(ctx context.Context, params identity.
return is, nextPage, nil
}
func (p *IdentityPersister) UpdateIdentityColumns(ctx context.Context, i *identity.Identity, columns ...string) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateIdentity",
trace.WithAttributes(
attribute.Stringer("identity.id", i.ID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
return p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error {
_, err := tx.Where("id = ? AND nid = ?", i.ID, p.NetworkID(ctx)).UpdateQuery(i, columns...)
return sqlcon.HandleError(err)
})
}
func (p *IdentityPersister) UpdateIdentity(ctx context.Context, i *identity.Identity) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateIdentity",
trace.WithAttributes(

View File

@ -9,6 +9,8 @@ import (
"strconv"
"time"
"github.com/ory/x/otelx"
"github.com/gofrs/uuid"
"github.com/julienschmidt/httprouter"
"github.com/pkg/errors"
@ -54,6 +56,7 @@ type (
x.WriterProvider
x.CSRFTokenGeneratorProvider
x.CSRFProvider
x.TracingProvider
config.Provider
ErrorHandlerProvider
sessiontokenexchange.PersistenceProvider
@ -330,6 +333,9 @@ type createNativeLoginFlow struct {
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
//
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
// of MFA credentials to choose from to perform the second factor instead.
//
// in: query
Via string `json:"via"`
}
@ -369,6 +375,11 @@ type createNativeLoginFlow struct {
// 400: errorGeneric
// default: errorGeneric
func (h *Handler) createNativeLoginFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createNativeLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
f, _, err := h.NewLoginFlow(w, r, flow.TypeAPI)
if err != nil {
h.d.Writer().WriteError(w, r, err)
@ -437,6 +448,9 @@ type createBrowserLoginFlow struct {
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
//
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
// of MFA credentials to choose from to perform the second factor instead.
//
// in: query
Via string `json:"via"`
}
@ -480,6 +494,11 @@ type createBrowserLoginFlow struct {
// 400: errorGeneric
// default: errorGeneric
func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createBrowserLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
var (
hydraLoginRequest *hydraclientgo.OAuth2LoginRequest
hydraLoginChallenge sqlxx.NullString
@ -488,13 +507,13 @@ func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request,
var err error
hydraLoginChallenge, err = hydra.GetLoginChallengeID(h.d.Config(), r)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
hydraLoginRequest, err = h.d.Hydra().GetLoginRequest(r.Context(), string(hydraLoginChallenge))
hydraLoginRequest, err = h.d.Hydra().GetLoginRequest(ctx, string(hydraLoginChallenge))
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
@ -510,7 +529,7 @@ func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request,
// different flows, such as login to registration and login to recovery.
// After completing a complex flow, such as recovery, we want the user
// to be redirected back to the original OAuth2 login flow.
if hydraLoginRequest.RequestUrl != "" && h.d.Config().OAuth2ProviderOverrideReturnTo(r.Context()) {
if hydraLoginRequest.RequestUrl != "" && h.d.Config().OAuth2ProviderOverrideReturnTo(ctx) {
// replace the return_to query parameter
q := r.URL.Query()
q.Set("return_to", hydraLoginRequest.RequestUrl)
@ -522,11 +541,11 @@ func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request,
if errors.Is(err, ErrAlreadyLoggedIn) {
if hydraLoginRequest != nil {
if !hydraLoginRequest.GetSkip() {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrInternalServerError.WithReason("ErrAlreadyLoggedIn indicated we can skip login, but Hydra asked us to refresh")))
h.d.SelfServiceErrorManager().Forward(ctx, w, r, errors.WithStack(herodot.ErrInternalServerError.WithReason("ErrAlreadyLoggedIn indicated we can skip login, but Hydra asked us to refresh")))
return
}
rt, err := h.d.Hydra().AcceptLoginRequest(r.Context(),
rt, err := h.d.Hydra().AcceptLoginRequest(ctx,
hydra.AcceptLoginRequestParams{
LoginChallenge: string(hydraLoginChallenge),
IdentityID: sess.IdentityID.String(),
@ -534,37 +553,37 @@ func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request,
AuthenticationMethods: sess.AMR,
})
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
returnTo, err := url.Parse(rt)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to parse URL: %s", rt)))
h.d.SelfServiceErrorManager().Forward(ctx, w, r, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to parse URL: %s", rt)))
return
}
x.AcceptToRedirectOrJSON(w, r, h.d.Writer(), err, returnTo.String())
return
}
returnTo, redirErr := x.SecureRedirectTo(r, h.d.Config().SelfServiceBrowserDefaultReturnTo(r.Context()),
x.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(r.Context())),
x.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(r.Context())),
returnTo, redirErr := x.SecureRedirectTo(r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx),
x.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(ctx)),
x.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
)
if redirErr != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, redirErr)
h.d.SelfServiceErrorManager().Forward(ctx, w, r, redirErr)
return
}
x.AcceptToRedirectOrJSON(w, r, h.d.Writer(), err, returnTo.String())
return
} else if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
a.HydraLoginRequest = hydraLoginRequest
x.AcceptToRedirectOrJSON(w, r, h.d.Writer(), a, a.AppendTo(h.d.Config().SelfServiceFlowLoginUI(r.Context())).String())
x.AcceptToRedirectOrJSON(w, r, h.d.Writer(), a, a.AppendTo(h.d.Config().SelfServiceFlowLoginUI(ctx)).String())
}
// Get Login Flow Parameters
@ -633,7 +652,12 @@ type getLoginFlow struct {
// 410: errorGeneric
// default: errorGeneric
func (h *Handler) getLoginFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ar, err := h.d.LoginFlowPersister().GetLoginFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("id")))
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.getLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
ar, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, x.ParseUUID(r.URL.Query().Get("id")))
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
@ -649,7 +673,7 @@ func (h *Handler) getLoginFlow(w http.ResponseWriter, r *http.Request, _ httprou
if ar.ExpiresAt.Before(time.Now()) {
if ar.Type == flow.TypeBrowser {
redirectURL := flow.GetFlowExpiredRedirectURL(r.Context(), h.d.Config(), RouteInitBrowserFlow, ar.ReturnTo)
redirectURL := flow.GetFlowExpiredRedirectURL(ctx, h.d.Config(), RouteInitBrowserFlow, ar.ReturnTo)
h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone.WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The login flow has expired. Redirect the user to the login flow init endpoint to initialize a new login flow.").
@ -659,16 +683,16 @@ func (h *Handler) getLoginFlow(w http.ResponseWriter, r *http.Request, _ httprou
}
h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone.WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The login flow has expired. Call the login flow init API endpoint to initialize a new login flow.").
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String())))
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(ctx), RouteInitAPIFlow).String())))
return
}
if ar.OAuth2LoginChallenge != "" {
hlr, err := h.d.Hydra().GetLoginRequest(r.Context(), string(ar.OAuth2LoginChallenge))
hlr, err := h.d.Hydra().GetLoginRequest(ctx, string(ar.OAuth2LoginChallenge))
if err != nil {
// We don't redirect back to the third party on errors because Hydra doesn't
// give us the 3rd party return_uri when it redirects to the login UI.
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
ar.HydraLoginRequest = hlr
@ -770,19 +794,24 @@ type updateLoginFlowBody struct{}
// 422: errorBrowserLocationChangeRequired
// default: errorGeneric
func (h *Handler) updateLoginFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.updateLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
rid, err := flow.GetFlowID(r)
if err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
return
}
f, err := h.d.LoginFlowPersister().GetLoginFlow(r.Context(), rid)
f, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, rid)
if err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
return
}
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r)
sess, err := h.d.SessionManager().FetchFromRequest(ctx, r)
if err == nil {
if f.Refresh {
// If we want to refresh, continue the login
@ -800,7 +829,7 @@ func (h *Handler) updateLoginFlow(w http.ResponseWriter, r *http.Request, _ http
return
}
http.Redirect(w, r, h.d.Config().SelfServiceBrowserDefaultReturnTo(r.Context()).String(), http.StatusSeeOther)
http.Redirect(w, r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx).String(), http.StatusSeeOther)
return
} else if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
// Only failure scenario here is if we try to upgrade the session to a higher AAL without actually
@ -842,7 +871,7 @@ continueLogin:
sess = session.NewInactiveSession()
}
method := ss.CompletedAuthenticationMethod(r.Context(), sess.AMR)
method := ss.CompletedAuthenticationMethod(ctx)
sess.CompletedLoginForMethod(method)
i = interim
break

View File

@ -21,12 +21,11 @@ import (
"github.com/ory/x/sqlxx"
stdtotp "github.com/pquerna/otp/totp"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/strategy/totp"
"github.com/ory/kratos/session"
stdtotp "github.com/pquerna/otp/totp"
"github.com/ory/kratos/ui/container"
@ -458,7 +457,7 @@ func TestFlowLifecycle(t *testing.T) {
require.NoError(t, reg.IdentityManager().Update(context.Background(), id, identity.ManagerAllowWriteProtectedTraits))
h := func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
sess, err := session.NewActiveSession(r, id, reg.Config(), time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess, err := testhelpers.NewActiveSession(r, reg, id, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
sess.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))

View File

@ -48,6 +48,7 @@ type (
config.Provider
hydra.Provider
identity.PrivilegedPoolProvider
identity.ManagementProvider
session.ManagementProvider
session.PersistenceProvider
x.CSRFTokenGeneratorProvider
@ -136,7 +137,7 @@ func (e *HookExecutor) PostLoginHook(
return err
}
if err := s.Activate(r, i, e.d.Config(), time.Now().UTC()); err != nil {
if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil {
return err
}
@ -370,7 +371,7 @@ func (e *HookExecutor) maybeLinkCredentials(ctx context.Context, sess *session.S
return err
}
method := strategy.CompletedAuthenticationMethod(ctx, sess.AMR)
method := strategy.CompletedAuthenticationMethod(ctx)
sess.CompletedLoginForMethod(method)
return nil

View File

@ -21,7 +21,7 @@ type Strategy interface {
NodeGroup() node.UiNodeGroup
RegisterLoginRoutes(*x.RouterPublic)
Login(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (i *identity.Identity, err error)
CompletedAuthenticationMethod(ctx context.Context, methods session.AuthenticationMethods) session.AuthenticationMethod
CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod
}
type Strategies []Strategy

View File

@ -81,10 +81,14 @@ func NewHookExecutor(d executorDependencies) *HookExecutor {
}
func (e *HookExecutor) PostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
e.d.Logger().
WithRequest(r).
WithField("identity_id", s.Identity.ID).
Debug("Running ExecutePostRecoveryHooks.")
logger := e.d.Logger().
WithRequest(r)
if s.Identity != nil {
logger = logger.WithField("identity_id", s.Identity.ID)
}
logger.Debug("Running ExecutePostRecoveryHooks.")
for k, executor := range e.d.PostRecoveryHooks(r.Context()) {
if err := executor.ExecutePostRecoveryHook(w, r, a, s); err != nil {
var traits identity.Traits
@ -94,20 +98,16 @@ func (e *HookExecutor) PostRecoveryHook(w http.ResponseWriter, r *http.Request,
return flow.HandleHookError(w, r, a, traits, node.LinkGroup, err, e.d, e.d)
}
e.d.Logger().WithRequest(r).
logger.
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookRecoveryExecutorNames(e.d.PostRecoveryHooks(r.Context()))).
WithField("identity_id", s.Identity.ID).
Debug("ExecutePostRecoveryHook completed successfully.")
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoverySucceeded(r.Context(), s.Identity.ID, string(a.Type), a.Active.String()))
e.d.Logger().
WithRequest(r).
WithField("identity_id", s.Identity.ID).
Debug("Post recovery execution hooks completed successfully.")
logger.Debug("Post recovery execution hooks completed successfully.")
return nil
}

View File

@ -10,8 +10,6 @@ import (
"testing"
"time"
"github.com/ory/kratos/session"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/strategy/code"
@ -31,6 +29,7 @@ import (
func TestRecoveryExecutor(t *testing.T) {
ctx := context.Background()
conf, reg := internal.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
s := code.NewStrategy(reg)
newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
@ -46,13 +45,14 @@ func TestRecoveryExecutor(t *testing.T) {
router.GET("/recovery/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
a, err := recovery.NewFlow(conf, time.Minute, x.FakeCSRFToken, r, s, ft)
require.NoError(t, err)
s, _ := session.NewActiveSession(r,
s, err := testhelpers.NewActiveSession(r,
reg,
i,
conf,
time.Now().UTC(),
identity.CredentialsTypeRecoveryLink,
identity.AuthenticatorAssuranceLevel1,
)
require.NoError(t, err)
a.RequestURL = x.RequestURL(r).String()
if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PostRecoveryHook(w, r, a, s)) {
_, _ = w.Write([]byte("ok"))

View File

@ -214,7 +214,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque
s.CompletedLoginForWithProvider(ct, identity.AuthenticatorAssuranceLevel1, provider,
httprouter.ParamsFromContext(r.Context()).ByName("organization"))
if err := s.Activate(r, i, c, time.Now().UTC()); err != nil {
if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil {
return err
}

View File

@ -11,6 +11,8 @@ import (
"testing"
"time"
confighelpers "github.com/ory/kratos/driver/config/testhelpers"
"github.com/pkg/errors"
"github.com/gofrs/uuid"
@ -149,9 +151,10 @@ func TestHandleError(t *testing.T) {
t.Cleanup(reset)
req := httptest.NewRequest("GET", "/sessions/whoami", nil)
req.WithContext(confighelpers.WithConfigValue(ctx, config.ViperKeySessionLifespan, time.Hour))
// This needs an authenticated client in order to call the RouteGetFlow endpoint
s, err := session.NewActiveSession(req, &id, testhelpers.NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
s, err := testhelpers.NewActiveSession(req, reg, &id, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
c := testhelpers.NewHTTPClientWithSessionToken(t, ctx, reg, s)

View File

@ -24,7 +24,6 @@ import (
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
)
@ -54,7 +53,7 @@ func TestSettingsExecutor(t *testing.T) {
if i == nil {
i = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
}
sess, _ := session.NewActiveSession(r, i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess, _ := testhelpers.NewActiveSession(r, reg, i, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
f, err := settings.NewFlow(conf, time.Minute, r, sess.Identity, ft)
require.NoError(t, err)
@ -67,7 +66,7 @@ func TestSettingsExecutor(t *testing.T) {
if i == nil {
i = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
}
sess, _ := session.NewActiveSession(r, i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess, _ := testhelpers.NewActiveSession(r, reg, i, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
a, err := settings.NewFlow(conf, time.Minute, r, sess.Identity, ft)
require.NoError(t, err)

View File

@ -17,7 +17,6 @@ import (
"github.com/ory/kratos/internal/testhelpers"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flowhelpers"
"github.com/ory/kratos/session"
)
func TestGuessForcedLoginIdentifier(t *testing.T) {
@ -34,9 +33,9 @@ func TestGuessForcedLoginIdentifier(t *testing.T) {
req := httptest.NewRequest("GET", "/sessions/whoami", nil)
sess, err := session.NewActiveSession(req, i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess, err := testhelpers.NewActiveSession(req, reg, i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
reg.SessionPersister().UpsertSession(context.Background(), sess)
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))
r := httptest.NewRequest("GET", "/login", nil)
r.Header.Set("Authorization", "Bearer "+sess.Token)

View File

@ -40,7 +40,7 @@ func TestCodeAddressVerifier(t *testing.T) {
_, err := reg.RegistrationCodePersister().CreateRegistrationCode(ctx, &code.CreateRegistrationCodeParams{
Address: address,
AddressType: identity.CodeAddressTypeEmail,
AddressType: identity.AddressTypeEmail,
RawCode: rawCode,
ExpiresIn: time.Hour,
FlowID: rf.ID,

View File

@ -15,6 +15,9 @@
"identifier": {
"type": "string"
},
"address": {
"type": "string"
},
"resend": {
"type": "string",
"enum": [

View File

@ -1,21 +0,0 @@
[
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010015,
"text": "Send sign in code",
"type": "info"
}
}
}
]

View File

@ -1,66 +0,0 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [
{
"id": 1010020,
"text": "We will send a code to fo****@ory.sh. To verify that this is your address please enter it here.",
"type": "info",
"context": {
"masked_to": "fo****@ory.sh"
}
}
],
"meta": {
"label": {
"id": 1070002,
"text": "",
"type": "info",
"context": {
"title": ""
}
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010019,
"text": "Continue with code",
"type": "info"
}
}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@ -1,66 +0,0 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [
{
"id": 1010020,
"text": "We will send a code to fo****@ory.sh. To verify that this is your address please enter it here.",
"type": "info",
"context": {
"masked_to": "fo****@ory.sh"
}
}
],
"meta": {
"label": {
"id": 1070002,
"text": "",
"type": "info",
"context": {
"title": ""
}
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010019,
"text": "Continue with code",
"type": "info"
}
}
},
{
"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,38 @@
[
{
"type": "input",
"group": "code",
"attributes": {
"name": "address",
"type": "submit",
"value": "populateloginmethodsecondfactor-code-mfa-via-2fa@ory.sh",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010023,
"text": "Send code to populateloginmethodsecondfactor-code-mfa-via-2fa@ory.sh",
"type": "info",
"context": {
"address": "populateloginmethodsecondfactor-code-mfa-via-2fa@ory.sh",
"channel": "email"
}
}
}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@ -0,0 +1,84 @@
[
{
"type": "input",
"group": "code",
"attributes": {
"name": "address",
"type": "submit",
"value": "+4917655138291",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010023,
"text": "Send code to +4917655138291",
"type": "info",
"context": {
"address": "+4917655138291",
"channel": "sms"
}
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "address",
"type": "submit",
"value": "populateloginmethodsecondfactor-no-via-2fa-0@ory.sh",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010023,
"text": "Send code to populateloginmethodsecondfactor-no-via-2fa-0@ory.sh",
"type": "info",
"context": {
"address": "populateloginmethodsecondfactor-no-via-2fa-0@ory.sh",
"channel": "email"
}
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "address",
"type": "submit",
"value": "populateloginmethodsecondfactor-no-via-2fa-1@ory.sh",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010023,
"text": "Send code to populateloginmethodsecondfactor-no-via-2fa-1@ory.sh",
"type": "info",
"context": {
"address": "populateloginmethodsecondfactor-no-via-2fa-1@ory.sh",
"channel": "email"
}
}
}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@ -1,55 +1,4 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [
{
"id": 1010020,
"text": "We will send a code to fo****@ory.sh. To verify that this is your address please enter it here.",
"type": "info",
"context": {
"masked_to": "fo****@ory.sh"
}
}
],
"meta": {
"label": {
"id": 1070002,
"text": "",
"type": "info",
"context": {
"title": ""
}
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010019,
"text": "Continue with code",
"type": "info"
}
}
},
{
"type": "input",
"group": "default",

View File

@ -30,49 +30,21 @@
{
"attributes": {
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [
{
"context": {
"masked_to": "fi****@ory.sh"
},
"id": 1010020,
"text": "We will send a code to fi****@ory.sh. To verify that this is your address please enter it here.",
"type": "info"
}
],
"meta": {
"label": {
"context": {
"title": "Email"
},
"id": 1070002,
"text": "Email",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code"
"value": "fixed_mfa_test_browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"id": 1010019,
"text": "Continue with code",
"context": {
"address": "fixed_mfa_test_browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to fixed_mfa_test_browser@ory.sh",
"type": "info"
}
},

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213110"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213110",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213110",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1browser@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2browser@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213110"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213110",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213110",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1browser@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2browser@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213110"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213110",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213110",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1browser@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2browser@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2browser@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2browser@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -17,48 +17,20 @@
{
"attributes": {
"disabled": false,
"name": "identifier",
"name": "address",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [
{
"context": {
"masked_to": "fi****@ory.sh"
},
"id": 1010020,
"text": "We will send a code to fi****@ory.sh. To verify that this is your address please enter it here.",
"type": "info"
}
],
"meta": {
"label": {
"context": {
"title": "Email"
},
"id": 1070002,
"text": "Email",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "code"
"type": "submit"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"id": 1010019,
"text": "Continue with code",
"context": {
"address": "fixed_mfa_test_api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to fixed_mfa_test_api@ory.sh",
"type": "info"
}
},

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213111"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213111",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213111",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1api@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1api@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2api@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2api@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213111"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213111",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213111",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1api@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1api@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2api@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2api@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213111"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213111",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213111",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1api@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1api@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2api@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2api@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2api@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -30,49 +30,21 @@
{
"attributes": {
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [
{
"context": {
"masked_to": "fi****@ory.sh"
},
"id": 1010020,
"text": "We will send a code to fi****@ory.sh. To verify that this is your address please enter it here.",
"type": "info"
}
],
"meta": {
"label": {
"context": {
"title": "Email"
},
"id": 1070002,
"text": "Email",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code"
"value": "fixed_mfa_test_spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"id": 1010019,
"text": "Continue with code",
"context": {
"address": "fixed_mfa_test_spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to fixed_mfa_test_spa@ory.sh",
"type": "info"
}
},

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213112"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213112",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213112",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1spa@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2spa@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213112"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213112",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213112",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1spa@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2spa@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -0,0 +1,71 @@
[
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "+4917613213112"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "+4917613213112",
"channel": "sms"
},
"id": 1010023,
"text": "Send code to +4917613213112",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-1spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-1spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-1spa@ory.sh",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "address",
"node_type": "input",
"type": "submit",
"value": "code-mfa-2spa@ory.sh"
},
"group": "code",
"messages": [],
"meta": {
"label": {
"context": {
"address": "code-mfa-2spa@ory.sh",
"channel": "email"
},
"id": 1010023,
"text": "Send code to code-mfa-2spa@ory.sh",
"type": "info"
}
},
"type": "input"
}
]

View File

@ -32,7 +32,7 @@ type LoginCode struct {
// AddressType represents the type of the address
// this can be an email address or a phone number.
AddressType identity.CodeAddressType `json:"-" db:"address_type"`
AddressType identity.CodeChannel `json:"-" db:"address_type"`
// CodeHMAC represents the HMACed value of the verification code
CodeHMAC string `json:"-" db:"code"`
@ -94,7 +94,7 @@ type CreateLoginCodeParams struct {
// AddressType is the type of the address (email or phone number).
// required: true
AddressType identity.CodeAddressType
AddressType identity.CodeChannel
// Code represents the recovery code
// required: true

View File

@ -32,7 +32,7 @@ type RegistrationCode struct {
// AddressType represents the type of the address
// this can be an email address or a phone number.
AddressType identity.CodeAddressType `json:"-" db:"address_type"`
AddressType identity.CodeChannel `json:"-" db:"address_type"`
// CodeHMAC represents the HMACed value of the verification code
CodeHMAC string `json:"-" db:"code"`
@ -93,7 +93,7 @@ type CreateRegistrationCodeParams struct {
// AddressType is the type of the address (email or phone number).
// required: true
AddressType identity.CodeAddressType
AddressType identity.CodeChannel
// Code represents the recovery code
// required: true

View File

@ -54,7 +54,7 @@ type (
}
Address struct {
To string
Via identity.CodeAddressType
Via identity.CodeChannel
}
)
@ -87,7 +87,7 @@ func (s *Sender) SendCode(ctx context.Context, f flow.Flow, id *identity.Identit
code, err := s.deps.
RegistrationCodePersister().
CreateRegistrationCode(ctx, &CreateRegistrationCodeParams{
AddressType: identity.CodeAddressType(address.Via),
AddressType: address.Via,
RawCode: rawCode,
ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx),
FlowID: f.GetID(),
@ -123,7 +123,7 @@ func (s *Sender) SendCode(ctx context.Context, f flow.Flow, id *identity.Identit
code, err := s.deps.
LoginCodePersister().
CreateLoginCode(ctx, &CreateLoginCodeParams{
AddressType: identity.CodeAddressType(address.Via),
AddressType: address.Via,
Address: address.To,
RawCode: rawCode,
ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx),

View File

@ -6,8 +6,11 @@ package code
import (
"context"
"net/http"
"sort"
"strings"
"github.com/samber/lo"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
@ -36,20 +39,21 @@ import (
)
var (
_ recovery.Strategy = new(Strategy)
_ recovery.AdminHandler = new(Strategy)
_ recovery.PublicHandler = new(Strategy)
_ recovery.Strategy = (*Strategy)(nil)
_ recovery.AdminHandler = (*Strategy)(nil)
_ recovery.PublicHandler = (*Strategy)(nil)
)
var (
_ verification.Strategy = new(Strategy)
_ verification.AdminHandler = new(Strategy)
_ verification.PublicHandler = new(Strategy)
_ verification.Strategy = (*Strategy)(nil)
_ verification.AdminHandler = (*Strategy)(nil)
_ verification.PublicHandler = (*Strategy)(nil)
)
var (
_ login.Strategy = new(Strategy)
_ registration.Strategy = new(Strategy)
_ login.Strategy = (*Strategy)(nil)
_ registration.Strategy = (*Strategy)(nil)
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
)
type (
@ -121,6 +125,25 @@ type (
}
)
func (s *Strategy) CountActiveFirstFactorCredentials(ctx context.Context, cc map[identity.CredentialsType]identity.Credentials) (int, error) {
codeConfig := s.deps.Config().SelfServiceCodeStrategy(ctx)
if codeConfig.PasswordlessEnabled {
// Login with code for passwordless is enabled
return 1, nil
}
return 0, nil
}
func (s *Strategy) CountActiveMultiFactorCredentials(ctx context.Context, cc map[identity.CredentialsType]identity.Credentials) (int, error) {
codeConfig := s.deps.Config().SelfServiceCodeStrategy(ctx)
if codeConfig.MFAEnabled {
return 1, nil
}
return 0, nil
}
func NewStrategy(deps any) *Strategy {
return &Strategy{deps: deps.(strategyDependencies), dx: decoderx.NewHTTP()}
}
@ -186,14 +209,16 @@ func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error {
func (s *Strategy) populateChooseMethodFlow(r *http.Request, f flow.Flow) error {
ctx := r.Context()
var codeMetaLabel *text.Message
switch f := f.(type) {
case *recovery.Flow, *verification.Flow:
f.GetUI().Nodes.Append(
node.NewInputField("email", nil, node.CodeGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).
WithMetaLabel(text.NewInfoNodeInputEmail()),
)
codeMetaLabel = text.NewInfoNodeLabelContinue()
f.GetUI().Nodes.Append(
node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoNodeLabelContinue()),
)
case *login.Flow:
ds, err := s.deps.Config().DefaultIdentityTraitsSchemaURL(ctx)
if err != nil {
@ -201,48 +226,80 @@ func (s *Strategy) populateChooseMethodFlow(r *http.Request, f flow.Flow) error
}
if f.RequestedAAL == identity.AuthenticatorAssuranceLevel2 {
via := r.URL.Query().Get("via")
if via == "" {
return errors.WithStack(herodot.ErrBadRequest.WithReason("AAL2 login via code requires the `via` query parameter"))
}
sess, err := s.deps.SessionManager().FetchFromRequest(r.Context(), r)
if err != nil {
return err
}
allSchemas, err := s.deps.IdentityTraitsSchemas(ctx)
if err != nil {
return err
}
iSchema, err := allSchemas.GetByID(sess.Identity.SchemaID)
if err != nil {
return err
}
identifierLabel, err := login.GetIdentifierLabelFromSchemaWithField(ctx, iSchema.RawURL, via)
if err != nil {
return err
// We need to load the identity's credentials.
if len(sess.Identity.Credentials) == 0 {
if err := s.deps.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, sess.Identity, identity.ExpandCredentials); err != nil {
return err
}
}
value := gjson.GetBytes(sess.Identity.Traits, via).String()
if value == "" {
return errors.WithStack(herodot.ErrBadRequest.WithReasonf("No value found for trait %s in the current identity", via))
}
// The via parameter lets us hint at the OTP address to use for 2fa.
if via == "" {
addresses, found, err := FindCodeAddressCandidates(sess.Identity, s.deps.Config().SelfServiceCodeMethodMissingCredentialFallbackEnabled(ctx))
if err != nil {
return err
} else if !found {
return nil
}
codeMetaLabel = text.NewInfoSelfServiceLoginCodeMFA()
idNode := node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel)
idNode.Messages.Add(text.NewInfoSelfServiceLoginCodeMFAHint(MaskAddress(value)))
f.GetUI().Nodes.Upsert(idNode)
sort.SliceStable(addresses, func(i, j int) bool {
return addresses[i].To < addresses[j].To && addresses[i].Via < addresses[j].Via
})
for _, address := range addresses {
f.GetUI().Nodes.Append(node.NewInputField("address", address.To, node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoSelfServiceLoginAAL2CodeAddress(string(address.Via), address.To)))
}
} else {
value := gjson.GetBytes(sess.Identity.Traits, via).String()
if value == "" {
return errors.WithStack(herodot.ErrBadRequest.WithReasonf("No value found for trait %s in the current identity.", via))
}
// TODO Remove this normalization once the via parameter is deprecated.
//
// Here we need to normalize the via parameter to the actual address. This is necessary because otherwise
// we won't find the address in the list of addresses.
//
// Since we don't know if the via parameter is an email address or a phone number, we need to normalize for both.
value = x.GracefulNormalization(value)
addresses, found, err := FindCodeAddressCandidates(sess.Identity, s.deps.Config().SelfServiceCodeMethodMissingCredentialFallbackEnabled(ctx))
if err != nil {
return err
} else if !found {
return nil
}
address, found := lo.Find(addresses, func(item Address) bool {
return item.To == value
})
if !found {
return errors.WithStack(herodot.ErrBadRequest.WithReasonf("You can only reference a trait that matches a verification email address in the via parameter, or a registered credential."))
}
f.GetUI().Nodes.Append(node.NewInputField("address", address.To, node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoSelfServiceLoginAAL2CodeAddress(string(address.Via), address.To)))
}
} else {
codeMetaLabel = text.NewInfoSelfServiceLoginCode()
identifierLabel, err := login.GetIdentifierLabelFromSchema(ctx, ds.String())
if err != nil {
return err
}
f.GetUI().Nodes.Upsert(node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel))
f.GetUI().Nodes.Append(
node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoSelfServiceLoginCode()),
)
}
case *registration.Flow:
codeMetaLabel = text.NewInfoSelfServiceRegistrationRegisterCode()
ds, err := s.deps.Config().DefaultIdentityTraitsSchemaURL(ctx)
if err != nil {
return err
@ -258,13 +315,13 @@ func (s *Strategy) populateChooseMethodFlow(r *http.Request, f flow.Flow) error
for _, n := range traitNodes {
f.GetUI().Nodes.Upsert(n)
}
f.GetUI().Nodes.Append(
node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoSelfServiceRegistrationRegisterCode()),
)
}
methodButton := node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(codeMetaLabel)
f.GetUI().Nodes.Append(methodButton)
return nil
}
@ -300,10 +357,11 @@ func (s *Strategy) populateEmailSentFlow(ctx context.Context, f flow.Flow) error
// preserve the login identifier that was submitted
// so we can retry the code flow with the same data
for _, n := range f.GetUI().Nodes {
if n.ID() == "identifier" {
if n.ID() == "identifier" || n.ID() == "address" {
if input, ok := n.Attributes.(*node.InputAttributes); ok {
input.Type = "hidden"
n.Attributes = input
input.Name = "identifier"
}
freshNodes = append(freshNodes, n)
}

View File

@ -4,12 +4,16 @@
package code
import (
"cmp"
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/strategy/idfirst"
"github.com/ory/kratos/text"
@ -61,6 +65,10 @@ type updateLoginFlowWithCodeMethod struct {
// required: false
Identifier string `json:"identifier" form:"identifier"`
// Address is the address to send the code to, in case that there are multiple addresses. This field
// is only used in two-factor flows and is ineffective for passwordless flows.
Address string `json:"address" form:"address"`
// Resend is set when the user wants to resend the code
// required: false
Resend string `json:"resend" form:"resend"`
@ -73,19 +81,15 @@ type updateLoginFlowWithCodeMethod struct {
func (s *Strategy) RegisterLoginRoutes(*x.RouterPublic) {}
func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context, amr session.AuthenticationMethods) session.AuthenticationMethod {
aal1Satisfied := lo.ContainsBy(amr, func(am session.AuthenticationMethod) bool {
return am.Method != identity.CredentialsTypeCodeAuth && am.AAL == identity.AuthenticatorAssuranceLevel1
})
if aal1Satisfied {
return session.AuthenticationMethod{
Method: identity.CredentialsTypeCodeAuth,
AAL: identity.AuthenticatorAssuranceLevel2,
}
func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod {
aal := identity.AuthenticatorAssuranceLevel1
if s.deps.Config().SelfServiceCodeStrategy(ctx).MFAEnabled {
aal = identity.AuthenticatorAssuranceLevel2
}
return session.AuthenticationMethod{
Method: identity.CredentialsTypeCodeAuth,
AAL: identity.AuthenticatorAssuranceLevel1,
Method: s.ID(),
AAL: aal,
}
}
@ -95,24 +99,16 @@ func (s *Strategy) HandleLoginError(r *http.Request, f *login.Flow, body *update
}
if f != nil {
email := ""
identifier := ""
if body != nil {
email = body.Identifier
identifier = cmp.Or(body.Address, body.Identifier)
}
ds, err := s.deps.Config().DefaultIdentityTraitsSchemaURL(r.Context())
if err != nil {
return err
}
identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String())
if err != nil {
return err
}
f.UI.SetCSRF(s.deps.GenerateCSRFToken(r))
f.UI.GetNodes().Upsert(
node.NewInputField("identifier", email, node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).
WithMetaLabel(identifierLabel),
)
identifierNode := node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden)
identifierNode.Attributes.SetValue(identifier)
f.UI.GetNodes().Upsert(identifierNode)
}
return err
@ -122,52 +118,98 @@ func (s *Strategy) HandleLoginError(r *http.Request, f *login.Flow, body *update
// If the identity does not have a code credential, it will attempt to find
// the identity through other credentials matching the identifier.
// the fallback mechanism is used for migration purposes of old accounts that do not have a code credential.
func (s *Strategy) findIdentityByIdentifier(ctx context.Context, identifier string) (_ *identity.Identity, isFallback bool, err error) {
func (s *Strategy) findIdentityByIdentifier(ctx context.Context, identifier string) (id *identity.Identity, cred *identity.Credentials, isFallback bool, err error) {
ctx, span := s.deps.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.code.strategy.findIdentityByIdentifier")
defer otelx.End(span, &err)
id, cred, err := s.deps.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), identifier)
id, cred, err = s.deps.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), identifier)
if errors.Is(err, sqlcon.ErrNoRows) {
// this is a migration for old identities that do not have a code credential
// we might be able to do a fallback login since we could not find a credential on this identifier
// Case insensitive because we only care about emails.
id, err := s.deps.PrivilegedIdentityPool().FindIdentityByCredentialIdentifier(ctx, identifier, false)
if err != nil {
return nil, false, errors.WithStack(schema.NewNoCodeAuthnCredentials())
return nil, nil, false, errors.WithStack(schema.NewNoCodeAuthnCredentials())
}
// we don't know if the user has verified the code yet, so we just return the identity
// and let the caller decide what to do with it
return id, true, nil
return id, nil, true, nil
} else if err != nil {
return nil, false, errors.WithStack(schema.NewNoCodeAuthnCredentials())
return nil, nil, false, errors.WithStack(schema.NewNoCodeAuthnCredentials())
}
if len(cred.Identifiers) == 0 {
return nil, false, errors.WithStack(schema.NewNoCodeAuthnCredentials())
return nil, nil, false, errors.WithStack(schema.NewNoCodeAuthnCredentials())
}
// we don't need the code credential, we just need to know that it exists
return id, false, nil
return id, cred, false, nil
}
func (s *Strategy) decode(r *http.Request) (*updateLoginFlowWithCodeMethod, error) {
var p updateLoginFlowWithCodeMethod
if err := s.dx.Decode(r, &p,
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.HTTPKeepRequestBody(true),
decoderx.MustHTTPRawJSONSchemaCompiler(loginMethodSchema),
decoderx.HTTPDecoderAllowedMethods("POST"),
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
return nil, err
}
return &p, nil
}
type decodedMethod struct {
Method string `json:"method" form:"method"`
Address string `json:"address" form:"address"`
}
func (s *Strategy) methodEnabledAndAllowedFromRequest(r *http.Request, f *login.Flow) (*decodedMethod, error) {
var method decodedMethod
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(loginMethodSchema)
if err != nil {
return nil, errors.WithStack(err)
}
if err := decoderx.NewHTTP().Decode(r, &method, compiler,
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderAllowedMethods("POST", "PUT", "PATCH", "GET"),
decoderx.HTTPDecoderSetValidatePayloads(false),
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
return nil, errors.WithStack(err)
}
if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.ID().String(), method.Method, s.deps); err != nil {
return nil, err
}
return &method, nil
}
func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, sess *session.Session) (_ *identity.Identity, err error) {
ctx, span := s.deps.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.code.strategy.Login")
defer otelx.End(span, &err)
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.deps); err != nil {
return nil, err
}
var aal identity.AuthenticatorAssuranceLevel
if s.deps.Config().SelfServiceCodeStrategy(ctx).PasswordlessEnabled {
aal = identity.AuthenticatorAssuranceLevel1
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil {
return nil, err
}
} else if s.deps.Config().SelfServiceCodeStrategy(ctx).MFAEnabled {
aal = identity.AuthenticatorAssuranceLevel2
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel2); err != nil {
return nil, err
}
} else {
return nil, errors.WithStack(flow.ErrStrategyNotResponsible)
}
if err := login.CheckAAL(f, aal); err != nil {
if p, err := s.methodEnabledAndAllowedFromRequest(r, f); errors.Is(err, flow.ErrStrategyNotResponsible) {
if !(s.deps.Config().SelfServiceCodeStrategy(ctx).MFAEnabled && s.deps.Config().SelfServiceCodeStrategy(ctx).MFAEnabled && (p == nil || len(p.Address) > 0)) {
return nil, err
}
// In this special case we only expect `address` to be set.
} else if err != nil {
return nil, err
}
@ -197,7 +239,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow,
}
return nil, nil
case flow.StateEmailSent:
i, err := s.loginVerifyCode(ctx, r, f, &p)
i, err := s.loginVerifyCode(ctx, r, f, &p, sess)
if err != nil {
return nil, s.HandleLoginError(r, f, &p, err)
}
@ -209,40 +251,153 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow,
return nil, s.HandleLoginError(r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unexpected flow state: %s", f.GetState())))
}
func (s *Strategy) findIdentifierInVerifiableAddress(i *identity.Identity, identifier string) (*Address, error) {
verifiableAddress, found := lo.Find(i.VerifiableAddresses, func(va identity.VerifiableAddress) bool {
return va.Value == identifier
})
if !found {
return nil, errors.WithStack(schema.NewUnknownAddressError())
}
// This should be fine for legacy cases because we use `UpgradeCredentials` to normalize all address types prior
// to calling this method.
parsed, err := identity.NewCodeChannel(verifiableAddress.Via)
if err != nil {
return nil, err
}
return &Address{
To: verifiableAddress.Value,
Via: parsed,
}, nil
}
func (s *Strategy) findIdentityForIdentifier(ctx context.Context, identifier string, requestedAAL identity.AuthenticatorAssuranceLevel, session *session.Session) (_ *identity.Identity, _ []Address, err error) {
ctx, span := s.deps.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.code.strategy.findIdentityForIdentifier")
defer otelx.End(span, &err)
if len(identifier) == 0 {
return nil, nil, errors.WithStack(schema.NewRequiredError("#/identifier", "identifier"))
}
identifier = x.GracefulNormalization(identifier)
var addresses []Address
// Step 1: Get the identity
i, cred, isFallback, err := s.findIdentityByIdentifier(ctx, identifier)
if err != nil {
if requestedAAL == identity.AuthenticatorAssuranceLevel2 {
// When using two-factor auth, the identity used to not have any code credential associated. Therefore,
// we need to gracefully handle this flow.
//
// TODO this section should be removed at some point when we are sure that all identities have a code credential.
if errors.Is(err, schema.NewNoCodeAuthnCredentials()) {
fallbackAllowed := s.deps.Config().SelfServiceCodeMethodMissingCredentialFallbackEnabled(ctx)
span.SetAttributes(
attribute.Bool(config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, fallbackAllowed),
)
if !fallbackAllowed {
s.deps.Logger().Warn("The identity does not have a code credential but the fallback mechanism is disabled. Login failed.")
return nil, nil, errors.WithStack(schema.NewNoCodeAuthnCredentials())
}
address, err := s.findIdentifierInVerifiableAddress(session.Identity, identifier)
if err != nil {
return nil, nil, err
}
addresses = []Address{*address}
// We only end up here if the identity's identity schema does not have the `code` identifier extension defined.
// We know that this is the case for a couple of projects who use 2FA with the code credential.
//
// In those scenarios, the identity has no code credential, and the code credential will also not be created by
// the identity schema.
//
// To avoid future regressions, we will not perform an update on the identity here. Effectively, whenever
// the identity would be updated again (and the identity schema + extensions parsed), it would be likely
// that the code credentials are overwritten.
//
// So we accept that the identity in this case will simply not have code credentials, and we will rely on the
// fallback mechanism to authenticate the user.
} else if err != nil {
return nil, nil, err
}
}
return nil, nil, err
} else if isFallback {
fallbackAllowed := s.deps.Config().SelfServiceCodeMethodMissingCredentialFallbackEnabled(ctx)
span.SetAttributes(
attribute.String("identity.id", i.ID.String()),
attribute.String("network.id", i.NID.String()),
attribute.Bool(config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, fallbackAllowed),
)
if !fallbackAllowed {
s.deps.Logger().Warn("The identity does not have a code credential but the fallback mechanism is disabled. Login failed.")
return nil, nil, errors.WithStack(schema.NewNoCodeAuthnCredentials())
}
// We don't have a code credential, but we can still login the user if they have a verified address.
// This is a migration path for old accounts that do not have a code credential.
addresses = []Address{{
To: identifier,
Via: identity.CodeChannelEmail,
}}
// We only end up here if the identity's identity schema does not have the `code` identifier extension defined.
// We know that this is the case for a couple of projects who use 2FA with the code credential.
//
// In those scenarios, the identity has no code credential, and the code credential will also not be created by
// the identity schema.
//
// To avoid future regressions, we will not perform an update on the identity here. Effectively, whenever
// the identity would be updated again (and the identity schema + extensions parsed), it would be likely
// that the code credentials are overwritten.
//
// So we accept that the identity in this case will simply not have code credentials, and we will rely on the
// fallback mechanism to authenticate the user.
} else {
span.SetAttributes(
attribute.String("identity.id", i.ID.String()),
attribute.String("network.id", i.NID.String()),
)
var conf identity.CredentialsCode
if err := json.Unmarshal(cred.Config, &conf); err != nil {
return nil, nil, errors.WithStack(err)
}
for _, address := range conf.Addresses {
addresses = append(addresses, Address{
To: address.Address,
Via: address.Channel,
})
}
}
return i, addresses, nil
}
func (s *Strategy) loginSendCode(ctx context.Context, w http.ResponseWriter, r *http.Request, f *login.Flow, p *updateLoginFlowWithCodeMethod, sess *session.Session) (err error) {
ctx, span := s.deps.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.code.strategy.loginSendCode")
defer otelx.End(span, &err)
if len(p.Identifier) == 0 {
return errors.WithStack(schema.NewRequiredError("#/identifier", "identifier"))
p.Identifier = maybeNormalizeEmail(
cmp.Or(p.Identifier, p.Address),
)
i, addresses, err := s.findIdentityForIdentifier(ctx, p.Identifier, f.RequestedAAL, sess)
if err != nil {
return err
}
p.Identifier = maybeNormalizeEmail(p.Identifier)
var addresses []Address
var i *identity.Identity
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
address, found := lo.Find(sess.Identity.VerifiableAddresses, func(va identity.VerifiableAddress) bool {
return va.Value == p.Identifier
})
if !found {
return errors.WithStack(schema.NewUnknownAddressError())
}
i = sess.Identity
addresses = []Address{{
To: address.Value,
Via: address.Via,
}}
} else {
// Step 1: Get the identity
i, _, err = s.findIdentityByIdentifier(ctx, p.Identifier)
if err != nil {
return err
}
addresses = []Address{{
To: p.Identifier,
Via: identity.CodeAddressType(identity.AddressTypeEmail),
}}
if address, found := lo.Find(addresses, func(item Address) bool {
return item.To == x.GracefulNormalization(p.Identifier)
}); found {
addresses = []Address{address}
}
// Step 2: Delete any previous login codes for this flow ID
@ -287,7 +442,7 @@ func maybeNormalizeEmail(input string) string {
return input
}
func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *login.Flow, p *updateLoginFlowWithCodeMethod) (_ *identity.Identity, err error) {
func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *login.Flow, p *updateLoginFlowWithCodeMethod, sess *session.Session) (_ *identity.Identity, err error) {
ctx, span := s.deps.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.code.strategy.loginVerifyCode")
defer otelx.End(span, &err)
@ -297,27 +452,21 @@ func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *logi
return nil, errors.WithStack(schema.NewRequiredError("#/code", "code"))
}
if len(p.Identifier) == 0 {
return nil, errors.WithStack(schema.NewRequiredError("#/identifier", "identifier"))
}
p.Identifier = maybeNormalizeEmail(
cmp.Or(
p.Address,
p.Identifier, // Older versions of Kratos required us to send the identifier here.
),
)
p.Identifier = maybeNormalizeEmail(p.Identifier)
isFallback := false
var i *identity.Identity
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
// Don't require the code credential if the user already has a session (e.g. this is an MFA flow)
sess, err := s.deps.SessionManager().FetchFromRequest(ctx, r)
if err != nil {
return nil, err
}
if f.RequestedAAL == identity.AuthenticatorAssuranceLevel2 {
i = sess.Identity
} else {
// Step 1: Get the identity
i, isFallback, err = s.findIdentityByIdentifier(ctx, p.Identifier)
if err != nil {
return nil, err
}
i, _, err = s.findIdentityForIdentifier(ctx, p.Identifier, f.RequestedAAL, sess)
}
if err != nil {
return nil, err
}
loginCode, err := s.deps.LoginCodePersister().UseLoginCode(ctx, f.ID, i.ID, p.Code)
@ -333,22 +482,6 @@ func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *logi
return nil, errors.WithStack(err)
}
// the code is correct, if the login happened through a different credential, we need to update the identity
if isFallback {
if err := i.SetCredentialsWithConfig(
s.ID(),
// p.Identifier was normalized prior.
identity.Credentials{Type: s.ID(), Identifiers: []string{p.Identifier}},
&identity.CredentialsCode{UsedAt: sql.NullTime{}},
); err != nil {
return nil, errors.WithStack(err)
}
if err := s.deps.PrivilegedIdentityPool().UpdateIdentity(ctx, i); err != nil {
return nil, errors.WithStack(err)
}
}
// Step 2: The code was correct
f.Active = identity.CredentialsTypeCodeAuth
@ -360,6 +493,14 @@ func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *logi
return nil, errors.WithStack(err)
}
// Step 3: Verify the address
if err := s.verifyAddress(ctx, i, Address{
To: loginCode.Address,
Via: loginCode.AddressType,
}); err != nil {
return nil, err
}
for idx := range i.VerifiableAddresses {
va := i.VerifiableAddresses[idx]
if !va.Verified && loginCode.Address == va.Value {
@ -375,6 +516,31 @@ func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *logi
return i, nil
}
func (s *Strategy) verifyAddress(ctx context.Context, i *identity.Identity, verified Address) error {
for idx := range i.VerifiableAddresses {
va := i.VerifiableAddresses[idx]
if va.Verified {
continue
}
if verified.To != va.Value || string(verified.Via) != va.Via {
continue
}
va.Verified = true
va.Status = identity.VerifiableAddressStatusCompleted
if err := s.deps.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, &va); errors.Is(err, sqlcon.ErrNoRows) {
// This happens when the verified address does not yet exist, for example during registration. In this case we just skip.
continue
} else if err != nil {
return err
}
break
}
return nil
}
func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, f *login.Flow) error {
return s.PopulateMethod(r, f)
}

View File

@ -10,9 +10,12 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/selfservice/strategy/idfirst"
configtesthelpers "github.com/ory/kratos/driver/config/testhelpers"
@ -45,6 +48,7 @@ import (
func createIdentity(ctx context.Context, t *testing.T, reg driver.Registry, withoutCodeCredential bool, moreIdentifiers ...string) *identity.Identity {
t.Helper()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.NID = x.NewUUID()
email := testhelpers.RandomEmail()
ids := fmt.Sprintf(`"email":"%s"`, email)
@ -60,7 +64,7 @@ func createIdentity(ctx context.Context, t *testing.T, reg driver.Registry, with
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\", \"user_handle\": \"rVIFaWRcTTuQLkXFmQWpgA==\"}")},
}
if !withoutCodeCredential {
credentials[identity.CredentialsTypeCodeAuth] = identity.Credentials{Type: identity.CredentialsTypeCodeAuth, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"address_type\": \"email\", \"used_at\": \"2023-07-26T16:59:06+02:00\"}")}
credentials[identity.CredentialsTypeCodeAuth] = identity.Credentials{Type: identity.CredentialsTypeCodeAuth, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"` + email + `"}]}`)}
}
i.Credentials = credentials
@ -73,7 +77,7 @@ func createIdentity(ctx context.Context, t *testing.T, reg driver.Registry, with
i.VerifiableAddresses = va
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
require.NoError(t, reg.IdentityManager().Create(ctx, i))
return i
}
@ -109,11 +113,9 @@ func TestLoginCodeStrategy(t *testing.T) {
ApiTypeNative ApiType = "api"
)
createLoginFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, withoutCodeCredential bool, moreIdentifiers ...string) *state {
createLoginFlowWithIdentity := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, user *identity.Identity) *state {
t.Helper()
identity := createIdentity(ctx, t, reg, withoutCodeCredential, moreIdentifiers...)
var client *http.Client
if apiType == ApiTypeNative {
client = &http.Client{}
@ -140,18 +142,23 @@ func TestLoginCodeStrategy(t *testing.T) {
require.NotEmptyf(t, csrfToken, "could not find csrf_token in: %s", body)
}
loginEmail := gjson.Get(identity.Traits.String(), "email").String()
require.NotEmptyf(t, loginEmail, "could not find the email trait inside the identity: %s", identity.Traits.String())
return &state{
flowID: clientInit.GetId(),
identity: identity,
identityEmail: loginEmail,
client: client,
testServer: public,
flowID: clientInit.GetId(),
identity: user,
client: client,
testServer: public,
}
}
createLoginFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, withoutCodeCredential bool, moreIdentifiers ...string) *state {
t.Helper()
s := createLoginFlowWithIdentity(ctx, t, public, apiType, createIdentity(ctx, t, reg, withoutCodeCredential, moreIdentifiers...))
loginEmail := gjson.Get(s.identity.Traits.String(), "email").String()
require.NotEmptyf(t, loginEmail, "could not find the email trait inside the identity: %s", s.identity.Traits.String())
s.identityEmail = loginEmail
return s
}
type onSubmitAssertion func(t *testing.T, s *state, body string, res *http.Response)
submitLogin := func(ctx context.Context, t *testing.T, s *state, apiType ApiType, vals func(v *url.Values), mustHaveSession bool, submitAssertion onSubmitAssertion) *state {
@ -187,8 +194,8 @@ func TestLoginCodeStrategy(t *testing.T) {
resp, err = s.client.Do(req)
require.NoError(t, err)
require.EqualValues(t, http.StatusOK, resp.StatusCode)
body = string(ioutilx.MustReadAll(resp.Body))
require.EqualValues(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
// SPAs need to be informed that the login has not yet completed using status 400.
// Browser clients will redirect back to the login URL.
@ -241,7 +248,7 @@ func TestLoginCodeStrategy(t *testing.T) {
}, true, nil)
})
t.Run("case=should be able to log in with code", func(t *testing.T) {
t.Run("case=should be able to log in with code sent to email", func(t *testing.T) {
// create login flow
s := createLoginFlow(ctx, t, public, tc.apiType, false)
@ -268,6 +275,148 @@ func TestLoginCodeStrategy(t *testing.T) {
}
})
t.Run("case=should be able to log in legacy cases", func(t *testing.T) {
run := func(t *testing.T, s *state) {
// submit email
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", s.identityEmail)
}, false, nil)
t.Logf("s.body: %s", s.body)
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.identityEmail, "Login to your account")
assert.Contains(t, message.Body, "please login to your account by entering the following code")
loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, loginCode)
// 3. Submit OTP
state := submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("code", loginCode)
}, true, nil)
if tc.apiType == ApiTypeSPA {
assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body)
} else {
assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body)
}
}
initDefault := func(t *testing.T, cf string) *state {
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.NID = x.NewUUID()
// valid fake phone number for libphonenumber
email := testhelpers.RandomEmail()
i.Traits = identity.Traits(fmt.Sprintf(`{"tos": true, "email": "%s"}`, email))
i.Credentials = map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypeCodeAuth: {
Type: identity.CredentialsTypeCodeAuth,
Identifiers: []string{email},
Version: 0,
Config: sqlxx.JSONRawMessage(cf),
},
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentities(ctx, i)) // We explicitly bypass identity validation to test the legacy code path
s := createLoginFlowWithIdentity(ctx, t, public, tc.apiType, i)
s.identityEmail = email
return s
}
t.Run("case=should be able to send address type with spaces", func(t *testing.T) {
run(t,
initDefault(t, `{"address_type": "email ", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`),
)
})
t.Run("case=should be able to send to empty address type", func(t *testing.T) {
run(t,
initDefault(t, `{"address_type": "", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`),
)
})
t.Run("case=should be able to send to empty credentials config", func(t *testing.T) {
run(t,
initDefault(t, `{}`),
)
})
t.Run("case=should be able to send to identity with no credentials at all when fallback is enabled", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, nil)
})
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.NID = x.NewUUID()
email := testhelpers.RandomEmail()
i.Traits = identity.Traits(fmt.Sprintf(`{"tos": true, "email": "%s"}`, email))
i.Credentials = map[identity.CredentialsType]identity.Credentials{
// This makes it possible for our code to find the identity identifier here.
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{}`)},
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentities(ctx, i)) // We explicitly bypass identity validation to test the legacy code path
s := createLoginFlowWithIdentity(ctx, t, public, tc.apiType, i)
s.identityEmail = email
run(t, s)
})
t.Run("case=should fail to send to identity with no credentials at all when fallback is disabled", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, nil)
})
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.NID = x.NewUUID()
email := testhelpers.RandomEmail()
i.Traits = identity.Traits(fmt.Sprintf(`{"tos": true, "email": "%s"}`, email))
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentities(ctx, i)) // We explicitly bypass identity validation to test the legacy code path
s := createLoginFlowWithIdentity(ctx, t, public, tc.apiType, i)
s.identityEmail = email
// submit email
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", s.identityEmail)
}, false, nil)
assert.Contains(t, s.body, "4000035", "Should not find the account")
})
})
t.Run("case=should be able to log in with code to sms and normalize the number", func(t *testing.T) {
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.NID = x.NewUUID()
// valid fake phone number for libphonenumber
phone := "+1 (415) 55526-71"
i.Traits = identity.Traits(fmt.Sprintf(`{"tos": true, "phone_1": "%s"}`, phone))
require.NoError(t, reg.IdentityManager().Create(ctx, i))
t.Cleanup(func() {
require.NoError(t, reg.PrivilegedIdentityPool().DeleteIdentity(ctx, i.ID))
})
s := createLoginFlowWithIdentity(ctx, t, public, tc.apiType, i)
// submit email
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", phone)
}, false, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, x.GracefulNormalization(phone), "Your login code is:")
loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, loginCode)
// 3. Submit OTP
state := submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("code", loginCode)
}, true, nil)
if tc.apiType == ApiTypeSPA {
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body)
assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body)
} else {
assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body)
}
})
t.Run("case=new identities automatically have login with code", func(t *testing.T) {
ctx := context.Background()
@ -602,46 +751,260 @@ func TestLoginCodeStrategy(t *testing.T) {
})
t.Run("case=should be able to get AAL2 session", func(t *testing.T) {
t.Cleanup(testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json")) // doesn't have the code credential
identity := createIdentity(ctx, t, reg, true)
run := func(t *testing.T, withoutCodeCredential bool, overrideCodeCredential *identity.Credentials) (*state, *http.Client) {
user := createIdentity(ctx, t, reg, withoutCodeCredential)
if overrideCodeCredential != nil {
toUpdate := user.Credentials[identity.CredentialsTypeCodeAuth]
if overrideCodeCredential.Config != nil {
toUpdate.Config = overrideCodeCredential.Config
}
if overrideCodeCredential.Identifiers != nil {
toUpdate.Identifiers = overrideCodeCredential.Identifiers
}
user.Credentials[identity.CredentialsTypeCodeAuth] = toUpdate
}
var cl *http.Client
var f *oryClient.LoginFlow
if tc.apiType == ApiTypeNative {
cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, user)
f = testhelpers.InitializeLoginFlowViaAPI(t, cl, public, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email"))
} else {
cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, user)
f = testhelpers.InitializeLoginFlowViaBrowser(t, cl, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email"))
}
body, err := json.Marshal(f)
require.NoError(t, err)
require.Len(t, gjson.GetBytes(body, "ui.nodes.#(group==code)").Array(), 1, "%s", body)
require.Len(t, gjson.GetBytes(body, "ui.messages").Array(), 1, "%s", body)
require.EqualValues(t, gjson.GetBytes(body, "ui.messages.0.id").Int(), text.InfoSelfServiceLoginMFA, "%s", body)
s := &state{
flowID: f.GetId(),
identity: user,
client: cl,
testServer: public,
identityEmail: gjson.Get(user.Traits.String(), "email").String(),
}
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", s.identityEmail)
}, false, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.identityEmail, "Login to your account")
assert.Contains(t, message.Body, "please login to your account by entering the following code")
loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, loginCode)
return submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("code", loginCode)
}, true, nil), cl
}
t.Run("case=correct code credential without fallback works", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") // has code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false) // fallback enabled
_, cl := run(t, true, nil)
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
})
t.Run("case=disabling mfa does not lock out the users", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") // has code identifier
s, cl := run(t, true, nil)
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
email := gjson.GetBytes(s.identity.Traits, "email").String()
s.identityEmail = email
// We change now disable code mfa and enable passwordless instead.
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.mfa_enabled", false)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.passwordless_enabled", true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.passwordless_enabled", false)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.mfa_enabled", true)
})
s = createLoginFlowWithIdentity(ctx, t, public, tc.apiType, s.identity)
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", email)
v.Set("method", "code")
}, false, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Login to your account")
assert.Contains(t, message.Body, "please login to your account by entering the following code")
loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, loginCode)
loginResult := submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("code", loginCode)
}, true, nil)
if tc.apiType == ApiTypeNative {
assert.EqualValues(t, "aal1", gjson.Get(loginResult.body, "session.authenticator_assurance_level").String())
assert.EqualValues(t, "code", gjson.Get(loginResult.body, "session.authentication_methods.#(method==code).method").String())
} else {
// The user should be able to sign in correctly even though, probably, the internal state was aal2 for available AAL.
res, err := s.client.Get(public.URL + session.RouteWhoami)
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode, loginResult.body)
sess := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
assert.EqualValues(t, "aal1", gjson.GetBytes(sess, "authenticator_assurance_level").String())
assert.EqualValues(t, "code", gjson.GetBytes(sess, "authentication_methods.#(method==code).method").String())
}
})
t.Run("case=missing code credential with fallback works when identity schema has the code identifier set", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") // has code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true) // fallback enabled
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
})
_, cl := run(t, false, nil)
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
})
t.Run("case=missing code credential with fallback works even when identity schema has no code identifier set", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json") // missing the code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true) // fallback enabled
t.Cleanup(func() {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json")
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
})
_, cl := run(t, false, nil)
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
})
t.Run("case=legacy code credential with fallback works when identity schema has the code identifier not set", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json") // has code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true) // fallback enabled
t.Cleanup(func() {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") // has code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
})
_, cl := run(t, false, &identity.Credentials{Config: []byte(`{"via":""}`)})
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
})
t.Run("case=legacy code credential with fallback works when identity schema has the code identifier not set", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json") // has code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true) // fallback enabled
t.Cleanup(func() {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") // has code identifier
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
})
for k, credentialsConfig := range []string{
`{"address_type": "email ", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`,
`{"address_type": "email", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`,
`{"address_type": "", "used_at": {"Time": "0001-01-01T00:00:00Z", "Valid": false}}`,
`{"address_type": ""}`,
`{"address_type": "sms"}`,
`{"address_type": "phone"}`,
`{}`,
} {
t.Run(fmt.Sprintf("config=%d", k), func(t *testing.T) {
_, cl := run(t, false, &identity.Credentials{Config: []byte(credentialsConfig)})
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
})
}
})
})
t.Run("case=without via parameter all options are shown", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code-mfa.identity.schema.json")
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
t.Cleanup(func() {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json")
})
var cl *http.Client
var f *oryClient.LoginFlow
user := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
user.NID = x.NewUUID()
email1 := "code-mfa-1" + string(tc.apiType) + "@ory.sh"
email2 := "code-mfa-2" + string(tc.apiType) + "@ory.sh"
phone1 := 4917613213110
if tc.apiType == ApiTypeNative {
cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity)
f = testhelpers.InitializeLoginFlowViaAPI(t, cl, public, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email"))
} else {
cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity)
f = testhelpers.InitializeLoginFlowViaBrowser(t, cl, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email"))
phone1 += 1
} else if tc.apiType == ApiTypeSPA {
phone1 += 2
}
user.Traits = identity.Traits(fmt.Sprintf(`{"email1":"%s","email2":"%s","phone1":"+%d"}`, email1, email2, phone1))
require.NoError(t, reg.IdentityManager().Create(ctx, user))
run := func(t *testing.T, identifierField string, identifier string) {
if tc.apiType == ApiTypeNative {
cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, user)
f = testhelpers.InitializeLoginFlowViaAPI(t, cl, public, false, testhelpers.InitFlowWithAAL("aal2"))
} else {
cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, user)
f = testhelpers.InitializeLoginFlowViaBrowser(t, cl, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2"))
}
body, err := json.Marshal(f)
require.NoError(t, err)
snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes.#(group==code)#").Raw))
require.Len(t, gjson.GetBytes(body, "ui.messages").Array(), 1, "%s", body)
require.EqualValues(t, gjson.GetBytes(body, "ui.messages.0.id").Int(), text.InfoSelfServiceLoginMFA, "%s", body)
s := &state{
flowID: f.GetId(),
identity: user,
client: cl,
testServer: public,
identityEmail: gjson.Get(user.Traits.String(), "email").String(),
}
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Del("method")
v.Set(identifierField, identifier)
}, false, nil)
var message *courier.Message
if !strings.HasPrefix(identifier, "+") {
// email
message = testhelpers.CourierExpectMessage(ctx, t, reg, x.GracefulNormalization(identifier), "Login to your account")
assert.Contains(t, message.Body, "please login to your account by entering the following code")
} else {
// SMS
message = testhelpers.CourierExpectMessage(ctx, t, reg, x.GracefulNormalization(identifier), "Your login code is:")
}
loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, loginCode)
t.Logf("loginCode: %s", loginCode)
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("code", loginCode)
v.Set(identifierField, identifier)
}, true, nil)
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
}
body, err := json.Marshal(f)
require.NoError(t, err)
require.Len(t, gjson.GetBytes(body, "ui.nodes.#(group==code)").Array(), 1)
require.Len(t, gjson.GetBytes(body, "ui.messages").Array(), 1, "%s", body)
require.EqualValues(t, gjson.GetBytes(body, "ui.messages.0.id").Int(), text.InfoSelfServiceLoginMFA, "%s", body)
t.Run("field=identifier-email", func(t *testing.T) {
run(t, "identifier", email1)
})
s := &state{
flowID: f.GetId(),
identity: identity,
client: cl,
testServer: public,
identityEmail: gjson.Get(identity.Traits.String(), "email").String(),
}
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", s.identityEmail)
}, true, nil)
t.Run("field=address-email", func(t *testing.T) {
run(t, "address", email2)
})
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.identityEmail, "Login to your account")
assert.Contains(t, message.Body, "please login to your account by entering the following code")
loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, loginCode)
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("code", loginCode)
}, true, nil)
testhelpers.EnsureAAL(t, cl, public, "aal2", "code")
t.Run("field=address-phone", func(t *testing.T) {
run(t, "address", fmt.Sprintf("+%d", phone1))
})
})
t.Run("case=cannot use different identifier", func(t *testing.T) {
identity := createIdentity(ctx, t, reg, false)
var cl *http.Client
@ -670,9 +1033,9 @@ func TestLoginCodeStrategy(t *testing.T) {
email := testhelpers.RandomEmail()
s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) {
v.Set("identifier", email)
}, true, nil)
}, false, nil)
require.Equal(t, "The address you entered does not match any known addresses in the current account.", gjson.Get(s.body, "ui.messages.0.text").String(), "%s", body)
require.Equal(t, "This account does not exist or has not setup sign in with code.", gjson.Get(s.body, "ui.messages.0.text").String(), "%s", body)
})
t.Run("case=verify initial payload", func(t *testing.T) {
@ -711,28 +1074,7 @@ func TestLoginCodeStrategy(t *testing.T) {
if tc.apiType == ApiTypeNative {
body = []byte(gjson.GetBytes(body, "error").Raw)
}
require.Equal(t, "Trait does not exist in identity schema", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("case=missing via parameter results results in an error", func(t *testing.T) {
identity := createIdentity(ctx, t, reg, false)
var cl *http.Client
var res *http.Response
var err error
if tc.apiType == ApiTypeNative {
cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity)
res, err = cl.Get(public.URL + "/self-service/login/api?aal=aal2")
} else {
cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity)
res, err = cl.Get(public.URL + "/self-service/login/browser?aal=aal2")
}
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
if tc.apiType == ApiTypeNative {
body = []byte(gjson.GetBytes(body, "error").Raw)
}
require.Equal(t, "AAL2 login via code requires the `via` query parameter", gjson.GetBytes(body, "reason").String(), "%s", body)
require.Equal(t, "No value found for trait doesnt_exist in the current identity.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("case=unset trait in identity should lead to an error", func(t *testing.T) {
@ -753,8 +1095,9 @@ func TestLoginCodeStrategy(t *testing.T) {
if tc.apiType == ApiTypeNative {
body = []byte(gjson.GetBytes(body, "error").Raw)
}
require.Equal(t, "No value found for trait email_1 in the current identity", gjson.GetBytes(body, "reason").String(), "%s", body)
require.Equal(t, "No value found for trait email_1 in the current identity.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
})
})
}
@ -767,7 +1110,7 @@ func TestFormHydration(t *testing.T) {
"enabled": true,
"passwordless_enabled": true,
})
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/default.schema.json")
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/code.identity.schema.json")
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeCodeAuth)
require.NoError(t, err)
@ -801,37 +1144,13 @@ func TestFormHydration(t *testing.T) {
"mfa_enabled": true,
})
toMFARequest := func(r *http.Request, f *login.Flow) {
toMFARequest := func(t *testing.T, r *http.Request, f *login.Flow, traits string) {
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
r.URL = &url.URL{Path: "/", RawQuery: "via=email"}
// I only fear god.
r.Header = testhelpers.NewHTTPClientWithArbitrarySessionTokenAndTraits(t, ctx, reg, []byte(`{"email":"foo@ory.sh"}`)).Transport.(*testhelpers.TransportWithHeader).GetHeader()
r.Header = testhelpers.NewHTTPClientWithArbitrarySessionTokenAndTraits(t, ctx, reg, []byte(traits)).Transport.(*testhelpers.TransportWithHeader).GetHeader()
}
t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) {
test := func(t *testing.T, ctx context.Context) {
r, f := newFlow(ctx, t)
toMFARequest(r, f)
r.Header = testhelpers.NewHTTPClientWithArbitrarySessionTokenAndTraits(t, ctx, reg, []byte(`{"email":"foo@ory.sh"}`)).Transport.(*testhelpers.TransportWithHeader).GetHeader()
// We still use the legacy hydrator under the hood here and thus need to set this correctly.
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
r.URL = &url.URL{Path: "/", RawQuery: "via=email"}
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
toSnapshot(t, f)
}
t.Run("case=code is used for 2fa", func(t *testing.T) {
test(t, mfaEnabled)
})
t.Run("case=code is used for passwordless login", func(t *testing.T) {
test(t, passwordlessEnabled)
})
})
t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) {
t.Run("case=code is used for 2fa but request is 1fa", func(t *testing.T) {
r, f := newFlow(mfaEnabled, t)
@ -867,16 +1186,62 @@ func TestFormHydration(t *testing.T) {
})
t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) {
t.Run("using via", func(t *testing.T) {
test := func(t *testing.T, ctx context.Context, email string) {
r, f := newFlow(ctx, t)
toMFARequest(t, r, f, `{"email":"`+email+`"}`)
// We still use the legacy hydrator under the hood here and thus need to set this correctly.
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
r.URL = &url.URL{Path: "/", RawQuery: "via=email"}
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
toSnapshot(t, f)
}
t.Run("case=code is used for 2fa", func(t *testing.T) {
test(t, mfaEnabled, "PopulateLoginMethodSecondFactor-code-mfa-via-2fa@ory.sh")
})
t.Run("case=code is used for passwordless login", func(t *testing.T) {
test(t, passwordlessEnabled, "PopulateLoginMethodSecondFactor-code-mfa-via-passwordless@ory.sh")
})
})
t.Run("without via", func(t *testing.T) {
test := func(t *testing.T, ctx context.Context, traits string) {
r, f := newFlow(ctx, t)
toMFARequest(t, r, f, traits)
// We still use the legacy hydrator under the hood here and thus need to set this correctly.
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
r.URL = &url.URL{Path: "/"}
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
toSnapshot(t, f)
}
t.Run("case=code is used for 2fa", func(t *testing.T) {
ctx = testhelpers.WithDefaultIdentitySchema(mfaEnabled, "file://./stub/code-mfa.identity.schema.json")
test(t, ctx, `{"email1":"PopulateLoginMethodSecondFactor-no-via-2fa-0@ory.sh","email2":"PopulateLoginMethodSecondFactor-no-via-2fa-1@ory.sh","phone1":"+4917655138291"}`)
})
t.Run("case=code is used for passwordless login", func(t *testing.T) {
ctx = testhelpers.WithDefaultIdentitySchema(passwordlessEnabled, "file://./stub/code-mfa.identity.schema.json")
test(t, ctx, `{"email1":"PopulateLoginMethodSecondFactor-no-via-passwordless-0@ory.sh","email2":"PopulateLoginMethodSecondFactor-no-via-passwordless-1@ory.sh","phone1":"+4917655138292"}`)
})
})
t.Run("case=code is used for 2fa and request is 2fa", func(t *testing.T) {
r, f := newFlow(mfaEnabled, t)
toMFARequest(r, f)
toMFARequest(t, r, f, `{"email":"foo@ory.sh"}`)
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
toSnapshot(t, f)
})
t.Run("case=code is used for passwordless login and request is 2fa", func(t *testing.T) {
r, f := newFlow(passwordlessEnabled, t)
toMFARequest(r, f)
toMFARequest(t, r, f, `{"email":"foo@ory.sh"}`)
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
toSnapshot(t, f)
})
@ -885,7 +1250,7 @@ func TestFormHydration(t *testing.T) {
t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) {
t.Run("case=code is used for 2fa and request is 2fa with refresh", func(t *testing.T) {
r, f := newFlow(mfaEnabled, t)
toMFARequest(r, f)
toMFARequest(t, r, f, `{"email":"foo@ory.sh"}`)
f.Refresh = true
require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f))
toSnapshot(t, f)
@ -893,7 +1258,7 @@ func TestFormHydration(t *testing.T) {
t.Run("case=code is used for passwordless login and request is 2fa with refresh", func(t *testing.T) {
r, f := newFlow(passwordlessEnabled, t)
toMFARequest(r, f)
toMFARequest(t, r, f, `{"email":"foo@ory.sh"}`)
f.Refresh = true
require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f))
toSnapshot(t, f)

View File

@ -0,0 +1,57 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package code
import (
"encoding/json"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
)
func FindAllIdentifiers(i *identity.Identity) (result []Address) {
for _, a := range i.VerifiableAddresses {
if len(a.Via) == 0 || len(a.Value) == 0 {
continue
}
result = append(result, Address{Via: identity.CodeChannel(a.Via), To: a.Value})
}
return result
}
func FindCodeAddressCandidates(i *identity.Identity, fallbackEnabled bool) (result []Address, found bool, _ error) {
// If no hint was given, we show all OTP addresses from the credentials.
creds, ok := i.GetCredentials(identity.CredentialsTypeCodeAuth)
if !ok {
if !fallbackEnabled {
// Without a fallback and with no credentials found, we can't really do a lot and exit early.
return nil, false, nil
}
return FindAllIdentifiers(i), true, nil
} else {
var conf identity.CredentialsCode
if len(creds.Config) > 0 {
if err := json.Unmarshal(creds.Config, &conf); err != nil {
return nil, false, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to unmarshal credentials config: %s", err))
}
}
if len(conf.Addresses) == 0 {
if !fallbackEnabled {
// Without a fallback and with no credentials found, we can't really do a lot and exit early.
return nil, false, nil
}
return FindAllIdentifiers(i), true, nil
}
return lo.Map(conf.Addresses, func(item identity.CredentialsCodeAddress, _ int) Address {
return Address{Via: item.Channel, To: item.Address}
}), true, nil
}
}

View File

@ -0,0 +1,161 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package code
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/identity"
)
func TestFindAllIdentifiers(t *testing.T) {
tests := []struct {
name string
input *identity.Identity
expected []Address
}{
{
name: "valid verifiable addresses",
input: &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{
{Via: "email", Value: "user@example.com"},
{Via: "sms", Value: "+1234567890"},
},
},
expected: []Address{
{Via: identity.CodeChannel("email"), To: "user@example.com"},
{Via: identity.CodeChannel("sms"), To: "+1234567890"},
},
},
{
name: "empty verifiable addresses",
input: &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{},
},
},
{
name: "verifiable address with empty fields",
input: &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{
{Via: "", Value: ""},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FindAllIdentifiers(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFindCodeAddressCandidates(t *testing.T) {
tests := []struct {
name string
input *identity.Identity
fallbackEnabled bool
expected []Address
found bool
wantErr bool
}{
{
name: "valid credentials with addresses",
input: &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypeCodeAuth: {
Config: []byte(`{"addresses":[{"channel":"email","address":"user@example.com"},{"channel":"sms","address":"+1234567890"}]}`),
},
},
},
fallbackEnabled: false,
expected: []Address{
{Via: identity.CodeChannel("email"), To: "user@example.com"},
{Via: identity.CodeChannel("sms"), To: "+1234567890"},
},
found: true,
wantErr: false,
},
{
name: "no credentials, fallback enabled",
input: &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{
{Via: "email", Value: "user@example.com"},
{Via: "sms", Value: "+1234567890"},
},
},
fallbackEnabled: true,
expected: []Address{
{Via: identity.CodeChannel("email"), To: "user@example.com"},
{Via: identity.CodeChannel("sms"), To: "+1234567890"},
},
found: true,
wantErr: false,
},
{
name: "no credentials, fallback disabled",
input: &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{
{Via: "email", Value: "user@example.com"},
{Via: "sms", Value: "+1234567890"},
},
},
fallbackEnabled: false,
expected: nil,
found: false,
wantErr: false,
},
{
name: "invalid credentials config",
input: &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypeCodeAuth: {
Config: []byte(`invalid`),
},
},
},
fallbackEnabled: false,
expected: nil,
found: false,
wantErr: true,
},
{
name: "invalid credentials config, fallback enabled, verifiable addresses exist",
input: &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypeCodeAuth: {
Config: []byte(`invalid`),
},
},
VerifiableAddresses: []identity.VerifiableAddress{
{Via: "email", Value: "user@example.com"},
{Via: "sms", Value: "+1234567890"},
},
},
fallbackEnabled: true,
expected: []Address{
{Via: identity.CodeChannel("email"), To: "user@example.com"},
{Via: identity.CodeChannel("sms"), To: "+1234567890"},
},
found: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, found, err := FindCodeAddressCandidates(tt.input, tt.fallbackEnabled)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.found, found)
}
})
}
}

View File

@ -190,9 +190,9 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request,
return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err))
}
sess, err := session.NewActiveSession(r, id, s.deps.Config(), time.Now().UTC(),
identity.CredentialsTypeRecoveryCode, identity.AuthenticatorAssuranceLevel1)
if err != nil {
sess := session.NewInactiveSession()
sess.CompletedLoginFor(identity.CredentialsTypeRecoveryCode, identity.AuthenticatorAssuranceLevel1)
if err := s.deps.SessionManager().ActivateSession(r, sess, id, time.Now().UTC()); err != nil {
return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err))
}

View File

@ -15,6 +15,8 @@ import (
"testing"
"time"
confighelpers "github.com/ory/kratos/driver/config/testhelpers"
"github.com/davecgh/go-spew/spew"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
@ -520,10 +522,10 @@ func TestRecovery(t *testing.T) {
}
req := httptest.NewRequest("GET", "/sessions/whoami", nil)
session, err := session.NewActiveSession(
req,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive},
testhelpers.NewSessionLifespanProvider(time.Hour),
req.WithContext(confighelpers.WithConfigValue(ctx, config.ViperKeySessionLifespan, time.Hour))
session, err := testhelpers.NewActiveSession(req,
reg,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive, NID: x.NewUUID()},
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -632,7 +634,7 @@ func TestRecovery(t *testing.T) {
id := createIdentityToRecover(t, reg, email)
req := httptest.NewRequest("GET", "/sessions/whoami", nil)
sess, err := session.NewActiveSession(req, id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess, err := testhelpers.NewActiveSession(req, reg, id, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))
@ -1360,11 +1362,12 @@ func TestRecovery_WithContinueWith(t *testing.T) {
f = testhelpers.InitializeRecoveryFlowViaBrowser(t, client, isSPA, public, nil)
}
req := httptest.NewRequest("GET", "/sessions/whoami", nil)
req.WithContext(confighelpers.WithConfigValue(ctx, config.ViperKeySessionLifespan, time.Hour))
session, err := session.NewActiveSession(
session, err := testhelpers.NewActiveSession(
req,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive},
testhelpers.NewSessionLifespanProvider(time.Hour),
reg,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive, NID: x.NewUUID()},
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
@ -1463,7 +1466,7 @@ func TestRecovery_WithContinueWith(t *testing.T) {
email := testhelpers.RandomEmail()
id := createIdentityToRecover(t, reg, email)
otherSession, err := session.NewActiveSession(httptest.NewRequest("GET", "/sessions/whoami", nil), id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
otherSession, err := testhelpers.NewActiveSession(httptest.NewRequest("GET", "/sessions/whoami", nil), reg, id, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, otherSession))

View File

@ -5,9 +5,11 @@ package code
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/x/otelx"
@ -91,30 +93,10 @@ func (s *Strategy) PopulateRegistrationMethod(r *http.Request, rf *registration.
return s.PopulateMethod(r, rf)
}
type options func(*identity.Identity) error
func withCredentials(via identity.CodeAddressType, usedAt sql.NullTime) options {
return func(i *identity.Identity) error {
return i.SetCredentialsWithConfig(identity.CredentialsTypeCodeAuth, identity.Credentials{Type: identity.CredentialsTypePassword, Identifiers: []string{}}, &identity.CredentialsCode{AddressType: via, UsedAt: usedAt})
}
}
func (s *Strategy) handleIdentityTraits(ctx context.Context, f *registration.Flow, traits, transientPayload json.RawMessage, i *identity.Identity, opts ...options) error {
f.TransientPayload = transientPayload
if len(traits) == 0 {
traits = json.RawMessage("{}")
}
// we explicitly set the Code credentials type
i.Traits = identity.Traits(traits)
if err := i.SetCredentialsWithConfig(s.ID(), identity.Credentials{Type: s.ID(), Identifiers: []string{}}, &identity.CredentialsCode{UsedAt: sql.NullTime{}}); err != nil {
return err
}
for _, opt := range opts {
if err := opt(i); err != nil {
return err
}
func (s *Strategy) validateTraits(ctx context.Context, traits json.RawMessage, i *identity.Identity) error {
i.Traits = []byte("{}")
if gjson.ValidBytes(traits) {
i.Traits = identity.Traits(traits)
}
// Validate the identity
@ -125,18 +107,26 @@ func (s *Strategy) handleIdentityTraits(ctx context.Context, f *registration.Flo
return nil
}
func (s *Strategy) getCredentialsFromTraits(ctx context.Context, f *registration.Flow, i *identity.Identity, traits, transientPayload json.RawMessage) (*identity.Credentials, error) {
if err := s.handleIdentityTraits(ctx, f, traits, transientPayload, i); err != nil {
return nil, errors.WithStack(err)
func (s *Strategy) validateAndGetCredentialsFromTraits(ctx context.Context, i *identity.Identity, traits json.RawMessage) (*identity.Credentials, *identity.CredentialsCode, error) {
if err := s.validateTraits(ctx, traits, i); err != nil {
return nil, nil, errors.WithStack(err)
}
cred, ok := i.GetCredentials(identity.CredentialsTypeCodeAuth)
if !ok {
return nil, errors.WithStack(schema.NewMissingIdentifierError())
} else if len(cred.Identifiers) == 0 {
return nil, errors.WithStack(schema.NewMissingIdentifierError())
return nil, nil, errors.WithStack(schema.NewMissingIdentifierError())
} else if len(strings.Join(cred.Identifiers, "")) == 0 {
return nil, nil, errors.WithStack(schema.NewMissingIdentifierError())
}
return cred, nil
var conf identity.CredentialsCode
if len(cred.Config) > 0 {
if err := json.Unmarshal(cred.Config, &conf); err != nil {
return nil, nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to unmarshal credentials config: %s", err))
}
}
return cred, &conf, nil
}
func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) {
@ -184,7 +174,7 @@ func (s *Strategy) registrationSendEmail(ctx context.Context, w http.ResponseWri
// Create the Registration code
// Step 1: validate the identity's traits
cred, err := s.getCredentialsFromTraits(ctx, f, i, p.Traits, p.TransientPayload)
_, conf, err := s.validateAndGetCredentialsFromTraits(ctx, i, p.Traits)
if err != nil {
return err
}
@ -196,9 +186,10 @@ func (s *Strategy) registrationSendEmail(ctx context.Context, w http.ResponseWri
// Step 3: Get the identity email and send the code
var addresses []Address
for _, identifier := range cred.Identifiers {
addresses = append(addresses, Address{To: identifier, Via: identity.AddressTypeEmail})
for _, address := range conf.Addresses {
addresses = append(addresses, Address{To: address.Address, Via: address.Channel})
}
// kratos only supports `email` identifiers at the moment with the code method
// this is validated in the identity validation step above
if err := s.deps.CodeSender().SendCode(ctx, f, i, addresses...); err != nil {
@ -246,7 +237,7 @@ func (s *Strategy) registrationVerifyCode(ctx context.Context, f *registration.F
// Step 1: Re-validate the identity's traits
// this is important since the client could have switched out the identity's traits
// this method also returns the credentials for a temporary identity
cred, err := s.getCredentialsFromTraits(ctx, f, i, p.Traits, p.TransientPayload)
cred, _, err := s.validateAndGetCredentialsFromTraits(ctx, i, p.Traits)
if err != nil {
return err
}
@ -267,9 +258,12 @@ func (s *Strategy) registrationVerifyCode(ctx context.Context, f *registration.F
return errors.WithStack(err)
}
// Step 4: The code was correct, populate the Identity credentials and traits
if err := s.handleIdentityTraits(ctx, f, p.Traits, p.TransientPayload, i, withCredentials(registrationCode.AddressType, registrationCode.UsedAt)); err != nil {
return errors.WithStack(err)
// Step 4: Verify the address
if err := s.verifyAddress(ctx, i, Address{
To: registrationCode.Address,
Via: registrationCode.AddressType,
}); err != nil {
return err
}
// since nothing has errored yet, we can assume that the code is correct

View File

@ -224,7 +224,7 @@ func TestRegistrationCodeStrategy(t *testing.T) {
return s
}
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, http.StatusOK, resp.StatusCode, body)
verifiableAddress, err := reg.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, identity.VerifiableAddressTypeEmail, s.email)
require.NoError(t, err)
@ -348,7 +348,7 @@ func TestRegistrationCodeStrategy(t *testing.T) {
require.NotEmpty(t, attr)
val := gjson.Get(attr, "#(attributes.type==hidden).attributes.value").String()
require.Equal(t, "code", val)
require.Equal(t, "code", val, body)
})
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Complete your account registration")
@ -526,8 +526,11 @@ func TestRegistrationCodeStrategy(t *testing.T) {
} {
t.Run("test="+tc.d, func(t *testing.T) {
t.Run("case=should fail when schema does not contain the `code` extension", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json")
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/no-code.schema.json")
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json")
})

View File

@ -5,8 +5,14 @@ package code_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
confighelpers "github.com/ory/kratos/driver/config/testhelpers"
"github.com/ory/kratos/internal"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/internal/testhelpers"
@ -74,3 +80,123 @@ func TestMaskAddress(t *testing.T) {
})
}
}
func TestCountActiveCredentials(t *testing.T) {
_, reg := internal.NewFastRegistryWithMocks(t)
strategy := code.NewStrategy(reg)
ctx := context.Background()
t.Run("first factor", func(t *testing.T) {
for k, tc := range []struct {
in map[identity.CredentialsType]identity.Credentials
expected int
passwordlessEnabled bool
enabled bool
}{
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
passwordlessEnabled: false,
enabled: true,
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
passwordlessEnabled: true,
enabled: false,
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{},
passwordlessEnabled: true,
enabled: true,
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{}`),
}},
passwordlessEnabled: true,
enabled: true,
expected: 1,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
ctx := confighelpers.WithConfigValue(ctx, "selfservice.methods.code.passwordless_enabled", tc.passwordlessEnabled)
ctx = confighelpers.WithConfigValue(ctx, "selfservice.methods.code.enabled", tc.enabled)
cc := map[identity.CredentialsType]identity.Credentials{}
for _, c := range tc.in {
cc[c.Type] = c
}
actual, err := strategy.CountActiveFirstFactorCredentials(ctx, cc)
require.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}
})
t.Run("second factor", func(t *testing.T) {
for k, tc := range []struct {
in map[identity.CredentialsType]identity.Credentials
expected int
mfaEnabled bool
enabled bool
}{
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
mfaEnabled: false,
enabled: true,
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
mfaEnabled: true,
enabled: false,
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{},
mfaEnabled: true,
enabled: true,
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{}`),
}},
mfaEnabled: true,
enabled: true,
expected: 1,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
ctx := confighelpers.WithConfigValue(ctx, "selfservice.methods.code.mfa_enabled", tc.mfaEnabled)
ctx = confighelpers.WithConfigValue(ctx, "selfservice.methods.code.enabled", tc.enabled)
cc := map[identity.CredentialsType]identity.Credentials{}
for _, c := range tc.in {
cc[c.Type] = c
}
actual, err := strategy.CountActiveMultiFactorCredentials(ctx, cc)
require.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}
})
}

View File

@ -0,0 +1,50 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email1": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "email"
}
}
}
},
"email2": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "email"
}
}
}
},
"phone1": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
}
}
}
},
"required": []
}
}
}

View File

@ -58,13 +58,29 @@
}
}
},
"phone_1": {
"type": "string",
"format": "tel",
"title": "Phone",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
},
"verification": {
"via": "sms"
}
}
},
"tos": {
"type": "boolean",
"title": "Tos",
"description": "Please accept the terms and conditions"
}
},
"required": ["email", "tos"]
"required": []
}
}
}

View File

@ -13,6 +13,10 @@
"credentials": {
"password": {
"identifier": true
},
"code": {
"identifier": true,
"via": "email"
}
},
"verification": {

Some files were not shown because too many files have changed in this diff Show More