mirror of https://github.com/ory/kratos
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:
parent
7945104750
commit
123e80782b
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "password",
|
||||
"version": 0,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "password",
|
||||
"version": 0,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "webauthn",
|
||||
"version": 0,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "password",
|
||||
"version": 0,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "webauthn",
|
||||
"version": 0,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "webauthn",
|
||||
"version": 0,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"credentials": {
|
||||
"webauthn": {
|
||||
"type": "webauthn",
|
||||
"identifiers": null,
|
||||
"identifiers": [],
|
||||
"config": {
|
||||
"credentials": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ package identity
|
|||
|
||||
const (
|
||||
AddressTypeEmail = "email"
|
||||
AddressTypeSMS = "sms"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(`{}`)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@
|
|||
"identifier": {
|
||||
"type": "string"
|
||||
},
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"resend": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue